diff --git a/.github/workflows/hosted-dev-testflight.yml b/.github/workflows/hosted-dev-testflight.yml index bb0ecd1..a39a60b 100644 --- a/.github/workflows/hosted-dev-testflight.yml +++ b/.github/workflows/hosted-dev-testflight.yml @@ -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: @@ -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 }} @@ -40,21 +43,39 @@ 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 @@ -62,6 +83,12 @@ jobs: 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: @@ -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 diff --git a/Development/HostedKinDev.md b/Development/HostedKinDev.md index 08885fd..84cc8d9 100644 --- a/Development/HostedKinDev.md +++ b/Development/HostedKinDev.md @@ -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. @@ -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 @@ -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 @@ -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. diff --git a/Kin.xcodeproj/project.pbxproj b/Kin.xcodeproj/project.pbxproj index 0715869..d0fe112 100644 --- a/Kin.xcodeproj/project.pbxproj +++ b/Kin.xcodeproj/project.pbxproj @@ -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; @@ -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; @@ -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; @@ -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; @@ -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"; @@ -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; diff --git a/scripts/check-signing-environment.py b/scripts/check-signing-environment.py new file mode 100644 index 0000000..3b5d44f --- /dev/null +++ b/scripts/check-signing-environment.py @@ -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() diff --git a/scripts/kin-sign-dev-cleanup.sh b/scripts/kin-sign-dev-cleanup.sh new file mode 100644 index 0000000..ea95ac8 --- /dev/null +++ b/scripts/kin-sign-dev-cleanup.sh @@ -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 diff --git a/scripts/kin-sign-dev-review.sh b/scripts/kin-sign-dev-review.sh index b21befa..be8cbdc 100644 --- a/scripts/kin-sign-dev-review.sh +++ b/scripts/kin-sign-dev-review.sh @@ -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. diff --git a/scripts/tests/test_dev_identity.py b/scripts/tests/test_dev_identity.py index 94f38b5..d8a3fd3 100644 --- a/scripts/tests/test_dev_identity.py +++ b/scripts/tests/test_dev_identity.py @@ -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) @@ -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 diff --git a/scripts/tests/test_dev_review.py b/scripts/tests/test_dev_review.py index b7882f2..59e8712 100644 --- a/scripts/tests/test_dev_review.py +++ b/scripts/tests/test_dev_review.py @@ -61,14 +61,19 @@ def test_dev_privacy_exception_is_exact_and_path_scoped(self): with self.subTest(expected=expected): self.assertIn(expected, guard.inspect(target, data)) - def test_disabled_trial_modes_and_cleanup_budget(self): + def test_signing_only_trial_mode_and_cleanup_budget(self): w = (ROOT / '.github/workflows/hosted-dev-testflight.yml').read_text() self.assertIn('options: [signing_only, upload_trial]', w) self.assertIn("if: ${{ false && inputs.mode == 'upload_trial' }}", w) - self.assertIn('timeout-minutes: 85', w) - self.assertLess(5 + 40 + 20 + 5 + 5, 85) - self.assertIn('if: ${{ false }}', w) - self.assertNotIn('environment:', w) + self.assertIn('test "$TRIAL_MODE" = signing_only', w) + self.assertIn('timeout-minutes: 86', w) + self.assertLess(5 + 1 + 40 + 20 + 5 + 5, 86) + self.assertIn("if: ${{ inputs.mode == 'signing_only' }}", w) + self.assertIn('environment: kin-dev-signing', w) + self.assertIn('needs: [source, tests]', w) + self.assertIn('needs: [source, tests, signing-preflight]', w) + self.assertEqual(w.count('python3 scripts/check-signing-environment.py'), 2) + self.assertLess(w.rindex('python3 scripts/check-signing-environment.py'), w.index('P12_BASE64:')) def test_fixed_target_and_disabled_guards(self): s = (ROOT / 'scripts/kin-sign-dev-review.sh').read_text() @@ -76,8 +81,16 @@ def test_fixed_target_and_disabled_guards(self): w = (ROOT / '.github/workflows/hosted-dev-testflight.yml').read_text() self.assertIn('exit 1', s.split('set -euo pipefail')[0]) self.assertIn('exit 1', c.split('set -euo pipefail')[0]) - self.assertIn('if: ${{ false }}', w) - self.assertNotIn('environment:', w) + self.assertIn("if: ${{ inputs.mode == 'signing_only' }}", w) + self.assertIn('environment: kin-dev-signing', w) + self.assertIn("if: ${{ false && inputs.mode == 'upload_trial' }}", w) + cleanup = ROOT / 'scripts/kin-sign-dev-cleanup.sh' + self.assertEqual(cleanup.read_text().split('set +x', 1)[1], + c.split('set +x', 1)[1]) + for path in (ROOT / 'scripts/kin-sign-dev-review.sh', cleanup): + result = subprocess.run(['bash', str(path)], env={'PATH':'/usr/bin:/bin'}, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5) + self.assertNotEqual(result.returncode, 0) self.assertIn("-scheme 'Kin Dev' -configuration Dev", s) self.assertIn('identifier \\\"com.intriguingideas.Kin.dev\\\"', s) self.assertIn('check_dev_entitlements.py', s) diff --git a/scripts/tests/test_dev_signing_trial.py b/scripts/tests/test_dev_signing_trial.py new file mode 100644 index 0000000..f4b0afb --- /dev/null +++ b/scripts/tests/test_dev_signing_trial.py @@ -0,0 +1,46 @@ +"""Exercise the proposed Dev signing-only flow using disposable fake tools.""" +import os +from pathlib import Path +import subprocess +import test_hosted_sign_failures as baseline + + +class DevSignTrialTests(baseline.SignFailureTests): + def setUp(self): + super().setUp() + scripts = self.root / 'scripts' + (scripts / 'check_dev_entitlements.py').write_bytes( + (baseline.HERE / 'check_dev_entitlements.py').read_bytes()) + (self.root / 'export-test.sh').write_bytes( + (baseline.HERE / 'kin-sign-dev-review.sh').read_bytes()) + (self.root / 'cleanup-test.sh').write_bytes( + (baseline.HERE / 'kin-sign-dev-cleanup.sh').read_bytes()) + build = self.root / 'fake-build.py' + build.write_text(build.read_text().replace('com.intriguingideas.Kin', + 'com.intriguingideas.Kin.dev')) + (self.root / 'fake-entitlements.py').write_text( + 'import os,plistlib,sys\n' + 'team=os.environ["TEAM_ID"]; app=team+".com.intriguingideas.Kin.dev"\n' + 'sys.stdout.buffer.write(plistlib.dumps({"get-task-allow":os.environ.get("FAKE_DEBUG")=="yes",' + '"application-identifier":app,"keychain-access-groups":[app],' + '"com.apple.developer.team-identifier":team}))\n') + self.env['KIN_DEV_SIGNING_ONLY'] = '1' + + def test_synthetic_output_check_and_debug_entitlement_failure(self): + for debug in ('no', 'yes'): + with self.subTest(debug=debug): + env = dict(self.env, FAKE_STAGE='success', FAKE_DEBUG=debug) + result = subprocess.run(['bash', str(self.root / 'export-test.sh')], env=env, + cwd=self.root, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=10) + if debug == 'no': + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(b'Dev candidate verified', result.stdout) + else: + self.assertNotEqual(result.returncode, 0) + self.assertNotIn(b'Dev candidate verified', result.stdout) + cleanup = subprocess.run(['bash', str(self.root / 'cleanup-test.sh')], env=env, + cwd=self.root, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=10) + self.assertEqual(cleanup.returncode, 0, cleanup.stderr) + self.assertEqual(list(self.runner.iterdir()), []) diff --git a/scripts/tests/test_outcome_review.py b/scripts/tests/test_outcome_review.py index e942ca8..9e8993f 100644 --- a/scripts/tests/test_outcome_review.py +++ b/scripts/tests/test_outcome_review.py @@ -41,11 +41,11 @@ def test_processing_outcomes_never_claim_byte_proof(self): def test_outcome_is_after_cleanup_in_disabled_job(self): s = (ROOT / '.github/workflows/hosted-dev-testflight.yml').read_text() - self.assertIn('if: ${{ false }}', s) - self.assertNotIn('environment:', s) + self.assertIn("if: ${{ false && inputs.mode == 'upload_trial' }}", s) + self.assertIn('environment: kin-dev-signing', s) self.assertLess(s.index('kin-sign-dev-review.sh'), s.index('kin-upload-dev-review.sh')) - self.assertLess(s.index('kin-upload-dev-review.sh'), s.index('kin-sign-cleanup.sh')) - self.assertLess(s.index('kin-sign-cleanup.sh'), s.index('summarize_upload_review.py')) + self.assertLess(s.index('kin-upload-dev-review.sh'), s.index('kin-sign-dev-cleanup.sh')) + self.assertLess(s.index('kin-sign-dev-cleanup.sh'), s.index('summarize_upload_review.py')) self.assertIn('if-no-files-found: error', s) diff --git a/scripts/tests/test_signing_environment.py b/scripts/tests/test_signing_environment.py new file mode 100644 index 0000000..6225e64 --- /dev/null +++ b/scripts/tests/test_signing_environment.py @@ -0,0 +1,61 @@ +import copy +import importlib.util +from pathlib import Path +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('preflight', Path(__file__).resolve().parents[1] / 'check-signing-environment.py') +p = importlib.util.module_from_spec(spec) +spec.loader.exec_module(p) + +class SigningEnvironmentTests(unittest.TestCase): + def setUp(self): + self.env = {'id': p.EXPECTED_ENVIRONMENT_ID, 'name': 'kin-dev-signing', + 'deployment_branch_policy': {'protected_branches': False, 'custom_branch_policies': True}, + 'protection_rules': [{'type': 'required_reviewers', 'prevent_self_review': False, + 'reviewers': [{'type': 'User', 'reviewer': {'id': p.EXPECTED_REVIEWER_ID, 'login': 'dannydover'}}]}], + 'can_admins_bypass': False} + self.policies = {'total_count': 1, 'branch_policies': [{'name': 'main', 'type': 'branch'}]} + + def test_expected_and_optional_fields(self): + self.assertEqual(p.validate(self.env, self.policies), {'admin_bypass_verified': True, 'branch_type_verified': True}) + del self.env['can_admins_bypass'] + del self.policies['branch_policies'][0]['type'] + self.assertEqual(p.validate(self.env, self.policies), {'admin_bypass_verified': False, 'branch_type_verified': False}) + + def test_bad_environment_and_reviewer(self): + variants = [] + for key, value in [('id', 1), ('name', 'other'), ('can_admins_bypass', True), ('can_admins_bypass', 'false'), ('protection_rules', [])]: + e = copy.deepcopy(self.env); e[key] = value; variants.append(e) + for field, value in [('prevent_self_review', True), ('reviewers', []), ('reviewers', [{'type':'User','reviewer':{'id':1,'login':'dannydover'}}])]: + e = copy.deepcopy(self.env); e['protection_rules'][0][field] = value; variants.append(e) + e = copy.deepcopy(self.env); e['protection_rules'].append(copy.deepcopy(e['protection_rules'][0])); variants.append(e) + e = copy.deepcopy(self.env); e['deployment_branch_policy']['custom_branch_policies'] = False; variants.append(e) + for e in variants: + with self.subTest(e=e), self.assertRaises(ValueError): p.validate(e, self.policies) + + def test_bad_branch_policy(self): + for policies in ({'total_count':0,'branch_policies':[]}, {'total_count':1,'branch_policies':[{'name':'*','type':'branch'}]}, {'total_count':1,'branch_policies':[{'name':'main','type':'tag'}]}, {'total_count':2,'branch_policies':[{'name':'main','type':'branch'}]}, {}): + with self.subTest(policies=policies), self.assertRaises(ValueError): p.validate(self.env, policies) + + def test_fetch_endpoint_and_json_bounds(self): + class Response: + status = 200 + def __init__(self, data): self.data = data + def __enter__(self): return self + def __exit__(self, *args): pass + def read(self, count): return self.data[:count] + class Opener: + def __init__(self, data): self.data = data + def open(self, request, timeout): + self_timeout = timeout + assert self_timeout == 10 + return Response(self.data) + with self.assertRaises(ValueError): p.fetch('https://example.invalid/') + for data in (b'{"a":1,"a":2}', b'x' * (p.MAX_BYTES + 1), b'{'): + with self.subTest(size=len(data)), patch.object(p.urllib.request, 'build_opener', return_value=Opener(data)), self.assertRaises(ValueError): p.fetch(p.BASE) + with patch.object(p.urllib.request, 'build_opener', return_value=Opener(b'{"ok":true}')): + self.assertEqual(p.fetch(p.BASE), {'ok': True}) + self.assertIsNone(p._NoRedirect().redirect_request(None, None, None, None, None, None)) + +if __name__ == '__main__': unittest.main()