diff --git a/.github/workflows/ios-ship-contract.yml b/.github/workflows/ios-ship-contract.yml index b6508f1e..da8270af 100644 --- a/.github/workflows/ios-ship-contract.yml +++ b/.github/workflows/ios-ship-contract.yml @@ -1,34 +1,27 @@ name: iOS ship contract -# Pins the iOS and Mac auto-ship wiring (owner 2026-10-17): a merge to main that -# touches an app's native tree, including the shared Sources/QuotaCore files its -# targets compile, uploads a TestFlight build with no human step. Offline and +# Pins the iOS auto-ship wiring (owner 2026-10-17): a merge to main that +# touches the iOS app's native tree, including the shared Sources/QuotaCore files +# its targets compile, uploads a TestFlight build with no human step. The Mac +# app no longer ships to TestFlight (CC-169, owner ruling 2026-10-19). Offline and # credential-free: it reads the workflow text and project.yml, and runs the # scheduled-gate and key-handoff shell tests. See -# scripts/ios-ship-workflow.test.mjs and scripts/macos-ship-workflow.test.mjs. +# scripts/ios-ship-workflow.test.mjs. on: pull_request: paths: - '.github/workflows/ios-ship*.yml' - - '.github/workflows/macos-ship.yml' - 'ios/CodeCapsCompanion/project.yml' - - 'ios/CodeCapsCompanion/mac-appstore-profiles.json' - 'scripts/ios-*' - - 'scripts/mac-*' - - 'scripts/macos-ship-workflow.test.mjs' - 'scripts/ios-fleet/**' - 'scripts/test-ios-stage-asc-key.sh' push: branches: [main] paths: - '.github/workflows/ios-ship*.yml' - - '.github/workflows/macos-ship.yml' - 'ios/CodeCapsCompanion/project.yml' - - 'ios/CodeCapsCompanion/mac-appstore-profiles.json' - 'scripts/ios-*' - - 'scripts/mac-*' - - 'scripts/macos-ship-workflow.test.mjs' - 'scripts/ios-fleet/**' - 'scripts/test-ios-stage-asc-key.sh' workflow_dispatch: @@ -49,6 +42,6 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Pin the auto-ship wiring run: | - node --test scripts/ios-ship-workflow.test.mjs scripts/macos-ship-workflow.test.mjs + node --test scripts/ios-ship-workflow.test.mjs bash scripts/ios-fleet/test-asc-credentials.sh node --test scripts/ios-fleet/test-asc-credentials.mjs diff --git a/.github/workflows/macos-ship.yml b/.github/workflows/macos-ship.yml deleted file mode 100644 index e07b9118..00000000 --- a/.github/workflows/macos-ship.yml +++ /dev/null @@ -1,351 +0,0 @@ -name: Mac TestFlight ship (GitHub-hosted macOS) - -# AUTO-SHIP CONTRACT (owner 2026-10-17): a merge to main that touches the Mac -# app's native tree uploads a fresh TestFlight build with no human step and -# publishes What to Test (IOS_TF_RELEASE_NOTES=1). "The native tree" is -# ios/CodeCapsCompanion/** PLUS the shared Sources/QuotaCore files the -# CodeCapsCompanionMac and CodeCapsWidgetsMac targets compile (project.yml -# `../../Sources/QuotaCore/...`). It is NOT the SwiftPM tree under Sources/: -# that is the Sparkle app owned by mac-release.yml, a different product. Both -# lists below (push paths and the scheduled gate's prefixes) are pinned to -# project.yml by scripts/macos-ship-workflow.test.mjs, so a file added to the -# Mac targets cannot quietly stop shipping. workflow_dispatch is the manual -# path. The rate gate and the same-HEAD skip are the ship script's defaults: -# never set IOS_TF_MIN_INTERVAL_SEC or --force-ship in this file. -# -# This file was workflow_dispatch only until 2026-10-17, so a merge to main -# never reached TestFlight (CodeCaps for Mac sat at 0.1.12 from Oct 3). It -# also read the stale ASC_* repository secrets (HTTP 401, CC-163) and was on -# the retired 0.1.N train. See docs/IOS-TESTFLIGHT.md. -# -# Archive + upload on GitHub-hosted macos-latest (a stable Xcode on a GM macOS, -# never the owned Mac, which runs a beta that App Store review rejects as -# INVALID_BINARY). -# -# Credentials: -# * App Store Connect key (ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8) comes from -# Fleet-OPS Infisical prod /asc, the fleet's single copy (owner ruling -# 2026-10-17: usable by any app), exactly as ios-ship.yml, Congress.Trade -# and Usage-Monitor read it. The same-named repository secrets are a stale -# copy that App Store Connect answers with HTTP 401, so they are -# deliberately not read here and there is no fallback to them. -# * The multiline key never enters GITHUB_ENV: scripts/ios-stage-asc-key.sh -# writes it to a mode-600 file and only the path crosses steps. -# * IOS_DIST_P12_* (Apple Distribution) and MAC_INSTALLER_P12_* (3rd Party Mac -# Developer Installer) stay repository secrets. -# * SENTRY_DSN and SENTRY_AUTH_TOKEN are best-effort: an inert Sentry beats a -# red TestFlight ship. Infisical prod first, same-named GitHub secrets as -# fallback. -# Values are never printed. -# -# Bundle `com.simplewithus.codecaps.macos`, Apple ID 6814951477, team CC8UTF7ATG. -# Versioning is 1.0., and CFBundleVersion is that same integer -# (owner 2026-10-18); UTC timestamps are retired. -# -# WHY THERE IS A `schedule:` TRIGGER -# A PR merged by github-actions[bot] (auto-merge armed with GITHUB_TOKEN) lands -# on main under GitHub's recursion guard and dispatches NO push run. The cron -# is exempt from that guard, so it notices what the push never announced. It -# is gated twice: scripts/ios-fleet/scheduled-ship-gate.sh refuses unless one -# of the Mac paths changed since the last successful ship, and the ship script -# rate-limits and skips an already-shipped HEAD. push and workflow_dispatch -# bypass the path gate (a push already passed the paths: filter). -# -# The ship state (~/.cache/ios-fleet) is restored from actions/cache because a -# hosted runner starts with an empty home. The key family is -# `ios-fleet-state-codecaps-mac-v1-`: v1 is the 1.0.N train. The older -# `ios-fleet-state-codecaps-mac-` caches hold build-seq 12 from the retired -# 0.1.N train, which would have made the first 1.0.N ship 1.0.13. - -on: - push: - branches: [main] - paths: - - 'ios/CodeCapsCompanion/**' - # Shared sources the Mac app and widget targets compile. Exact files, not - # Sources/QuotaCore/**: most QuotaCore edits are not compiled into this - # app and must not ship a TestFlight build. - - 'Sources/QuotaCore/CompanionReadTokenStore.swift' - - 'Sources/QuotaCore/FleetSourceDisagreement.swift' - - 'Sources/QuotaCore/QuotaWindowVocabulary.swift' - - 'Sources/QuotaCore/ResetAlarmCadence.swift' - - 'Sources/QuotaCore/ResetAlarmMessage.swift' - - 'Sources/QuotaCore/ResetAlarmTracker.swift' - - 'Sources/QuotaCore/WidgetPlatformDisplay.swift' - - 'Sources/QuotaCore/WidgetSnapshotFetcher.swift' - - '.github/workflows/macos-ship.yml' - - '.github/actions/infisical-secrets/**' - - 'scripts/infisical-fetch.mjs' - - 'scripts/ios-fleet/**' - - '!scripts/ios-fleet/test-*.sh' - - 'scripts/mac-ship-testflight.sh' - - 'scripts/mac-appstore-gm-prepare.sh' - - 'scripts/mac-install-appstore-profiles.sh' - - 'scripts/ios-stage-asc-key.sh' - workflow_dispatch: - schedule: - # Twice an hour, offset from ios-ship.yml ('26,56') and the other fleet apps. - - cron: '11,41 * * * *' - -permissions: - contents: read - checks: read - -# A later push must queue behind an upload in flight, never cancel it: a run -# cancelled between archive and upload burns a build number for nothing. -concurrency: - group: macos-ship-codecaps - cancel-in-progress: false - -jobs: - # The scheduled tick used to queue macos-latest just to decide "skip". - # That decision, and the wait for Swift CI, both run on ubuntu. The Mac - # runner starts only when this commit should ship and has compiled. - gate: - name: Ship gate - if: github.event.repository.fork == false && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 90 - outputs: - should_ship: ${{ steps.gate.outputs.should_ship }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - # The gate diffs HEAD against the sha recorded by the last successful - # ship, which can be many commits back. - fetch-depth: 0 - - - name: Restore ship-state cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/ios-fleet - key: ios-fleet-state-codecaps-mac-v1-${{ github.run_id }}-${{ github.run_attempt }} - restore-keys: | - ios-fleet-state-codecaps-mac-v1-${{ github.run_id }}- - ios-fleet-state-codecaps-mac-v1- - - # Prefixes mirror on.push.paths (minus workflow and script files, which - # only a push or dispatch should act on). The app key is codecaps-mac: - # ship-testflight.sh records last-ship-codecaps-mac.txt under that name. - - name: Scheduled ship gate (skip unless Mac code changed) - id: gate - run: | - bash "$GITHUB_WORKSPACE/scripts/ios-fleet/scheduled-ship-gate.sh" \ - --event "${{ github.event_name }}" \ - --path-prefix 'ios/CodeCapsCompanion/' \ - --path-prefix 'Sources/QuotaCore/CompanionReadTokenStore.swift' \ - --path-prefix 'Sources/QuotaCore/FleetSourceDisagreement.swift' \ - --path-prefix 'Sources/QuotaCore/QuotaWindowVocabulary.swift' \ - --path-prefix 'Sources/QuotaCore/ResetAlarmCadence.swift' \ - --path-prefix 'Sources/QuotaCore/ResetAlarmMessage.swift' \ - --path-prefix 'Sources/QuotaCore/ResetAlarmTracker.swift' \ - --path-prefix 'Sources/QuotaCore/WidgetPlatformDisplay.swift' \ - --path-prefix 'Sources/QuotaCore/WidgetSnapshotFetcher.swift' \ - --repo-root "$GITHUB_WORKSPACE" \ - codecaps-mac - - - name: Wait for Swift CI compile checks on this commit - if: steps.gate.outputs.should_ship == '1' - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - SHA: ${{ github.sha }} - run: bash scripts/await-swift-ci-checks.sh - - ship: - name: Mac TestFlight archive + upload - needs: gate - if: >- - github.event.repository.fork == false && - github.ref == 'refs/heads/main' && - needs.gate.result == 'success' && - needs.gate.outputs.should_ship == '1' - runs-on: macos-latest - timeout-minutes: 90 - env: - DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - # The release notes need the commit range since the last ship. - fetch-depth: 0 - - - name: Restore ship-state cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/ios-fleet - key: ios-fleet-state-codecaps-mac-v1-${{ github.run_id }}-${{ github.run_attempt }} - restore-keys: | - ios-fleet-state-codecaps-mac-v1-${{ github.run_id }}- - ios-fleet-state-codecaps-mac-v1- - - - name: Assert stable Xcode (never beta) - run: | - echo "DEVELOPER_DIR=${DEVELOPER_DIR}" - xcodebuild -version - ver=$(xcodebuild -version | awk 'NR==1{print $2}') - echo "Xcode version: ${ver}" - case "$DEVELOPER_DIR" in - *[Bb]eta*) - echo "::error::DEVELOPER_DIR resolves to a beta Xcode: $DEVELOPER_DIR" - exit 1 - ;; - esac - - - name: Install xcodegen - run: | - if command -v xcodegen >/dev/null 2>&1; then - xcodegen --version - else - brew install xcodegen - fi - - - name: Load Sentry ship secrets (Infisical, then repository secrets) - uses: ./.github/actions/infisical-secrets - with: - names: SENTRY_DSN SENTRY_AUTH_TOKEN - project-id: ${{ secrets.INFISICAL_PROJECT_ID }} - client-id: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} - client-secret: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} - env: - GH_FALLBACK_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - GH_FALLBACK_SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - - # Do not use `secrets.*` in `if:` -- GitHub's dispatch parser rejects it - # (`Unrecognized named-value: secrets`) and the whole workflow dies at 0s. - - # ASC key from Fleet-OPS prod /asc. ASC_KEY_ID and ASC_ISSUER_ID are - # single-line and go to GITHUB_ENV; the multiline private key is staged - # to a mode-600 file and only ASC_KEY_PATH is exported. This step is a - # verbatim copy of the one in ios-ship.yml; the contract test fails when - # the two drift. - - name: Load ASC credentials (Fleet-OPS prod /asc) - env: - ASC_INFISICAL_PROJECT_ID: 8dae6d03-8f12-48b2-adf3-e5c1b881c8c5 - INFISICAL_UNIVERSAL_AUTH_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID }} - INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET }} - INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID }} - INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET }} - run: | - set +o xtrace - set -euo pipefail - export INFISICAL_DISABLE_UPDATE_CHECK=true - - if ! command -v infisical >/dev/null 2>&1; then - NONINTERACTIVE=1 HOMEBREW_NO_AUTO_UPDATE=1 brew install infisical/get-cli/infisical - fi - command -v infisical >/dev/null 2>&1 || { - echo "::error::Infisical CLI is not installed." - exit 1 - } - - CLIENT_ID="${INFISICAL_UNIVERSAL_AUTH_CLIENT_ID}" - CLIENT_SECRET="${INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET}" - if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ]; then - CLIENT_ID="${INFISICAL_CLIENT_ID}" - CLIENT_SECRET="${INFISICAL_CLIENT_SECRET}" - fi - if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ]; then - echo "::error::Missing Infisical machine identity. Set INFISICAL_UNIVERSAL_AUTH_CLIENT_ID/SECRET (fallback INFISICAL_CLIENT_ID/SECRET) on the org or repo." - exit 1 - fi - - TOKEN="$( - env -u INFISICAL_CLIENT_ID -u INFISICAL_CLIENT_SECRET -u INFISICAL_TOKEN \ - INFISICAL_UNIVERSAL_AUTH_CLIENT_ID="$CLIENT_ID" \ - INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET="$CLIENT_SECRET" \ - infisical login --method=universal-auth --silent --plain --telemetry=false 2>/dev/null || true - )" - if [ -z "$TOKEN" ]; then - echo "::error::Infisical universal-auth login failed." - exit 1 - fi - if [[ "$TOKEN" == *$'\n'* || "$TOKEN" == *$'\r'* ]]; then - echo '::error::Infisical returned a multiline token.' >&2 - exit 1 - fi - printf '::add-mask::%s\n' "${TOKEN//'%'/'%25'}" - - for name in ASC_KEY_ID ASC_ISSUER_ID; do - val="$( - INFISICAL_TOKEN="$TOKEN" infisical secrets get "$name" \ - --plain --silent --telemetry=false --expand=false \ - --projectId="$ASC_INFISICAL_PROJECT_ID" --env=prod --path=/asc \ - 2>/dev/null || true - )" - if [ -z "$val" ]; then - echo "::error::Missing $name in Fleet-OPS prod /asc; verify the CI identity has read access to project ${ASC_INFISICAL_PROJECT_ID} path /asc." >&2 - exit 1 - fi - if [[ "$val" == *$'\n'* || "$val" == *$'\r'* ]]; then - echo "::error::Signing field $name must be single-line; correct Infisical formatting." >&2 - exit 1 - fi - printf '::add-mask::%s\n' "${val//'%'/'%25'}" - printf '%s=%s\n' "$name" "$val" >> "$GITHUB_ENV" - done - - # Keep the multiline PEM out of GITHUB_ENV and every later step - # environment. Only its mode-600 file path crosses the step boundary. - key_material="$( - INFISICAL_TOKEN="$TOKEN" infisical secrets get ASC_KEY_P8 \ - --plain --silent --telemetry=false --expand=false \ - --projectId="$ASC_INFISICAL_PROJECT_ID" --env=prod --path=/asc \ - 2>/dev/null || true - )" - if [ -z "$key_material" ]; then - echo "::error::Missing ASC key material in Fleet-OPS prod /asc; verify the CI identity has read access to project ${ASC_INFISICAL_PROJECT_ID} path /asc." >&2 - exit 1 - fi - printf '%s\n' "$key_material" | bash "$GITHUB_WORKSPACE/scripts/ios-stage-asc-key.sh" - unset key_material TOKEN CLIENT_ID CLIENT_SECRET val - - - name: Import signing identities - env: - IOS_DIST_P12_BASE64: ${{ secrets.IOS_DIST_P12_BASE64 }} - IOS_DIST_P12_PASSWORD: ${{ secrets.IOS_DIST_P12_PASSWORD }} - MAC_INSTALLER_P12_BASE64: ${{ secrets.MAC_INSTALLER_P12_BASE64 }} - MAC_INSTALLER_P12_PASSWORD: ${{ secrets.MAC_INSTALLER_P12_PASSWORD }} - run: | - if [ -z "${ASC_KEY_ID:-}" ] || [ -z "${ASC_KEY_PATH:-}" ]; then - echo "::error::ASC credentials were not loaded from Fleet-OPS prod /asc." - exit 1 - fi - if [ -z "${IOS_DIST_P12_BASE64:-}" ] || [ -z "${MAC_INSTALLER_P12_BASE64:-}" ]; then - echo "::error::IOS_DIST_P12_BASE64 or MAC_INSTALLER_P12_BASE64 is empty. Confirm the IOS_DIST_ and MAC_INSTALLER_ repository secrets are configured." - exit 1 - fi - bash "$GITHUB_WORKSPACE/scripts/mac-appstore-gm-prepare.sh" - - - name: Install Mac App Store profiles - run: bash "$GITHUB_WORKSPACE/scripts/mac-install-appstore-profiles.sh" - - - name: Clear stale archive lock from ship-state cache - run: | - # Hosted runners restore ~/.cache/ios-fleet; a cancelled prior ship can - # leave archive.lockdir and block the next run for ~30 minutes. - rm -rf "${HOME}/.cache/ios-fleet/archive.lockdir" - echo "archive lock cleared" - - - name: Ship CodeCaps for Mac to TestFlight - env: - IOS_TF_RELEASE_NOTES: "1" - MAC_MANUAL_SIGN: "1" - run: bash scripts/mac-ship-testflight.sh - - - name: Save ship-state cache - if: always() - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/ios-fleet - key: ios-fleet-state-codecaps-mac-v1-${{ github.run_id }}-${{ github.run_attempt }} - - - name: Remove runner signing material - if: always() - run: | - set +o xtrace - security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" >/dev/null 2>&1 || true - rm -f "$RUNNER_TEMP/app-signing-kc-pass" - if [ -n "${ASC_KEY_PATH:-}" ]; then - rm -rf "$(dirname "$ASC_KEY_PATH")" - fi diff --git a/docs/IOS-TESTFLIGHT.md b/docs/IOS-TESTFLIGHT.md index 6f411c6a..c983b6eb 100644 --- a/docs/IOS-TESTFLIGHT.md +++ b/docs/IOS-TESTFLIGHT.md @@ -31,13 +31,10 @@ The marketing version train is `1.0.N` (`marketingVersionDefault` in `scripts/io 2. A failed run names its step. `Load ASC credentials (Fleet-OPS prod /asc)` failing means the CI Infisical identity lost read access to Fleet-OPS `/asc`; that is an owner fix in Infisical, never a new App Store Connect key. 3. Compare the newest processed TestFlight build against `git log -1 -- ios/CodeCapsCompanion` before saying the app is up to date. -## CodeCaps For Mac (TestFlight) +## CodeCaps For Mac (Retired From TestFlight) -The Mac App Store build of CodeCaps ships the same way, from `.github/workflows/macos-ship.yml`, and `scripts/macos-ship-workflow.test.mjs` pins it in the same `iOS ship contract` check. It was manual only until Sat, Oct 17, 2026, so merges never reached TestFlight. +CodeCaps for Mac no longer ships to TestFlight or the Mac App Store (CC-169, owner ruling Mon, Oct 19, 2026). The Mac app is a direct download: the Sparkle app under `Sources/`, released as a signed and notarized DMG on GitHub Releases by `.github/workflows/mac-release.yml` and linked from https://simplewithus.com/codecaps/. -- **What it builds.** The `CodeCapsCompanionMac` scheme of `ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj`, bundle `com.simplewithus.codecaps.macos`, ship key `codecaps-mac`. It is not the SwiftPM tree under `Sources/`: that is the Sparkle app, released by `mac-release.yml` as a Developer ID build, and a change there ships no TestFlight build. -- **What triggers it.** The same list as the iOS ship: `ios/CodeCapsCompanion/**` plus the eight exact `Sources/QuotaCore` files the Mac app and widget targets compile, the workflow, `scripts/ios-fleet/**` (test scripts excluded) and the Mac ship and signing scripts. A cron tick (`11,41 * * * *`) catches merges GitHub does not announce and ships only when the scheduled gate (`codecaps-mac`) finds a change under those paths since the last successful ship. `workflow_dispatch` ships without the gate. The contract test fails when `project.yml` and the workflow disagree. -- **Credentials.** The App Store Connect key comes from Fleet-OPS Infisical prod `/asc` through the same step as `ios-ship.yml` (the test fails if the two copies drift). `IOS_DIST_P12_*` and `MAC_INSTALLER_P12_*` stay repository secrets. The same-named repository `ASC_*` secrets are the stale copy and are not read. -- **Versions.** Marketing train `1.0.N` (`codecaps-mac` in `scripts/ios-fleet/apps.json`); the older `0.1.N` builds, newest `0.1.12`, stay in TestFlight and `1.0.1` is above them. The state cache key family is `ios-fleet-state-codecaps-mac-v1-`, so the retired sequence cannot carry over. Every upload publishes What to Test. -- **Gates.** The ship script keeps its rate gate and the same-HEAD skip, and the job waits for Swift CI (`Mac package build and test` and `iOS companion compile`) on the commit first. Swift CI builds the Mac widget extension (`CodeCapsWidgetsMac`) but not the `CodeCapsCompanionMac` app target, so a break in the Mac app alone shows up in the archive step, not before it. -- **When a Mac build is missing.** `gh workflow view macos-ship.yml -R Simple-With-Us/CodeCaps` must say active, and the latest `main` push touching the paths above should have a run. A failed `Load ASC credentials (Fleet-OPS prod /asc)` step is an owner fix in Infisical, never a new App Store Connect key. Then run the fleet's TestFlight freshness check for `codecaps-mac`; its verdict says whether the newest processed build contains the newest native commit. +- **What was removed.** `.github/workflows/macos-ship.yml` and its contract test, the Mac App Store signing and export scripts, `mac-appstore-profiles.json`, the `codecaps-mac` entry in `scripts/ios-fleet/apps.json`, and the Apple Distribution signing overrides on the `CodeCapsCompanionMac` and `CodeCapsWidgetsMac` targets in `project.yml`. `scripts/mac-ship-testflight.sh` remains as a stub that exits non-zero. +- **What stays.** The `CodeCapsCompanionMac` and `CodeCapsWidgetsMac` targets still build (Swift CI compiles the widget extension without signing), but nothing archives or uploads them. +- **Not changed.** The iOS ship above, and `mac-release.yml`. diff --git a/ios/CodeCapsCompanion/mac-appstore-profiles.json b/ios/CodeCapsCompanion/mac-appstore-profiles.json deleted file mode 100644 index 1be58e9f..00000000 --- a/ios/CodeCapsCompanion/mac-appstore-profiles.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "com.simplewithus.codecaps.macos": "CodeCaps for Mac AppStore", - "com.simplewithus.codecaps.macos.widgets": "CodeCaps Mac Widgets AppStore" -} diff --git a/ios/CodeCapsCompanion/project.yml b/ios/CodeCapsCompanion/project.yml index 69f02a1d..819c4a5e 100644 --- a/ios/CodeCapsCompanion/project.yml +++ b/ios/CodeCapsCompanion/project.yml @@ -181,12 +181,6 @@ targets: ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon GENERATE_INFOPLIST_FILE: NO ITSAppUsesNonExemptEncryption: false - # Release profiles must authorize the app group for this app ID. - configs: - Release: - CODE_SIGN_STYLE: Manual - CODE_SIGN_IDENTITY: "Apple Distribution" - PROVISIONING_PROFILE_SPECIFIER: "CodeCaps for Mac AppStore" CodeCapsWidgetsMac: type: app-extension @@ -228,11 +222,6 @@ targets: CODE_SIGN_STYLE: Automatic GENERATE_INFOPLIST_FILE: NO ITSAppUsesNonExemptEncryption: false - configs: - Release: - CODE_SIGN_STYLE: Manual - CODE_SIGN_IDENTITY: "Apple Distribution" - PROVISIONING_PROFILE_SPECIFIER: "CodeCaps Mac Widgets AppStore" schemes: CodeCapsCompanion: diff --git a/scripts/ios-fleet/apps.json b/scripts/ios-fleet/apps.json index d1ec22da..f7343450 100644 --- a/scripts/ios-fleet/apps.json +++ b/scripts/ios-fleet/apps.json @@ -12,18 +12,6 @@ "worktreeHint": "~/Code/codecaps", "marketingVersionDefault": "1.0.0", "notes": "CodeCaps Companion \u2014 iOS companion for CodeCaps AI subscription monitor. Apple ID 6814623846 registered on ASC. ASC SKU codecaps-companion-ios. Marketing train 1.0.N (the retired 0.1.N builds stay in TestFlight; 1.0.1 is above every one of them); build UTC YYYYMMDDHHMM." - }, - "codecaps-mac": { - "displayName": "CodeCaps for Mac", - "bundleId": "com.simplewithus.codecaps.macos", - "appleId": 6814951477, - "platform": "macOS", - "scheme": "CodeCapsCompanionMac", - "projectRel": "ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj", - "xcodegenDir": "ios/CodeCapsCompanion", - "worktreeHint": "~/Code/codecaps", - "marketingVersionDefault": "1.0.0", - "notes": "CodeCaps Companion for Mac. Bundle ID com.simplewithus.codecaps.macos. Team CC8UTF7ATG. AppleId 6814951477. ASC SKU codecaps-macos. Marketing train 1.0.N (the retired 0.1.N builds stay in TestFlight; 1.0.1 is above every one of them); build UTC YYYYMMDDHHMM." } } } diff --git a/scripts/ios-ship-workflow.test.mjs b/scripts/ios-ship-workflow.test.mjs index 8b4c05e9..2fe455ec 100644 --- a/scripts/ios-ship-workflow.test.mjs +++ b/scripts/ios-ship-workflow.test.mjs @@ -196,7 +196,6 @@ test("ship state cache is keyed apart from the Mac ship", () => { const keys = [...code.matchAll(/ios-fleet-state-codecaps-[a-z]*-?/g)].map((m) => m[0]); assert.ok(keys.length >= 6, "gate and ship restore and save the state cache"); for (const k of keys) assert.equal(k, "ios-fleet-state-codecaps-ios-", `bare prefix ${k} also matches the Mac ship cache`); - assert.match(read(".github/workflows/macos-ship.yml"), /ios-fleet-state-codecaps-mac-/); }); test("the marketing train is 1.0.N and release notes use the owner clock", () => { diff --git a/scripts/mac-appstore-gm-prepare.sh b/scripts/mac-appstore-gm-prepare.sh deleted file mode 100755 index 4959e46a..00000000 --- a/scripts/mac-appstore-gm-prepare.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash -# Prepare a GitHub-hosted macOS runner to archive CodeCaps for Mac for TestFlight. -# Writes ASC credentials, imports Apple Distribution (IOS_DIST_*) and -# 3rd Party Mac Developer Installer (MAC_INSTALLER_*). Never prints secret values. -set +o xtrace -set -euo pipefail -umask 077 - -die() { echo "error: $*" >&2; exit 1; } -log() { echo "[mac-gm] $*"; } - -os=$(sw_vers -productVersion) -build=$(sw_vers -buildVersion) -log "macOS ${os} (${build})" -if echo "$build" | grep -Eq '[0-9][a-z]$'; then - die "beta macOS host ${os} (${build}). App Store review rejects these as INVALID_BINARY. Use GitHub-hosted macos-latest (GM)." -fi - -: "${ASC_KEY_ID:?ASC_KEY_ID required}" -: "${ASC_ISSUER_ID:?ASC_ISSUER_ID required}" -# Current CI passes ASC_KEY_PATH (a mode-600 file staged by -# scripts/ios-stage-asc-key.sh); retain ASC_KEY_P8 for local legacy callers. -if [[ -z "${ASC_KEY_PATH:-}" ]]; then - : "${ASC_KEY_P8:?ASC_KEY_P8 or ASC_KEY_PATH required}" -fi -: "${IOS_DIST_P12_BASE64:?IOS_DIST_P12_BASE64 required}" -: "${IOS_DIST_P12_PASSWORD:?IOS_DIST_P12_PASSWORD required}" -: "${MAC_INSTALLER_P12_BASE64:?MAC_INSTALLER_P12_BASE64 required}" -: "${MAC_INSTALLER_P12_PASSWORD:?MAC_INSTALLER_P12_PASSWORD required}" - -# AFC-359: nothing is written under the old secrets folder. Scratch files live in a -# private temp dir that is removed on exit. The ship step reads ASC_KEY_ID / -# ASC_ISSUER_ID / ASC_KEY_PATH from the job environment (GITHUB_ENV), not from a file. -KC_DIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" -WORK_DIR="$(mktemp -d "${KC_DIR%/}/gm-prepare.XXXXXX")" -chmod 700 "$WORK_DIR" -trap 'rm -rf "$WORK_DIR"' EXIT - -KEY_PATH="${ASC_KEY_PATH:-${KC_DIR%/}/asc-gm-AuthKey.p8}" -if [[ -n "${ASC_KEY_PATH:-}" ]]; then - [[ -s "$KEY_PATH" ]] || die "ASC_KEY_PATH has no key file" -else - # Legacy local path: the caller passed the key itself, not a staged file. - printf '%s\n' "$ASC_KEY_P8" > "$KEY_PATH" - [[ -z "${GITHUB_ENV:-}" ]] || printf 'ASC_KEY_PATH=%s\n' "$KEY_PATH" >> "$GITHUB_ENV" -fi -chmod 600 "$KEY_PATH" - -DIST_P12="${WORK_DIR}/ios-distribution.p12" -printf '%s' "$IOS_DIST_P12_BASE64" | base64 --decode > "$DIST_P12" -chmod 600 "$DIST_P12" - -INST_P12="${WORK_DIR}/mac-installer.p12" -printf '%s' "$MAC_INSTALLER_P12_BASE64" | base64 --decode > "$INST_P12" -chmod 600 "$INST_P12" - -KC_DIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" -KC_PATH="${KC_DIR}/app-signing.keychain-db" -KC_PASS_FILE="${KC_DIR}/app-signing-kc-pass" -openssl rand -base64 24 > "$KC_PASS_FILE" -chmod 600 "$KC_PASS_FILE" -KC_PASS=$(cat "$KC_PASS_FILE") - -security delete-keychain "$KC_PATH" >/dev/null 2>&1 || true -security create-keychain -p "$KC_PASS" "$KC_PATH" -security set-keychain-settings -lut 21600 "$KC_PATH" -security unlock-keychain -p "$KC_PASS" "$KC_PATH" -# -A: allow all apps (required for non-interactive productsign on hosted runners; -# -T alone still prompts/hangs for the Installer identity). -security import "$DIST_P12" -k "$KC_PATH" -P "$IOS_DIST_P12_PASSWORD" -A >/dev/null -security import "$INST_P12" -k "$KC_PATH" -P "$MAC_INSTALLER_P12_PASSWORD" -A >/dev/null -# The keychain holds the identities now; do not leave the p12 files on disk. -rm -f "$DIST_P12" "$INST_P12" -# productsign (Mac pkg) needs the same partition list as codesign; without it -# xcodebuild -exportArchive can hang on a keychain ACL prompt on hosted runners. -security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" "$KC_PATH" >/dev/null -security unlock-keychain -p "$KC_PASS" "$KC_PATH" -security list-keychain -d user -s "$KC_PATH" login.keychain-db -# Persist pass path for later ship steps if the keychain relocks. -printf '%s -' "$KC_PATH" > "${KC_DIR}/app-signing-kc-path" -chmod 600 "${KC_DIR}/app-signing-kc-path" - -if ! security find-identity -v -p codesigning "$KC_PATH" | grep -q 'Apple Distribution'; then - die "imported keychain has no Apple Distribution identity" -fi -if ! security find-identity -v "$KC_PATH" | grep -q '3rd Party Mac Developer Installer'; then - die "imported keychain has no 3rd Party Mac Developer Installer identity" -fi -log "Apple Distribution + Mac Installer identities imported" -log "ASC credentials come from the job environment (key id length ${#ASC_KEY_ID})" diff --git a/scripts/mac-export-appstore-pkg.sh b/scripts/mac-export-appstore-pkg.sh deleted file mode 100755 index 3c866c89..00000000 --- a/scripts/mac-export-appstore-pkg.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bash -# Build a Mac App Store .pkg from an .xcarchive without xcodebuild -exportArchive -# (hosted runners have hung on exportArchive/productsign for CodeCaps). -# Usage: mac-export-appstore-pkg.sh -set +o xtrace -set -euo pipefail - -die() { echo "error: $*" >&2; exit 1; } -log() { echo "[mac-pkg] $*"; } - -ARCHIVE="${1:-}" -OUT_DIR="${2:-}" -[[ -d "$ARCHIVE" ]] || die "archive missing: $ARCHIVE" -[[ -n "$OUT_DIR" ]] || die "out dir required" -mkdir -p "$OUT_DIR" - -APP="$(find "$ARCHIVE/Products" -maxdepth 3 -name '*.app' -type d | head -1 || true)" -[[ -n "$APP" && -d "$APP" ]] || die "no .app under $ARCHIVE/Products" -APP_NAME="$(basename "$APP" .app)" -log "app=$APP" - -# Prefer explicit installer identity; fall back to short name. -INSTALLER_ID="${MAC_INSTALLER_IDENTITY:-3rd Party Mac Developer Installer: Jay Wedgeworth, LLC (CC8UTF7ATG)}" -if ! security find-identity -v 2>/dev/null | grep -Fq "$INSTALLER_ID"; then - INSTALLER_ID="3rd Party Mac Developer Installer" -fi -log "installer identity ready (name length ${#INSTALLER_ID})" - -# Re-unlock throwaway keychain if the prepare step left one (hosted CI). -if [[ -n "${RUNNER_TEMP:-}" && -f "${RUNNER_TEMP}/app-signing-kc-pass" && -f "${RUNNER_TEMP}/app-signing.keychain-db" ]]; then - KC_PASS="$(cat "${RUNNER_TEMP}/app-signing-kc-pass")" - security unlock-keychain -p "$KC_PASS" "${RUNNER_TEMP}/app-signing.keychain-db" >/dev/null - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" "${RUNNER_TEMP}/app-signing.keychain-db" >/dev/null || true -fi - -# Ensure Info.plist carries LSMinimumSystemVersion for the product definition -# (ASC 90264: product min version must equal LSMinimumSystemVersion). -INFO="$APP/Contents/Info.plist" -if ! /usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' "$INFO" >/dev/null 2>&1; then - /usr/libexec/PlistBuddy -c 'Add :LSMinimumSystemVersion string 14.0' "$INFO" -fi -MIN_VER="$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' "$INFO")" -MARKETING="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$INFO" 2>/dev/null || echo 0.1.0)" -log "LSMinimumSystemVersion=$MIN_VER marketing=$MARKETING" - -PKG_PATH="${OUT_DIR}/${APP_NAME}.pkg" -# --component + --product copies min-system-version into the product definition. -productbuild \ - --component "$APP" /Applications \ - --product "$INFO" \ - --sign "$INSTALLER_ID" \ - --identifier "com.simplewithus.codecaps.macos" \ - --version "$MARKETING" \ - "$PKG_PATH" - -[[ -f "$PKG_PATH" ]] || die "pkg not produced" -log "pkg=$PKG_PATH size=$(wc -c < "$PKG_PATH") bytes" -printf '%s\n' "$PKG_PATH" diff --git a/scripts/mac-install-appstore-profiles.sh b/scripts/mac-install-appstore-profiles.sh deleted file mode 100755 index e717dc43..00000000 --- a/scripts/mac-install-appstore-profiles.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bash -# Download CodeCaps Mac App Store profiles and install them for manual signing. -# Prints names and bundle ids only. Never prints profile bytes. -set +o xtrace -set -euo pipefail -umask 077 - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -MAP="${ROOT}/ios/CodeCapsCompanion/mac-appstore-profiles.json" -CLIENT="${ROOT}/scripts/ios-fleet/asc-api.mjs" -DEST="${HOME}/Library/MobileDevice/Provisioning Profiles" -WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/cc-mac-profiles.XXXXXX")" -trap 'rm -rf "$WORKDIR"' EXIT -mkdir -p "$DEST" -chmod 700 "$DEST" - -[[ -f "$MAP" && -f "$CLIENT" ]] || { - echo "error: Mac profile map or App Store Connect client is missing" >&2 - exit 1 -} - -node "$CLIENT" GET '/v1/profiles?filter[profileType]=MAC_APP_STORE&limit=200&include=bundleId' \ - >"$WORKDIR/profiles.json" -python3 - "$MAP" "$DEST" "$WORKDIR/profiles.json" <<'PY' -import base64, json, os, plistlib, subprocess, sys - -want = json.load(open(sys.argv[1], encoding="utf-8")) -dest = sys.argv[2] -data = json.load(open(sys.argv[3], encoding="utf-8")) -if data.get("errors"): - err = data["errors"][0] - raise SystemExit("error: profile list failed: %s" % (err.get("code") or err.get("title") or "unknown")) - -bundles = {} -for inc in data.get("included") or []: - if inc.get("type") == "bundleIds": - bundles[inc["id"]] = (inc.get("attributes") or {}).get("identifier") - -found = {} -for row in data.get("data") or []: - rel = ((row.get("relationships") or {}).get("bundleId") or {}).get("data") or {} - bid = bundles.get(rel.get("id")) - attrs = row.get("attributes") or {} - if bid in want and attrs.get("name") == want[bid] and attrs.get("profileState") == "ACTIVE": - found[bid] = row - -missing = [bid for bid in want if bid not in found] -if missing: - raise SystemExit("error: missing ACTIVE Mac App Store profile for %s" % ", ".join(missing)) - -for bid, row in found.items(): - attrs = row.get("attributes") or {} - blob = base64.b64decode(attrs.get("profileContent") or "") - raw_path = os.path.join(os.path.dirname(sys.argv[3]), bid + ".provisionprofile") - with open(raw_path, "wb") as handle: - handle.write(blob) - os.chmod(raw_path, 0o600) - plist = plistlib.loads(subprocess.check_output(["security", "cms", "-D", "-i", raw_path])) - ents = plist.get("Entitlements") or {} - app_id = ents.get("com.apple.application-identifier") or ents.get("application-identifier") or "" - if not str(app_id).endswith(bid): - raise SystemExit("error: profile %s is for %s" % (attrs.get("name"), app_id)) - if ents.get("get-task-allow") is True: - raise SystemExit("error: profile %s is not a distribution profile" % attrs.get("name")) - uuid = plist.get("UUID") - if not uuid: - raise SystemExit("error: profile %s has no UUID" % attrs.get("name")) - # Xcode accepts .mobileprovision for both platforms on hosted runners. - final = os.path.join(dest, uuid + ".mobileprovision") - os.replace(raw_path, final) - os.chmod(final, 0o644) - print("installed %s name=%s uuid=%s" % (bid, attrs.get("name"), uuid)) -PY diff --git a/scripts/mac-ship-testflight.sh b/scripts/mac-ship-testflight.sh index 53d98a24..4aea00a2 100755 --- a/scripts/mac-ship-testflight.sh +++ b/scripts/mac-ship-testflight.sh @@ -1,14 +1,7 @@ #!/usr/bin/env bash -# Thin wrapper: ship CodeCaps for Mac to TestFlight (no Xcode UI / no Mac seat). -set -euo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -IN_REPO="${ROOT}/scripts/ios-fleet/ship-testflight.sh" -MAC="/Users/jay/apps/ios-fleet/ship-testflight.sh" -if [[ -f "$IN_REPO" ]]; then - exec bash "$IN_REPO" codecaps-mac --repo-root "$ROOT" "$@" -fi -if [[ -f "$MAC" ]]; then - exec bash "$MAC" codecaps-mac --repo-root "$ROOT" "$@" -fi -echo "error: ios-fleet ship-testflight.sh not found at ${IN_REPO} or ${MAC}" >&2 +# RETIRED (CC-169, owner ruling 2026-10-19). CodeCaps for Mac no longer ships to +# TestFlight or the Mac App Store; it is a direct-download app released by +# .github/workflows/mac-release.yml. This stub stays only so a stale caller +# fails loudly instead of uploading. +echo "Mac TestFlight shipping is retired (CC-169, owner ruling 2026-10-19); the Mac app ships as a direct-download DMG (GitHub Releases, .github/workflows/mac-release.yml)." >&2 exit 1 diff --git a/scripts/macos-ship-workflow.test.mjs b/scripts/macos-ship-workflow.test.mjs deleted file mode 100644 index d9cdb640..00000000 --- a/scripts/macos-ship-workflow.test.mjs +++ /dev/null @@ -1,285 +0,0 @@ -// Contract for the Mac auto-ship pipeline (.github/workflows/macos-ship.yml). -// -// Owner 2026-10-17: a merge to main that touches the Mac app's native tree -// uploads a fresh TestFlight build with no human step. These checks pin every -// piece of that wiring so a refactor cannot quietly turn the pipeline back into -// a manual one. The gap that prompted them: macos-ship.yml was -// workflow_dispatch only, read the stale ASC_* repository secrets (HTTP 401), -// and sat on the retired 0.1.N marketing train, so CodeCaps for Mac stayed at -// 0.1.12 while main moved on. -// -// The Mac TestFlight app is built from the Xcode project in -// ios/CodeCapsCompanion (scheme CodeCapsCompanionMac), plus the shared -// Sources/QuotaCore files its targets compile. It is NOT the SwiftPM tree -// under Sources/, which is the Sparkle app owned by mac-release.yml. -// -// Run: node --test scripts/macos-ship-workflow.test.mjs -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import test from "node:test"; - -const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); - -function read(rel) { - return readFileSync(join(ROOT, rel), "utf8"); -} - -function withoutComments(text) { - return text - .split("\n") - .filter((line) => !/^\s*#/.test(line)) - .join("\n"); -} - -const workflow = read(".github/workflows/macos-ship.yml"); -const code = withoutComments(workflow); -const iosCode = withoutComments(read(".github/workflows/ios-ship.yml")); - -/** The top-level `on:` mapping, up to the next top-level key. */ -function onBlock(yml) { - const m = yml.match(/^on:\n((?:[ \t]+.*\n|[ \t]*#.*\n|\n)*)/m); - assert.ok(m, "macos-ship.yml must declare an on: block"); - return withoutComments(m[1]); -} - -/** The `on.push.paths` entries (including any `!` exclusions), in order. */ -function pushPaths() { - const on = onBlock(workflow); - const push = on.match(/^ {2}push:\n((?: {4}.*\n|\n)*)/m); - assert.ok(push, "on.push must exist"); - const paths = push[1].match(/^ {4}paths:\n((?: {6}.*\n|\n)*)/m); - assert.ok(paths, "on.push.paths must exist"); - return [...paths[1].matchAll(/^ {6}- '([^']+)'$/gm)].map((m) => m[1]); -} - -/** Every `--path-prefix` argument the scheduled gate is called with. */ -function gatePrefixes() { - return [...code.matchAll(/--path-prefix '([^']+)'/g)].map((m) => m[1]); -} - -/** Shared files the two Mac targets compile, from project.yml. */ -function macQuotaCoreFiles() { - const yml = read("ios/CodeCapsCompanion/project.yml"); - const start = yml.indexOf("\n CodeCapsCompanionMac:"); - const end = yml.indexOf("\nschemes:"); - assert.ok(start > 0 && end > start, "project.yml must define the Mac targets before schemes:"); - const macTargets = yml.slice(start, end); - assert.match(macTargets, /\n CodeCapsWidgetsMac:/, "the Mac widget target must follow the Mac app target"); - return new Set( - [...macTargets.matchAll(/path:\s*\.\.\/\.\.\/(Sources\/QuotaCore\/[A-Za-z0-9_+.-]+\.swift)/g)].map((m) => m[1]), - ); -} - -/** GitHub `paths:` glob match: `**` crosses directories, `*` does not. */ -function globToRegExp(glob) { - let re = ""; - for (let i = 0; i < glob.length; i++) { - const c = glob[i]; - if (c === "*" && glob[i + 1] === "*") { - re += ".*"; - i++; - } else if (c === "*") { - re += "[^/]*"; - } else if (c === "?") { - re += "[^/]"; - } else { - re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&"); - } - } - return new RegExp(`^${re}$`); -} - -/** Would a push touching `file` start macos-ship.yml? Later `!` entries exclude. */ -function triggers(file, paths = pushPaths()) { - let hit = false; - for (const p of paths) { - if (p.startsWith("!")) { - if (hit && globToRegExp(p.slice(1)).test(file)) hit = false; - } else if (globToRegExp(p).test(file)) { - hit = true; - } - } - return hit; -} - -/** The text of one step of a workflow, by exact step name. */ -function stepBlock(yml, name) { - const lines = yml.split("\n"); - const start = lines.findIndex((l) => l === ` - name: ${name}`); - assert.ok(start >= 0, `step "${name}" must exist`); - let end = lines.length; - for (let i = start + 1; i < lines.length; i++) { - if (/^ {6}- /.test(lines[i]) || /^ {2}\S/.test(lines[i])) { - end = i; - break; - } - } - return lines.slice(start, end).join("\n"); -} - -test("macos-ship.yml ships automatically on a merge that touches the native tree", () => { - const on = onBlock(workflow); - - assert.match(on, /push:\s*\n\s+branches:\s*\[main\]/); - assert.ok(pushPaths().includes("ios/CodeCapsCompanion/**"), "the Xcode project tree must be a push path"); - assert.ok(pushPaths().includes(".github/workflows/macos-ship.yml"), "editing the workflow must re-ship"); - assert.match(on, /workflow_dispatch:/); - - // A scheduled catch-up is allowed only behind the scheduled path gate, and - // the gate must read the state file the Mac ship writes (codecaps-mac). - if (/^\s+schedule:/m.test(on)) { - assert.match(code, /scheduled-ship-gate\.sh/); - assert.match(code, /--event "\$\{\{ github\.event_name \}\}"/); - assert.ok(gatePrefixes().includes("ios/CodeCapsCompanion/"), "the gate must watch the Xcode project tree"); - assert.match(code, /--repo-root "\$GITHUB_WORKSPACE" \\\n\s+codecaps-mac\s*$/m); - } - - // The gate diffs against an older sha, and the notes need the commit range. - assert.ok((code.match(/fetch-depth:\s*0/g) || []).length >= 2, "gate and ship checkouts need fetch-depth: 0"); - // The Swift CI wait runs before the Mac runner starts. - assert.match(code, /bash scripts\/await-swift-ci-checks\.sh/); - // What to Test is mandatory and published, never dry-rendered. - assert.match(code, /IOS_TF_RELEASE_NOTES:\s*"1"/); - // Ship through the wrapper that runs ship-testflight.sh codecaps-mac - // (ensure-tf-ready inside). - assert.match(code, /run: bash scripts\/mac-ship-testflight\.sh\s*$/m); - assert.match(read("scripts/mac-ship-testflight.sh"), /exec bash "\$IN_REPO" codecaps-mac /); - assert.match(read("scripts/ios-fleet/ship-testflight.sh"), /ensure-tf-ready/); - // Hosted macOS with the stable-Xcode guard, never a self-hosted label. - assert.match(code, /runs-on:\s*macos-latest/); - assert.doesNotMatch(code, /runs-on:\s*\[?\s*self-hosted/); - assert.match(code, /Assert stable Xcode/); - assert.match(code, /github\.event\.repository\.fork == false/); - // TestFlight ships come from main only. - assert.match(code, /github\.ref == 'refs\/heads\/main'/); - // A later push queues behind an upload in flight instead of cancelling it. - assert.match(code, /concurrency:\s*\n\s+group: macos-ship-codecaps\s*\n\s+cancel-in-progress: false/); -}); - -test("every shared file the Mac targets compile ships a build when it changes", () => { - const compiled = macQuotaCoreFiles(); - assert.ok(compiled.size >= 8, `project.yml should reference the QuotaCore files, found ${compiled.size}`); - - const paths = pushPaths(); - const prefixes = gatePrefixes(); - for (const file of compiled) { - assert.ok(triggers(file, paths), `${file} is compiled by a Mac target but is not an on.push.paths entry`); - assert.ok(prefixes.includes(file), `${file} is compiled by a Mac target but is not a scheduled-gate --path-prefix`); - } - - // The reverse: no QuotaCore file the Mac targets do not compile, and no - // directory wildcard. Edits to the rest of QuotaCore must not ship TestFlight. - const listed = [...paths, ...prefixes].filter((p) => p.startsWith("Sources/")); - for (const p of listed) { - assert.doesNotMatch(p, /\*/, `${p}: use exact files, a wildcard would ship on unrelated edits`); - assert.ok(compiled.has(p), `${p} is listed but no Mac target compiles it`); - } - assert.equal(triggers("Sources/QuotaCore/InfisicalSettings.swift", paths), false); - // The SwiftPM tree is the Sparkle app (mac-release.yml), a different product. - assert.equal(triggers("Sources/CodeCaps/MenuBarController.swift", paths), false); - assert.equal(triggers("Package.swift", paths), false); -}); - -test("the Mac and iOS ships watch the same shared files, because the targets share them", () => { - const iosYml = read(".github/workflows/ios-ship.yml"); - const iosSources = [...iosYml.matchAll(/^ {6}- '(Sources\/QuotaCore\/[^']+)'$/gm)].map((m) => m[1]).sort(); - const macSources = pushPaths().filter((p) => p.startsWith("Sources/")).sort(); - assert.deepEqual(macSources, iosSources, "ios-ship.yml and macos-ship.yml list different QuotaCore files"); -}); - -test("push paths trigger on the native tree and not on test-only changes", () => { - const paths = pushPaths(); - assert.equal(triggers("ios/CodeCapsCompanion/App/Views/CompanionContentView.swift", paths), true); - assert.equal(triggers("ios/CodeCapsCompanion/App/InfoMac.plist", paths), true); - assert.equal(triggers("ios/CodeCapsCompanion/project.yml", paths), true); - assert.equal(triggers("Sources/QuotaCore/ResetAlarmTracker.swift", paths), true); - assert.equal(triggers("scripts/ios-fleet/ship-testflight.sh", paths), true); - assert.equal(triggers("scripts/mac-ship-testflight.sh", paths), true); - assert.equal(triggers("scripts/mac-appstore-gm-prepare.sh", paths), true); - assert.equal(triggers("scripts/ios-fleet/test-scheduled-ship-gate.sh", paths), false); - assert.equal(triggers("Sources/CodeCaps/AppDelegate.swift", paths), false); - assert.equal(triggers("docs/IOS-TESTFLIGHT.md", paths), false); - assert.equal(triggers("README.md", paths), false); -}); - -test("ASC credentials come from Fleet-OPS /asc, never the stale repository secrets", () => { - // The repository ASC_* secrets are a revoked copy (HTTP 401, CC-163). - assert.doesNotMatch(code, /secrets\.ASC_KEY_ID/); - assert.doesNotMatch(code, /secrets\.ASC_ISSUER_ID/); - assert.doesNotMatch(code, /secrets\.ASC_KEY_P8/); - assert.doesNotMatch(code, /ASC_KEY_(ID|P8):\s*\$\{\{/); - - assert.match(code, /ASC_INFISICAL_PROJECT_ID:\s*8dae6d03-8f12-48b2-adf3-e5c1b881c8c5/); - assert.match(code, /--path=\/asc/); - assert.match(code, /infisical login --method=universal-auth/); - assert.match(code, /secrets\.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID/); - - // The multiline key goes to a mode-600 file, never GITHUB_ENV. - assert.match(code, /scripts\/ios-stage-asc-key\.sh/); - assert.doesNotMatch(code, /for name in .*ASC_KEY_P8/); - - // The load step is a verbatim copy of ios-ship.yml's, so a fix to one cannot - // silently skip the other. - const step = "Load ASC credentials (Fleet-OPS prod /asc)"; - assert.equal(stepBlock(code, step), stepBlock(iosCode, step), "the ASC load step drifted from ios-ship.yml"); - - // The signing identities stay repository secrets, scoped to the import step. - const imports = stepBlock(code, "Import signing identities"); - for (const name of ["IOS_DIST_P12_BASE64", "IOS_DIST_P12_PASSWORD", "MAC_INSTALLER_P12_BASE64", "MAC_INSTALLER_P12_PASSWORD"]) { - assert.match(imports, new RegExp(`${name}:\\s*\\$\\{\\{ secrets\\.${name} \\}\\}`), `${name} must reach the import step`); - assert.equal((code.match(new RegExp(`secrets\\.${name}`, "g")) || []).length, 1, `${name} must not leak into other steps`); - } - assert.match(imports, /mac-appstore-gm-prepare\.sh/); - - // `secrets.*` in `if:` kills the whole workflow at dispatch time. - assert.doesNotMatch(code, /if:.*secrets\./); - - const prepare = read("scripts/mac-appstore-gm-prepare.sh"); - assert.match(prepare, /ASC_KEY_P8 or ASC_KEY_PATH required/); - assert.match(prepare, /KEY_PATH="\$\{ASC_KEY_PATH:-/); - assert.doesNotMatch(prepare, /echo "\$ASC_KEY_P8"/); - assert.doesNotMatch(prepare, /echo "\$IOS_DIST_P12/); - assert.doesNotMatch(prepare, /echo "\$MAC_INSTALLER_P12/); -}); - -test("the ship carries no standing gate override and always publishes What to Test", () => { - assert.doesNotMatch(code, /IOS_TF_MIN_INTERVAL_SEC/); - assert.doesNotMatch(code, /--force-ship/); - assert.doesNotMatch(code, /IOS_TF_RELEASE_NOTES:\s*"0"/); - assert.doesNotMatch(code, /allow-unverified-seq/); - assert.doesNotMatch(code, /IOS_SHIP_PRIORITY/); -}); - -test("ship state cache is keyed apart from the iOS ship and from the retired 0.1.N sequence", () => { - const keys = [...code.matchAll(/ios-fleet-state-codecaps-[A-Za-z0-9]*-?[A-Za-z0-9]*-?/g)].map((m) => m[0]); - assert.ok(keys.length >= 6, "gate and ship restore and save the state cache"); - for (const k of keys) assert.equal(k, "ios-fleet-state-codecaps-mac-v1-", `${k} is not the Mac 1.0.N cache family`); - // restore-keys match by prefix, so neither family may be a prefix of the other. - const iosFamily = "ios-fleet-state-codecaps-ios-"; - assert.ok(!iosFamily.startsWith("ios-fleet-state-codecaps-mac-v1-") && !"ios-fleet-state-codecaps-mac-v1-".startsWith(iosFamily)); - assert.match(iosCode, /ios-fleet-state-codecaps-ios-/); -}); - -test("the marketing train is 1.0.N and the ship script targets codecaps-mac", () => { - const apps = JSON.parse(read("scripts/ios-fleet/apps.json")); - const mac = apps.apps["codecaps-mac"]; - assert.match(mac.marketingVersionDefault, /^1\.0\.\d+$/, "0.x.x versions are banned by AGENT-SYNC"); - assert.equal(mac.bundleId, "com.simplewithus.codecaps.macos"); - assert.equal(mac.scheme, "CodeCapsCompanionMac"); - assert.equal(mac.projectRel, "ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj"); -}); - -test("the Mac profile map still names both Mac bundle ids", () => { - const map = JSON.parse(read("ios/CodeCapsCompanion/mac-appstore-profiles.json")); - assert.deepEqual(Object.keys(map).sort(), ["com.simplewithus.codecaps.macos", "com.simplewithus.codecaps.macos.widgets"]); -}); - -test("the shared gate and key-handoff shell tests still pass", () => { - for (const script of ["scripts/ios-fleet/test-scheduled-ship-gate.sh", "scripts/test-ios-stage-asc-key.sh"]) { - const out = execFileSync("bash", [join(ROOT, script)], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); - assert.match(out, /all tests passed/, `${script} did not report success`); - } -});