Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions .github/workflows/hosted-dev-testflight.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# LOCAL REVIEW EXAMPLE: no signing, environment or upload can run.
# REVIEW CANDIDATE: the protected job can run on an approved main dispatch;
# signing and cleanup scripts remain guarded and upload is disabled.
name: Kin Dev hosted TestFlight review
on:
workflow_dispatch:
Expand Down Expand Up @@ -26,9 +27,11 @@ jobs:
- name: Require the selected dispatch commit
env:
SOURCE_SHA: ${{ inputs.source_sha }}
TRIAL_MODE: ${{ inputs.mode }}
run: |
[[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]
test "$SOURCE_SHA" = "$GITHUB_SHA"
test "$TRIAL_MODE" = signing_only
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.sha }}
Expand All @@ -40,28 +43,52 @@ jobs:
uses: ./.github/workflows/ci.yml
permissions:
contents: read
signing:
# BOTH this condition and the script's independent guard are disabled.
# No environment reference is present; a missing environment can be
# automatically created without protection. Owner review/configuration and
# a follow-up change are mandatory before any credential-bearing use.
if: ${{ false }}
signing-preflight:
if: ${{ inputs.mode == 'signing_only' }}
needs: [source, tests]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.sha }}
persist-credentials: false
- name: Check the existing signing environment without secrets
timeout-minutes: 1
run: |
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
test -z "$(git status --porcelain)"
python3 scripts/check-signing-environment.py
signing:
# REVIEW CANDIDATE ONLY: activate only after environment and runner verification.
# Upload remains independently disabled and requires separate approval.
if: ${{ inputs.mode == 'signing_only' }}
needs: [source, tests, signing-preflight]
environment: kin-dev-signing
runs-on: xcode-27
# 5 checkout + 40 signing + 20 upload + 5 cleanup + 5 evidence + 10 slack.
# 5 checkout + 1 preflight + 40 signing + 20 upload + 5 cleanup + 5 evidence + 10 slack.
# This reserves time under normal step deadlines, not runner loss or cancellation.
timeout-minutes: 85
timeout-minutes: 86
permissions:
contents: read
env:
DEVELOPER_DIR: /Applications/Xcode_27.app/Contents/Developer
KIN_DEV_SIGNING_ONLY: '1'
PATH: /usr/bin:/bin:/usr/sbin:/sbin
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
timeout-minutes: 5
with:
ref: ${{ github.sha }}
persist-credentials: false
- name: Recheck environment before secret-bearing step
timeout-minutes: 1
run: |
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
test -z "$(git status --porcelain)"
python3 scripts/check-signing-environment.py
- name: Archive and verify Kin Dev (disabled)
timeout-minutes: 40
env:
Expand Down Expand Up @@ -89,7 +116,7 @@ jobs:
timeout-minutes: 5
env:
PROFILE_UUID: ${{ vars.KIN_PROFILE_UUID }}
run: bash scripts/kin-sign-cleanup.sh
run: bash scripts/kin-sign-dev-cleanup.sh
- name: Create fixed-field outcome evidence (disabled)
if: ${{ always() && inputs.mode == 'upload_trial' }}
timeout-minutes: 2
Expand Down
16 changes: 11 additions & 5 deletions Development/HostedKinDev.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Disabled hosted Kin Dev delivery proposal

This is separate from PR #61 and the provisional 1.3 metadata. No signing or
upload path is active. The caller workflow has an unconditional false
signing job and no environment reference. The signing and cleanup scripts
This is separate from PR #61. The protected signing job is conditional on a
main-branch signing-only dispatch and references the configured environment.
The signing and cleanup scripts
both exit immediately. The uploader CLI also exits immediately. These guards are
intentional and must not be removed until reviewed and specifically approved.

Expand Down Expand Up @@ -51,7 +51,7 @@ individual-key support, approved private-key handoff and actual processing API
behavior remain unresolved. A team provisioning API key is not
required merely to do manual signing.

## Proposed protection and approvals, not configured
## Protection and approvals

Use a pre-existing explicitly verified environment restricted to the protected
main branch, with Danny as required reviewer and administrator bypass disabled
Expand All @@ -72,7 +72,7 @@ establish trust. A self-hosted or unverified runner must not receive secrets.
GitHub describes fresh GitHub-hosted VMs at
https://docs.github.com/en/actions/concepts/runners/github-hosted-runners.

The 85-minute example budget reserves 5 minutes for checkout, 40 for signing,
The 86-minute example budget reserves 5 minutes for checkout, 1 for preflight, 40 for signing,
20 for upload, 5 for cleanup, 5 for evidence and 10 slack. Each bounded step
must actually return within its budget for that reserve to apply; cancellation,
SIGKILL, detached processes and runner loss can still defeat cleanup. Signing
Expand Down Expand Up @@ -126,3 +126,9 @@ Apple altool notarization key location (not proof of upload compatibility): http
Apple keys: https://developer.apple.com/documentation/AppStoreConnectAPI/creating-api-keys-for-app-store-connect-api
Apple TestFlight: https://developer.apple.com/help/app-store-connect/test-a-beta-version/add-internal-testers/
GitHub environments: https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments

### Pre-secret checks and limits

The signing-only candidate checks the existing environment's public GitHub response before the protected job and again before the secret-bearing step. It checks the environment ID, required reviewer, self-review setting and selected main branch rule. An absent optional bypass or branch-type response field is reported as unverified and requires owner inspection in GitHub settings. Before a trial, the owner must independently confirm those settings, the intended provider-hosted runner, and that no repository or organization secrets use the same names. A missing environment can otherwise be recreated without protections and GitHub can resolve higher-scope secrets. These checks are snapshots, not an atomic guarantee against an administrator changing settings between checks and secret use.

The runner label and past hosted-runner logs are evidence, not attestation of a future runner. `security import -P` passes the password as a process argument; Apple describes this as insecure, and the installed tool documents no stdin password option. Same-user processes can potentially observe it. Cleanup reserves time and attempts removal but cannot guarantee removal after runner loss or SIGKILL. An actual signing-only trial and its credential placement require separate approval; it deletes the IPA and does not upload it.
12 changes: 6 additions & 6 deletions Kin.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Kin;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 7;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Kin;
Expand All @@ -422,7 +422,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.intriguingideas.Kin;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
Expand All @@ -440,7 +440,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Kin;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 7;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Kin;
Expand All @@ -454,7 +454,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.intriguingideas.Kin;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
Expand Down Expand Up @@ -618,7 +618,7 @@
CODE_SIGN_STYLE = Manual;
CODE_SIGN_ENTITLEMENTS = Development/KinDev.entitlements;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 7;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Kin Dev";
Expand All @@ -632,7 +632,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.intriguingideas.Kin.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
Expand Down
94 changes: 94 additions & 0 deletions scripts/check-signing-environment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Bounded read-only GitHub checks before the proposed secret-bearing step.

This detects common configuration drift; it is not an atomic deployment-policy
attestation. The owner must also verify unsupported/omitted settings in GitHub.
"""
import json
import urllib.error
import urllib.request

BASE = 'https://api.github.com/repos/dannydover/Kin/environments/kin-dev-signing'
EXPECTED_ENVIRONMENT_ID = 26652988418
EXPECTED_REVIEWER_ID = 136573
MAX_BYTES = 131072


def _unique(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError('duplicate JSON key')
result[key] = value
return result


class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None


def fetch(url):
if url not in (BASE, BASE + '/deployment-branch-policies?per_page=100'):
raise ValueError('unexpected endpoint')
request = urllib.request.Request(url, headers={
'Accept': 'application/vnd.github+json', 'User-Agent': 'kin-signing-preflight'})
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect())
with opener.open(request, timeout=10) as response:
if response.status != 200:
raise ValueError('unexpected response')
data = response.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise ValueError('response too large')
return json.loads(data, object_pairs_hook=_unique)


def validate(environment, policies):
if not isinstance(environment, dict) or not isinstance(policies, dict):
raise ValueError('malformed response')
if environment.get('id') != EXPECTED_ENVIRONMENT_ID or environment.get('name') != 'kin-dev-signing':
raise ValueError('environment missing or replaced')
policy = environment.get('deployment_branch_policy')
if not isinstance(policy, dict) or policy.get('protected_branches') is not False or policy.get('custom_branch_policies') is not True:
raise ValueError('unexpected branch policy')
rules = environment.get('protection_rules')
if not isinstance(rules, list):
raise ValueError('missing protection rules')
reviews = [r for r in rules if isinstance(r, dict) and r.get('type') == 'required_reviewers']
if len(reviews) != 1 or reviews[0].get('prevent_self_review') is not False:
raise ValueError('unexpected review policy')
reviewers = reviews[0].get('reviewers')
if not isinstance(reviewers, list) or len(reviewers) != 1:
raise ValueError('unexpected reviewers')
reviewer = reviewers[0]
if not isinstance(reviewer, dict) or reviewer.get('type') != 'User' or not isinstance(reviewer.get('reviewer'), dict):
raise ValueError('unexpected reviewer')
if reviewer['reviewer'].get('id') != EXPECTED_REVIEWER_ID or reviewer['reviewer'].get('login') != 'dannydover':
raise ValueError('unexpected reviewer')
branch_rules = policies.get('branch_policies')
if policies.get('total_count') != 1 or not isinstance(branch_rules, list) or len(branch_rules) != 1:
raise ValueError('unexpected branch policy count')
branch = branch_rules[0]
if not isinstance(branch, dict) or branch.get('name') != 'main' or branch.get('type', 'branch') != 'branch':
raise ValueError('unexpected branch rule')
# The public response may include this field, but it is not in GitHub's
# published response schema. If absent, the owner must verify it in the UI.
bypass = environment.get('can_admins_bypass')
if bypass is not None and bypass is not False:
raise ValueError('administrator bypass enabled or malformed')
return {'admin_bypass_verified': bypass is False,
'branch_type_verified': branch.get('type') == 'branch'}


def main():
try:
result = validate(fetch(BASE), fetch(BASE + '/deployment-branch-policies?per_page=100'))
except Exception:
raise SystemExit('Signing environment preflight failed; no secrets used.')
print('Signing environment public checks passed; recheck owner settings before approval.')
if not all(result.values()):
print('Some optional response fields were absent; owner UI verification remains required.')


if __name__ == '__main__':
main()
65 changes: 65 additions & 0 deletions scripts/kin-sign-dev-cleanup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/bin/bash
# Guard direct invocation outside the separately approved signing-only job.
if [[ "${KIN_DEV_SIGNING_ONLY:-}" != 1 ]]; then
printf '%s\n' 'Kin Dev cleanup requires the approved signing-only job.' >&2
exit 1
fi

set +x
set -euo pipefail
[[ "${GITHUB_RUN_ID:-}" =~ ^[0-9]+$ && "${GITHUB_RUN_ATTEMPT:-}" =~ ^[0-9]+$ ]]
work="$RUNNER_TEMP/kin-sign-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
if [[ ! -e "$work" && ! -L "$work" ]]; then
printf '%s\n' 'Cleanup cannot be verified: workspace unavailable.' >&2
exit 1
fi
test -d "$work" && test ! -L "$work"
failed=0
if [[ -L "$work/search-list-original" ]]; then
failed=1
elif [[ -f "$work/search-list-original" ]]; then
if python3 -c 'import pathlib,shlex,sys; p=shlex.split(pathlib.Path(sys.argv[1]).read_text()); assert all("\n" not in x for x in p); print("\n".join(p))' "$work/search-list-original" > "$work/search-list-restore"; then
old=()
while IFS= read -r path; do old+=("$path"); done < "$work/search-list-restore"
if (( ${#old[@]} > 0 )); then
security list-keychains -d user -s "${old[@]}" >/dev/null 2>&1 || failed=1
if ! security list-keychains -d user > "$work/search-list-current" 2>/dev/null ||
! python3 -c 'import pathlib,shlex,sys; a,b=(shlex.split(pathlib.Path(x).read_text()) for x in sys.argv[1:]); assert a and a == b' "$work/search-list-original" "$work/search-list-current"; then failed=1; fi
else failed=1; fi
else failed=1; fi
else
failed=1
fi
if [[ -L "$work/signing.keychain-db" ]]; then
failed=1
elif [[ -e "$work/signing.keychain-db" ]]; then
security delete-keychain "$work/signing.keychain-db" >/dev/null 2>&1 || failed=1
if [[ -e "$work/signing.keychain-db" || -L "$work/signing.keychain-db" ]]; then failed=1; fi
fi
if [[ -L "$work/owned-profile" ]]; then
failed=1
elif [[ -f "$work/owned-profile" ]]; then
IFS= read -r profile < "$work/owned-profile" || failed=1
if [[ ! "${PROFILE_UUID:-}" =~ ^[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}$ ]] ||
[[ "${profile:-}" != "$HOME/Library/MobileDevice/Provisioning Profiles/$PROFILE_UUID.mobileprovision" ]] ||
[[ -L "$profile" || ! -f "$profile" ]] ||
[[ -L "$work/profile-identity" || ! -f "$work/profile-identity" || -L "$work/profile-sha256" || ! -f "$work/profile-sha256" ]] ||
[[ "$(stat -f '%d:%i' "$profile")" != "$(cat "$work/profile-identity")" ]] ||
[[ "$(shasum -a 256 "$profile" | cut -d ' ' -f1)" != "$(cat "$work/profile-sha256")" ]]; then
failed=1
elif rm -f -- "$profile" && [[ ! -e "$profile" && ! -L "$profile" ]]; then
rm -f -- "$work/profile-staging" "$work/profile-identity" "$work/profile-sha256" "$work/owned-profile" || failed=1
else failed=1; fi
elif [[ -e "$work/profile-staging" || -L "$work/profile-staging" || -e "$work/owned-profile" ]]; then
# Do not guess whether a partly staged or pre-existing profile is ours.
failed=1
fi
# Remove secrets, raw logs and signed outputs even on failure. Keep ownership
# and search-list evidence, and any undeleted keychain, for safe recovery.
rm -f -- "$work/identity.p12" "$work/security.log" "$work/archive.log" "$work/export.log" "$work/verify.log" "$work/entitlements.plist" "$work/ExportOptions.plist" || failed=1
rm -f -- "$work"/codesign[0-9]* || failed=1
rm -rf -- "$work/Kin.xcarchive" "$work/DerivedData" "$work/export" "$work/inspect" || failed=1
if (( failed )); then printf '%s\n' 'Cleanup incomplete; private recovery evidence retained.' >&2; exit 1; fi
rm -rf -- "$work" || failed=1
if [[ -e "$work" || -L "$work" ]]; then failed=1; fi
if (( failed )); then printf '%s\n' 'Cleanup incomplete; private recovery evidence may remain.' >&2; exit 1; fi
8 changes: 5 additions & 3 deletions scripts/kin-sign-dev-review.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#!/bin/bash
# REVIEW-ONLY: this unconditional guard must remain until specifically approved.
printf '%s\n' 'Kin Dev signing is disabled in the local proposal.' >&2
exit 1
# Guard direct invocation outside the separately approved signing-only job.
if [[ "${KIN_DEV_SIGNING_ONLY:-}" != 1 ]]; then
printf '%s\n' 'Kin Dev signing requires the approved signing-only job.' >&2
exit 1
fi

# Everything below is illustrative and has NOT been exercised with signing.
# The example reserves separate step deadlines plus job-level cleanup slack.
Expand Down
8 changes: 4 additions & 4 deletions scripts/tests/test_dev_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ def test_production_configuration_protection_and_identity_unchanged(self):
expected = json.loads(json.dumps(value))
expected['buildSettings']['ASSETCATALOG_COMPILER_APPICON_NAME'] = 'Kin'
# Deliberate next-release metadata; all other production settings stay frozen.
expected['buildSettings']['MARKETING_VERSION'] = '1.2.0'
expected['buildSettings']['CURRENT_PROJECT_VERSION'] = '6'
expected['buildSettings']['MARKETING_VERSION'] = '1.3.0'
expected['buildSettings']['CURRENT_PROJECT_VERSION'] = '7'
self.assertEqual(current[key], expected, key)
else:
self.assertEqual(current[key], value, key)
Expand Down Expand Up @@ -64,8 +64,8 @@ def test_dev_configuration(self):
self.assertEqual(ids, {'com.intriguingideas.Kin.dev', 'com.intriguingideas.Kin.dev.tests', 'com.intriguingideas.Kin.dev.uitests'})
app = next(c for c in configs if c.get('PRODUCT_BUNDLE_IDENTIFIER') == 'com.intriguingideas.Kin.dev')
self.assertEqual(app['INFOPLIST_KEY_CFBundleDisplayName'], 'Kin Dev')
self.assertEqual(app['MARKETING_VERSION'], '1.2.0')
self.assertEqual(app['CURRENT_PROJECT_VERSION'], '6')
self.assertEqual(app['MARKETING_VERSION'], '1.3.0')
self.assertEqual(app['CURRENT_PROJECT_VERSION'], '7')
self.assertEqual(app['ASSETCATALOG_COMPILER_APPICON_NAME'], 'AppIconDev')
self.assertEqual(app['CODE_SIGN_ENTITLEMENTS'], 'Development/KinDev.entitlements')
import plistlib
Expand Down
Loading
Loading