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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions .github/workflows/ios-ship-contract.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
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:
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:
Expand All @@ -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:
Expand All @@ -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
267 changes: 239 additions & 28 deletions .github/workflows/macos-ship.yml
Original file line number Diff line number Diff line change
@@ -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 }}
Comment thread
jaywedgeworth22 marked this conversation as resolved.
Expand All @@ -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: |
Expand Down Expand Up @@ -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
Comment thread
jaywedgeworth22 marked this conversation as resolved.
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"
Expand All @@ -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"
Expand All @@ -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()
Expand All @@ -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
Loading
Loading