From 9ffcb3b516432d25faf228e8864d9c93ab721d03 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:38:16 -0500 Subject: [PATCH 1/2] CC-165 Auto-ship CodeCaps for Mac to TestFlight on merge macos-ship.yml was workflow_dispatch only, 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, which App Store Connect answers with HTTP 401, and was on the retired 0.1.N marketing train. - Push to main on the Mac native tree (ios/CodeCapsCompanion/** plus the eight exact Sources/QuotaCore files the Mac targets compile), workflow_dispatch, and a catch-up cron behind scheduled-ship-gate.sh (codecaps-mac). - ASC key from Fleet-OPS prod /asc through the same step as ios-ship.yml; mac-appstore-gm-prepare.sh accepts the staged ASC_KEY_PATH. - Marketing train 1.0.N with its own state cache family, so the 0.1.N build sequence cannot carry over. cancel-in-progress is now false. - scripts/macos-ship-workflow.test.mjs pins all of it in the iOS ship contract check, including parity of the ASC step with ios-ship.yml. Refs CC-165 Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ios-ship-contract.yml | 14 +- .github/workflows/macos-ship.yml | 267 +++++++++++++++++++--- docs/IOS-TESTFLIGHT.md | 11 + scripts/ios-fleet/apps.json | 4 +- scripts/mac-appstore-gm-prepare.sh | 23 +- scripts/macos-ship-workflow.test.mjs | 285 ++++++++++++++++++++++++ 6 files changed, 565 insertions(+), 39 deletions(-) create mode 100644 scripts/macos-ship-workflow.test.mjs diff --git a/.github/workflows/ios-ship-contract.yml b/.github/workflows/ios-ship-contract.yml index 1b5b5094..04f9d757 100644 --- a/.github/workflows/ios-ship-contract.yml +++ b/.github/workflows/ios-ship-contract.yml @@ -1,11 +1,11 @@ name: iOS ship contract -# Pins the iOS auto-ship wiring (owner 2026-10-17): a merge to main that -# touches the iOS native tree, including the shared Sources/QuotaCore files its +# 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 # 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. +# scripts/ios-ship-workflow.test.mjs and scripts/macos-ship-workflow.test.mjs. on: pull_request: @@ -13,7 +13,10 @@ on: - '.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: @@ -22,7 +25,10 @@ on: - '.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: @@ -42,4 +48,4 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Pin the auto-ship wiring - run: node --test scripts/ios-ship-workflow.test.mjs + run: node --test scripts/ios-ship-workflow.test.mjs scripts/macos-ship-workflow.test.mjs diff --git a/.github/workflows/macos-ship.yml b/.github/workflows/macos-ship.yml index 3efc1eb9..1a5ea44c 100644 --- a/.github/workflows/macos-ship.yml +++ b/.github/workflows/macos-ship.yml @@ -1,36 +1,151 @@ name: Mac TestFlight ship (GitHub-hosted macOS) -# Archive + upload CodeCaps for Mac on GitHub-hosted macos-latest. -# Manual only (workflow_dispatch) — same family as HogHunter ios-testflight.yml. -# Signing + ASC keys from repo secrets (names only; values never printed): -# ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8, -# IOS_DIST_P12_BASE64, IOS_DIST_P12_PASSWORD, -# MAC_INSTALLER_P12_BASE64, MAC_INSTALLER_P12_PASSWORD +# 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. -# Marketing train is 0.1.N; build is UTC YYYYMMDDHHMM. -# Extra-ship no. No --force-ship. No local Mac seat_launch. +# Marketing train is 1.0.N (AGENT-SYNC: 0.x.x is banned); build is UTC +# YYYYMMDDHHMM. +# +# 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: true + cancel-in-progress: false jobs: - await-swift-ci: - name: Wait for Swift CI + # 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 }} @@ -39,35 +154,30 @@ jobs: ship: name: Mac TestFlight archive + upload - needs: await-swift-ci + needs: gate if: >- github.event.repository.fork == false && github.ref == 'refs/heads/main' && - needs.await-swift-ci.result == 'success' + 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 - ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} - ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} - ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} - 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 }} 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-${{ github.run_id }}-${{ github.run_attempt }} + key: ios-fleet-state-codecaps-mac-v1-${{ github.run_id }}-${{ github.run_attempt }} restore-keys: | - ios-fleet-state-codecaps-mac-${{ github.run_id }}- - ios-fleet-state-codecaps-mac- + ios-fleet-state-codecaps-mac-v1-${{ github.run_id }}- + ios-fleet-state-codecaps-mac-v1- - name: Assert stable Xcode (never beta) run: | @@ -101,10 +211,108 @@ jobs: GH_FALLBACK_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} GH_FALLBACK_SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - - name: Import signing + ASC key + # 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: | - if [ -z "${ASC_KEY_ID:-}" ]; then - echo "::error::ASC_KEY_ID empty. Confirm ASC_/IOS_DIST_/MAC_INSTALLER_ secrets are configured." + 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" @@ -114,7 +322,7 @@ jobs: - name: Clear stale archive lock from ship-state cache run: | - # Hosted runners restore ~/.cache/ios-fleet; a cancelled prior ship can + # 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" @@ -130,7 +338,7 @@ jobs: uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ios-fleet - key: ios-fleet-state-codecaps-mac-${{ github.run_id }}-${{ github.run_attempt }} + key: ios-fleet-state-codecaps-mac-v1-${{ github.run_id }}-${{ github.run_attempt }} - name: Remove runner signing material if: always() @@ -142,3 +350,6 @@ jobs: "$HOME/.secrets/mac-installer.p12" \ "$HOME/.secrets/appstore-connect.env" \ "$HOME/.secrets/AuthKey.p8" + 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 94c3f86d..77386573 100644 --- a/docs/IOS-TESTFLIGHT.md +++ b/docs/IOS-TESTFLIGHT.md @@ -30,3 +30,14 @@ The marketing version train is `1.0.N` (`marketingVersionDefault` in `scripts/io 1. `gh workflow view ios-ship.yml -R Simple-With-Us/CodeCaps` must say the workflow is active, and the latest `main` push touching the paths above should have a run. 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) + +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. + +- **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 `node ~/apps/ios-fleet/ios_testflight_freshness.mjs --app codecaps-mac`. diff --git a/scripts/ios-fleet/apps.json b/scripts/ios-fleet/apps.json index 18cec699..d1ec22da 100644 --- a/scripts/ios-fleet/apps.json +++ b/scripts/ios-fleet/apps.json @@ -22,8 +22,8 @@ "projectRel": "ios/CodeCapsCompanion/CodeCapsCompanion.xcodeproj", "xcodegenDir": "ios/CodeCapsCompanion", "worktreeHint": "~/Code/codecaps", - "marketingVersionDefault": "0.1.0", - "notes": "CodeCaps Companion for Mac. Bundle ID com.simplewithus.codecaps.macos. Team CC8UTF7ATG. AppleId 6814951477. ASC SKU codecaps-macos. Marketing train 0.1.N; build UTC YYYYMMDDHHMM." + "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/mac-appstore-gm-prepare.sh b/scripts/mac-appstore-gm-prepare.sh index be304a35..9d8a9d4c 100755 --- a/scripts/mac-appstore-gm-prepare.sh +++ b/scripts/mac-appstore-gm-prepare.sh @@ -1,8 +1,10 @@ #!/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. +# 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] $*"; } @@ -11,12 +13,16 @@ 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)." + 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}" -: "${ASC_KEY_P8:?ASC_KEY_P8 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}" @@ -26,8 +32,13 @@ SECRETS_DIR="${HOME}/.secrets" mkdir -p "$SECRETS_DIR" chmod 700 "$SECRETS_DIR" -KEY_PATH="${SECRETS_DIR}/AuthKey.p8" -printf '%s\n' "$ASC_KEY_P8" > "$KEY_PATH" +KEY_PATH="${ASC_KEY_PATH:-${SECRETS_DIR}/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" +fi chmod 600 "$KEY_PATH" ENV_PATH="${SECRETS_DIR}/appstore-connect.env" @@ -61,6 +72,8 @@ security unlock-keychain -p "$KC_PASS" "$KC_PATH" # -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 diff --git a/scripts/macos-ship-workflow.test.mjs b/scripts/macos-ship-workflow.test.mjs new file mode 100644 index 00000000..d9cdb640 --- /dev/null +++ b/scripts/macos-ship-workflow.test.mjs @@ -0,0 +1,285 @@ +// 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`); + } +}); From 05c65686202a133eea5e7e990ba1cbfae183414c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:00:55 -0500 Subject: [PATCH 2/2] CC-165 Keep the local ios-fleet path out of the public TestFlight doc Review finding on PR 254: the doc named a path under ~/apps/ios-fleet. The step now describes the freshness check by purpose. Refs CC-165 Co-Authored-By: Claude Sonnet 5.5 --- docs/IOS-TESTFLIGHT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/IOS-TESTFLIGHT.md b/docs/IOS-TESTFLIGHT.md index 77386573..6f411c6a 100644 --- a/docs/IOS-TESTFLIGHT.md +++ b/docs/IOS-TESTFLIGHT.md @@ -40,4 +40,4 @@ The Mac App Store build of CodeCaps ships the same way, from `.github/workflows/ - **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 `node ~/apps/ios-fleet/ios_testflight_freshness.mjs --app codecaps-mac`. +- **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.