From f961ca37e5acc98942090c1611ab87c04c2dd128 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:18:20 -0500 Subject: [PATCH 01/39] fix: load Sparkle notarization credentials from Fleet-OPS Infisical --- .github/workflows/mac-release.yml | 125 ++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 17 deletions(-) diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index f9e0ba64..289f31fa 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -11,8 +11,9 @@ name: Mac Release (Sparkle) # BotFleet's release.yml does (INFISICAL_PROJECT_ID plus # INFISICAL_UNIVERSAL_AUTH_CLIENT_ID/_SECRET, or INFISICAL_CLIENT_ID/ # _SECRET), with same-named repository secrets as the fallback. -# ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 The App Store Connect API key that -# ios-ship.yml already uses; notarytool signs in with it. +# ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 come from Fleet-OPS Infisical prod +# /asc, matching ios-ship.yml. Never fall back to the stale repository +# ASC_* secrets. The private key stays in a mode-600 temporary file. # SPARKLE_ED_PRIVATE_KEY The EdDSA key that signs each update archive # (Sparkle's generate_keys -x output). # With any of them missing the job still builds the bundle and verifies its @@ -27,8 +28,12 @@ on: - 'Package.resolved' - 'VERSION' - 'assets/icon-*.png' + - 'assets/AppIcon.icns' - 'script/build_and_run.sh' - 'script/make_appcast.sh' + - 'scripts/check-notary-access.sh' + - 'scripts/mac-release-workflow.test.mjs' + - 'scripts/ios-stage-asc-key.sh' - 'script/make_icon.swift' - '.github/workflows/mac-release.yml' # A pull request that touches the bundle is built and verified the same @@ -39,6 +44,9 @@ on: - 'Package.resolved' - 'script/build_and_run.sh' - 'script/make_appcast.sh' + - 'scripts/check-notary-access.sh' + - 'scripts/mac-release-workflow.test.mjs' + - 'scripts/ios-stage-asc-key.sh' - '.github/workflows/mac-release.yml' - '.github/actions/infisical-secrets/**' - 'scripts/infisical-fetch.mjs' @@ -67,6 +75,9 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Test Sparkle release contract without credentials + run: node --test scripts/mac-release-workflow.test.mjs + - name: Wait for Swift CI compile checks on this commit env: GH_TOKEN: ${{ github.token }} @@ -82,10 +93,7 @@ jobs: runs-on: macos-latest timeout-minutes: 60 env: - ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} - ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} - ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} - SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + SPARKLE_ED_PRIVATE_KEY: ${{ github.event_name != 'pull_request' && secrets.SPARKLE_ED_PRIVATE_KEY || '' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -107,6 +115,88 @@ jobs: GH_FALLBACK_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} GH_FALLBACK_SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: Load ASC credentials (Fleet-OPS prod /asc) + if: github.event_name != 'pull_request' + 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: Decide whether this run can publish id: gate run: | @@ -116,9 +206,12 @@ jobs: exit 0 fi missing=() - for name in MAC_CERT_P12_BASE64 MAC_CERT_PASSWORD ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 SPARKLE_ED_PRIVATE_KEY; do + for name in MAC_CERT_P12_BASE64 MAC_CERT_PASSWORD ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_PATH SPARKLE_ED_PRIVATE_KEY; do [ -n "${!name:-}" ] || missing+=("$name") done + if [ -n "${ASC_KEY_PATH:-}" ] && [ ! -s "$ASC_KEY_PATH" ]; then + missing+=("ASC_KEY_PATH file") + fi if [ "${#missing[@]}" -eq 0 ]; then echo "publish=true" >> "$GITHUB_OUTPUT" else @@ -126,6 +219,10 @@ jobs: echo "::notice title=Mac release not published::Missing: ${missing[*]}. The bundle is still built and its signature verified. See docs/AUTO-UPDATE.md, Secrets." fi + - name: Verify notarization access before building + if: steps.gate.outputs.publish == 'true' + run: bash scripts/check-notary-access.sh + - name: Install XcodeGen run: | if ! command -v xcodegen >/dev/null 2>&1; then @@ -169,18 +266,12 @@ jobs: echo "CODECAPS_CODESIGN_IDENTITY=$identity" >> "$GITHUB_ENV" echo "imported: $identity" - - name: Write the notarization key - if: steps.gate.outputs.publish == 'true' - run: | - umask 077 - printf '%s\n' "$ASC_KEY_P8" > "$RUNNER_TEMP/AuthKey.p8" - - name: Build, sign, notarize and staple if: steps.gate.outputs.publish == 'true' env: - NOTARY_KEY_PATH: ${{ runner.temp }}/AuthKey.p8 - NOTARY_KEY_ID: ${{ secrets.ASC_KEY_ID }} - NOTARY_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + NOTARY_KEY_PATH: ${{ env.ASC_KEY_PATH }} + NOTARY_KEY_ID: ${{ env.ASC_KEY_ID }} + NOTARY_ISSUER_ID: ${{ env.ASC_ISSUER_ID }} run: script/build_and_run.sh --release - name: Upload Sentry debug files @@ -275,4 +366,4 @@ jobs: if: always() run: | security delete-keychain "$RUNNER_TEMP/codecaps-signing.keychain-db" >/dev/null 2>&1 || true - rm -f "$RUNNER_TEMP/AuthKey.p8" + if [ -n "${ASC_KEY_PATH:-}" ]; then rm -f -- "$ASC_KEY_PATH"; fi From 220da747c4705630509be722a4e0bcbdaec951fe Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:18:41 -0500 Subject: [PATCH 02/39] test: verify Sparkle credential wiring and sanitized notarization preflight --- scripts/check-notary-access.sh | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 scripts/check-notary-access.sh diff --git a/scripts/check-notary-access.sh b/scripts/check-notary-access.sh new file mode 100644 index 00000000..8cef2802 --- /dev/null +++ b/scripts/check-notary-access.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Read-only authentication preflight. Never print Apple's raw diagnostic text: +# it can contain account identifiers or request details. No credential storage. +set +o xtrace +set -euo pipefail +for name in ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_PATH; do + if [[ -z "${!name:-}" ]]; then + echo "error: missing $name from Fleet-OPS prod /asc" >&2 + exit 1 + fi +done +if [[ ! -s "$ASC_KEY_PATH" ]]; then + echo 'error: staged ASC key file is missing or empty' >&2 + exit 1 +fi +umask 077 +diagnostic="$(mktemp "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/notary-access.XXXXXX")" +trap 'rm -f -- "$diagnostic"' EXIT +if xcrun notarytool history --key "$ASC_KEY_PATH" --key-id "$ASC_KEY_ID" \ + --issuer "$ASC_ISSUER_ID" --output-format json > /dev/null 2> "$diagnostic"; then + echo 'Notarization service authentication succeeded; artifact acceptance remains a separate release gate.' +else + status=$? + category='unclassified service or client failure' + if grep -Eiq '(^|[^0-9])401([^0-9]|$)|unauthorized|authentication' "$diagnostic"; then + category='authentication rejected; verify the existing Fleet-OPS /asc key' + elif grep -Eiq '(^|[^0-9])403([^0-9]|$)|forbidden|agreement|permission' "$diagnostic"; then + category='service access rejected; verify key permissions and account agreements' + elif grep -Eiq 'timed out|timeout|network|resolve host|connection' "$diagnostic"; then + category='network or service connectivity failure' + elif grep -Eiq 'private key|key format|parse.*key|issuer' "$diagnostic"; then + category='key format or issuer configuration failure' + fi + printf 'error: notarization preflight failed (exit %s): %s. Raw response withheld.\n' "$status" "$category" >&2 + exit 1 +fi From 5f1b265ebfabc5fe29454ee7d961a248bf8a8eb8 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:18:46 -0500 Subject: [PATCH 03/39] test: verify Sparkle credential wiring and sanitized notarization preflight --- scripts/mac-release-workflow.test.mjs | 65 +++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 scripts/mac-release-workflow.test.mjs diff --git a/scripts/mac-release-workflow.test.mjs b/scripts/mac-release-workflow.test.mjs new file mode 100644 index 00000000..6b9028cd --- /dev/null +++ b/scripts/mac-release-workflow.test.mjs @@ -0,0 +1,65 @@ +// Credential-free contract and mocked notarization preflight tests. +import assert from 'node:assert/strict'; +import {readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, readdirSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {spawnSync} from 'node:child_process'; +import test from 'node:test'; +const root = fileURLToPath(new URL('../', import.meta.url)); +const read = p => readFileSync(join(root, p), 'utf8'); +const workflow = read('.github/workflows/mac-release.yml'); +const code = workflow.split('\n').filter(l => !/^\s*#/.test(l)).join('\n'); +function step(s, name) { + const start = s.indexOf(` - name: ${name}\n`); + assert.ok(start >= 0, name); + const end = s.indexOf('\n - ', start + 1); + return s.slice(start, end < 0 ? undefined : end).trim(); +} +test('Sparkle uses the existing fleet ASC loader, never repository ASC secrets', () => { + assert.doesNotMatch(code, /secrets\.ASC_/); + const name = 'Load ASC credentials (Fleet-OPS prod /asc)'; + assert.equal(step(workflow, name).replace(" if: github.event_name != 'pull_request'\n", ''), step(read('.github/workflows/ios-ship.yml'), name)); + assert.match(step(workflow, name), /if: github.event_name != 'pull_request'/); + assert.match(code, /NOTARY_KEY_PATH: \$\{\{ env.ASC_KEY_PATH \}\}/); + assert.match(code, /NOTARY_KEY_ID: \$\{\{ env.ASC_KEY_ID \}\}/); + assert.match(code, /NOTARY_ISSUER_ID: \$\{\{ env.ASC_ISSUER_ID \}\}/); + assert.doesNotMatch(code, /printf.*ASC_KEY_P8|ASC_KEY_P8:/); + assert.match(code, /rm -f -- "\$ASC_KEY_PATH"/); +}); +test('PRs cannot notarize or publish, and icon-only changes trigger releases', () => { + assert.match(step(workflow, 'Decide whether this run can publish'), /GITHUB_EVENT_NAME.*pull_request[\s\S]*publish=false[\s\S]*exit 0/); + for (const name of ['Verify notarization access before building', 'Build, sign, notarize and staple', 'Write the signed appcast', 'Publish the GitHub release', 'Confirm the live feed serves this build']) { + assert.match(step(workflow, name), /if: steps.gate.outputs.publish == 'true'/); + } + assert.match(code, /'assets\/AppIcon.icns'/); + assert.match(code, /run: node --test scripts\/mac-release-workflow.test.mjs/); + assert.match(code, /needs.await-swift-ci.result == 'success'/); + assert.match(code, /run: script\/build_and_run.sh --release/); + assert.ok(code.indexOf('Verify notarization access') < code.indexOf('Build, sign, notarize')); +}); +function preflight(exitCode, error, missing = false) { + const dir = mkdtempSync(join(tmpdir(), 'notary-test-')); + try { + const bin = join(dir, 'bin'); mkdirSync(bin); + const marker = join(dir, 'called'); + writeFileSync(join(bin, 'xcrun'), '#!/bin/bash\nprintf called > "$MOCK_MARKER"\n[[ "$1 $2" == "notarytool history" ]] || exit 99\nprintf "%s" "$MOCK_ERROR" >&2\nexit "$MOCK_EXIT"\n', {mode: 0o700}); + const key = join(dir, 'key'); writeFileSync(key, 'FAKE TEST KEY'); + const r = spawnSync('bash', [join(root, 'scripts/check-notary-access.sh')], {encoding:'utf8', env:{...process.env, PATH:bin+':'+process.env.PATH, RUNNER_TEMP:dir, ASC_KEY_ID: missing ? '' : 'FAKE-ID', ASC_ISSUER_ID:'FAKE-ISSUER', ASC_KEY_PATH:key, MOCK_EXIT:String(exitCode), MOCK_ERROR:error, MOCK_MARKER:marker}}); + assert.ok(!readdirSync(dir).some(p => p.startsWith('notary-access.')), 'temporary diagnostics cleaned'); + assert.ok(!(r.stdout+r.stderr).includes('PRIVATE_SENTINEL'), 'raw error never logged'); + if (missing) assert.ok(!readdirSync(dir).includes('called')); + return r; + } finally { rmSync(dir, {recursive:true, force:true}); } +} +test('read-only preflight accepts service auth without claiming artifact acceptance', () => { + const r=preflight(0,''); assert.equal(r.status,0); assert.match(r.stdout,/artifact acceptance remains a separate/); +}); +for (const [error, category] of [['HTTP 401 PRIVATE_SENTINEL','authentication rejected'], ['HTTP 403 PRIVATE_SENTINEL','service access rejected'], ['connection timed out PRIVATE_SENTINEL','connectivity failure'], ['cannot parse private key PRIVATE_SENTINEL','key format'], ['PRIVATE_SENTINEL','unclassified']]) { + test(`preflight fails closed with sanitized ${category}`, () => { + const r=preflight(7,error); assert.equal(r.status,1); assert.ok(r.stderr.includes(category)); assert.match(r.stderr,/exit 7/); + }); +} +test('missing credential fails before invoking Apple', () => { + const r=preflight(0,'',true); assert.equal(r.status,1); assert.match(r.stderr,/missing ASC_KEY_ID/); +}); From 96a2773cb3e30b557a78dc1d86da68c0ff0d2b8f Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:18:50 -0500 Subject: [PATCH 04/39] docs: explain Sparkle credential source and feed recovery gates --- docs/AUTO-UPDATE.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/AUTO-UPDATE.md b/docs/AUTO-UPDATE.md index 8a3afe61..ed7dd861 100644 --- a/docs/AUTO-UPDATE.md +++ b/docs/AUTO-UPDATE.md @@ -67,11 +67,17 @@ A private repository cannot do this, because anonymous clients cannot download i | Name | Where | Status on Tue, Sep 29, 2026 | |---|---|---| | `MAC_CERT_P12_BASE64`, `MAC_CERT_PASSWORD` | Infisical (the way BotFleet's `release.yml` reads them), needing `INFISICAL_PROJECT_ID` and `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID`/`_SECRET` (or `INFISICAL_CLIENT_ID`/`_SECRET`) repository secrets; or same-named repository secrets as the fallback | Missing. Publishing is gated until they exist. | -| `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_P8` | Repository secrets. `ios-ship.yml` no longer reads them (App Store Connect answers them with HTTP 401, CC-163) and takes the fleet key from Fleet-OPS Infisical prod `/asc`; see `docs/IOS-TESTFLIGHT.md` | Present, stale for App Store Connect. | +| `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_P8` | Fleet-OPS Infisical prod `/asc`, using the same loader as `ios-ship.yml`. The stale repository copies are never read. The PEM is staged by `scripts/ios-stage-asc-key.sh` to a mode-600 temporary file; only its path crosses steps. | Notarization access is verified with read-only `notarytool history` before building. Artifact acceptance is still required separately. | | `SPARKLE_ED_PRIVATE_KEY` | Repository secret | Present. | While any of them is missing, the release job still builds the bundle and verifies its signature ad hoc, then finishes green with a notice naming what is missing. Nothing is published. +### Diagnosing a stale Sparkle feed + +`mac-release.yml` must complete notarization, stapling, archive signing, release publication and live-feed verification. A successful TestFlight upload is a separate channel and does not prove Sparkle was updated. `scripts/check-notary-access.sh` performs a read-only authentication check before an expensive release build and reports only a fixed error category, never Apple's raw response or credential values. Access failure stops the release; do not bypass notarization or generate a new key as a workaround. Check the existing Fleet-OPS `/asc` key's permissions and account state. Contract tests run without credentials on pull requests. + +A repaired pipeline may initially publish a build below a locally installed feature-branch build. Keep the existing monotonic version scheme: an installed build updates only when the published build number is greater. + ## Signing, Inside-Out Sparkle ships helpers inside its framework, and each one is signed before the thing that contains it, in the order Sparkle's code-signing guide gives: From 51629d35b2da1780f2c0af7500e095c73023776a Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:21:17 -0500 Subject: [PATCH 05/39] docs: clarify fail-closed ASC credential loading --- .github/workflows/mac-release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index 289f31fa..210055ed 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -16,8 +16,9 @@ name: Mac Release (Sparkle) # ASC_* secrets. The private key stays in a mode-600 temporary file. # SPARKLE_ED_PRIVATE_KEY The EdDSA key that signs each update archive # (Sparkle's generate_keys -x output). -# With any of them missing the job still builds the bundle and verifies its -# signature, then skips publishing with a notice that names what is missing. +# Pull requests build without credentials and never publish. A main release +# fails early if the fleet ASC loader or notarization preflight fails. Missing +# certificate/Sparkle material is reported by the publication gate. on: push: From 3eee6b8f3aec5a076f445750d8a3170926013fbe Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:21:21 -0500 Subject: [PATCH 06/39] test: pin secret-free PR guards and always cleanup --- scripts/mac-release-workflow.test.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/mac-release-workflow.test.mjs b/scripts/mac-release-workflow.test.mjs index 6b9028cd..6eda37cd 100644 --- a/scripts/mac-release-workflow.test.mjs +++ b/scripts/mac-release-workflow.test.mjs @@ -28,6 +28,10 @@ test('Sparkle uses the existing fleet ASC loader, never repository ASC secrets', assert.match(code, /rm -f -- "\$ASC_KEY_PATH"/); }); test('PRs cannot notarize or publish, and icon-only changes trigger releases', () => { + assert.match(step(workflow, 'Load the Developer ID certificate (Infisical, then repository secrets)'), /if: github.event_name != 'pull_request'/); + assert.match(code, /SPARKLE_ED_PRIVATE_KEY: \$\{\{ github.event_name != 'pull_request' && secrets.SPARKLE_ED_PRIVATE_KEY \|\| '' \}\}/); + assert.match(step(workflow, 'Remove the signing keychain and key'), /if: always\(\)/); + assert.match(step(workflow, 'Remove the signing keychain and key'), /rm -f -- "\$ASC_KEY_PATH"/); assert.match(step(workflow, 'Decide whether this run can publish'), /GITHUB_EVENT_NAME.*pull_request[\s\S]*publish=false[\s\S]*exit 0/); for (const name of ['Verify notarization access before building', 'Build, sign, notarize and staple', 'Write the signed appcast', 'Publish the GitHub release', 'Confirm the live feed serves this build']) { assert.match(step(workflow, name), /if: steps.gate.outputs.publish == 'true'/); From 1f6839f46c732b0eeeb77f614264d1d351679385 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:21:26 -0500 Subject: [PATCH 07/39] docs: distinguish missing ASC failure from non-publishing validation --- docs/AUTO-UPDATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/AUTO-UPDATE.md b/docs/AUTO-UPDATE.md index ed7dd861..6659fb38 100644 --- a/docs/AUTO-UPDATE.md +++ b/docs/AUTO-UPDATE.md @@ -70,7 +70,7 @@ A private repository cannot do this, because anonymous clients cannot download i | `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_P8` | Fleet-OPS Infisical prod `/asc`, using the same loader as `ios-ship.yml`. The stale repository copies are never read. The PEM is staged by `scripts/ios-stage-asc-key.sh` to a mode-600 temporary file; only its path crosses steps. | Notarization access is verified with read-only `notarytool history` before building. Artifact acceptance is still required separately. | | `SPARKLE_ED_PRIVATE_KEY` | Repository secret | Present. | -While any of them is missing, the release job still builds the bundle and verifies its signature ad hoc, then finishes green with a notice naming what is missing. Nothing is published. +Pull requests skip credential loading, perform ad-hoc structural validation and never publish. On main, a missing Infisical identity, missing fleet ASC material, or failed notarization preflight fails the job before publication. If fleet ASC loading succeeds but certificate or Sparkle signing material is missing, the publication gate reports the missing names and runs the existing ad-hoc build-only validation. None of these non-publishing paths updates the feed. ### Diagnosing a stale Sparkle feed From 523609fe2f2783a4692a5c1858f817854ef3c052 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:27:21 -0500 Subject: [PATCH 08/39] fix: keep project identifiers out of credential failure messages --- .github/workflows/mac-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index 210055ed..fc945034 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -172,7 +172,7 @@ jobs: 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 + echo "::error::Missing $name in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc." >&2 exit 1 fi if [[ "$val" == *$'\n'* || "$val" == *$'\r'* ]]; then @@ -192,7 +192,7 @@ jobs: 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 + echo "::error::Missing ASC key material in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc." >&2 exit 1 fi printf '%s\n' "$key_material" | bash "$GITHUB_WORKSPACE/scripts/ios-stage-asc-key.sh" From b4a8cc8460a84bcfe567f8326fe589885a643fa4 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:27:26 -0500 Subject: [PATCH 09/39] test: pin fixed credential error messages without project identifiers --- scripts/mac-release-workflow.test.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/mac-release-workflow.test.mjs b/scripts/mac-release-workflow.test.mjs index 6eda37cd..274def93 100644 --- a/scripts/mac-release-workflow.test.mjs +++ b/scripts/mac-release-workflow.test.mjs @@ -19,7 +19,12 @@ function step(s, name) { test('Sparkle uses the existing fleet ASC loader, never repository ASC secrets', () => { assert.doesNotMatch(code, /secrets\.ASC_/); const name = 'Load ASC credentials (Fleet-OPS prod /asc)'; - assert.equal(step(workflow, name).replace(" if: github.event_name != 'pull_request'\n", ''), step(read('.github/workflows/ios-ship.yml'), name)); + const normalize = s => s.replaceAll('verify the CI identity has read access to project ${ASC_INFISICAL_PROJECT_ID} path /asc.', 'verify the CI identity has read access to Fleet-OPS prod /asc.'); + assert.equal(step(workflow, name).replace(" if: github.event_name != 'pull_request'\n", ''), normalize(step(read('.github/workflows/ios-ship.yml'), name))); + const loader = step(workflow, name); + assert.doesNotMatch(loader, /echo .*\$\{ASC_INFISICAL_PROJECT_ID\}/); + assert.match(loader, /Missing \$name in Fleet-OPS prod \/asc; verify the CI identity has read access to Fleet-OPS prod \/asc\./); + assert.match(loader, /Missing ASC key material in Fleet-OPS prod \/asc; verify the CI identity has read access to Fleet-OPS prod \/asc\./); assert.match(step(workflow, name), /if: github.event_name != 'pull_request'/); assert.match(code, /NOTARY_KEY_PATH: \$\{\{ env.ASC_KEY_PATH \}\}/); assert.match(code, /NOTARY_KEY_ID: \$\{\{ env.ASC_KEY_ID \}\}/); From 50b2a6cceea7b8dc45d7439ab83d7449caf49e1c Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:49:24 -0500 Subject: [PATCH 10/39] fix(release): load the signing key without a hard-coded project id The Mac release has failed on every merge to main since 2026-10-11. The cause was the credential source and not the release script: the workflow read the App Store Connect key from repository secrets that are a revoked copy (HTTP 401, CC-163), while the working key lives in Fleet-OPS prod /asc. This replaces the bare `infisical secrets get` calls with the repository's own safe fetch, in both workflows that read the key. - ASC_KEY_ID and ASC_ISSUER_ID come from the infisical-secrets composite action, which masks every line of every value and never prints one. - ASC_KEY_P8 is fetched by scripts/infisical-fetch-asc-key.mjs and streamed into scripts/ios-stage-asc-key.sh over a pipe, so the private key still never becomes an environment variable and only its mode-600 file path crosses the step boundary. The project id is read from the INFISICAL_FLEET_OPS_PROJECT_ID secret instead of being written into a public workflow, where it was also interpolated into the job log on every failed notarization. A dispatch-only job, "Verify the release credentials load", now fetches the key and checks that Apple's notarization service still accepts it, and publishes nothing. A green pull-request check proves the workflow's own contract, not that a credential can be fetched, which is why this outage ran for nine days without a check noticing. Fixes CC-177 --- .github/workflows/ios-ship-contract.yml | 4 +- .github/workflows/ios-ship.yml | 94 +++-------- .github/workflows/mac-release.yml | 154 ++++++++++-------- docs/EFFORT-LOG.md | 42 +++++ scripts/infisical-fetch-asc-key.mjs | 86 ++++++++++ scripts/infisical-fetch-asc-key.node-test.mjs | 129 +++++++++++++++ scripts/ios-ship-workflow.test.mjs | 21 ++- scripts/mac-release-workflow.test.mjs | 51 +++++- scripts/test-ios-stage-asc-key.sh | 103 +++--------- 9 files changed, 451 insertions(+), 233 deletions(-) create mode 100644 scripts/infisical-fetch-asc-key.mjs create mode 100644 scripts/infisical-fetch-asc-key.node-test.mjs diff --git a/.github/workflows/ios-ship-contract.yml b/.github/workflows/ios-ship-contract.yml index da8270af..02cbc027 100644 --- a/.github/workflows/ios-ship-contract.yml +++ b/.github/workflows/ios-ship-contract.yml @@ -14,6 +14,7 @@ on: - '.github/workflows/ios-ship*.yml' - 'ios/CodeCapsCompanion/project.yml' - 'scripts/ios-*' + - 'scripts/infisical-fetch*.mjs' - 'scripts/ios-fleet/**' - 'scripts/test-ios-stage-asc-key.sh' push: @@ -22,6 +23,7 @@ on: - '.github/workflows/ios-ship*.yml' - 'ios/CodeCapsCompanion/project.yml' - 'scripts/ios-*' + - 'scripts/infisical-fetch*.mjs' - 'scripts/ios-fleet/**' - 'scripts/test-ios-stage-asc-key.sh' workflow_dispatch: @@ -42,6 +44,6 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Pin the auto-ship wiring run: | - node --test scripts/ios-ship-workflow.test.mjs + node --test scripts/ios-ship-workflow.test.mjs scripts/infisical-fetch-asc-key.node-test.mjs bash scripts/ios-fleet/test-asc-credentials.sh node --test scripts/ios-fleet/test-asc-credentials.mjs diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index bf331b37..f94679f2 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -73,6 +73,8 @@ on: - '.github/workflows/ios-ship.yml' - '.github/actions/infisical-secrets/**' - 'scripts/infisical-fetch.mjs' + - 'scripts/infisical-fetch-asc-key.mjs' + - 'scripts/infisical-fetch-asc-key.mjs' - 'scripts/ios-fleet/**' - '!scripts/ios-fleet/test-*.sh' - 'scripts/ios-ship-testflight.sh' @@ -211,86 +213,38 @@ jobs: # 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. - name: Load ASC credentials (Fleet-OPS prod /asc) + uses: ./.github/actions/infisical-secrets + with: + names: ASC_KEY_ID ASC_ISSUER_ID + project-id: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} + environment: prod + secret-path: /asc + client-id: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} + client-secret: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} + + - name: Stage the ASC private key (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 }} + INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} + INFISICAL_ENVIRONMENT: prod + INFISICAL_SECRET_PATH: /asc + INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} + INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} + run: node scripts/infisical-fetch-asc-key.mjs + + - name: Check the ASC credentials arrived 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 + if [ -z "${!name:-}" ]; then + echo "::error::Missing $name in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc." >&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 + if [ -z "${ASC_KEY_PATH:-}" ] || [ ! -s "${ASC_KEY_PATH:-}" ]; then + echo '::error::Missing ASC key material in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /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 identity env: IOS_DIST_P12_BASE64: ${{ secrets.IOS_DIST_P12_BASE64 }} diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index fc945034..2f805c08 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -51,6 +51,7 @@ on: - '.github/workflows/mac-release.yml' - '.github/actions/infisical-secrets/**' - 'scripts/infisical-fetch.mjs' + - 'scripts/infisical-fetch-asc-key.mjs' workflow_dispatch: permissions: @@ -77,7 +78,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Test Sparkle release contract without credentials - run: node --test scripts/mac-release-workflow.test.mjs + run: node --test scripts/mac-release-workflow.test.mjs scripts/infisical-fetch-asc-key.node-test.mjs - name: Wait for Swift CI compile checks on this commit env: @@ -117,87 +118,41 @@ jobs: GH_FALLBACK_SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: Load ASC credentials (Fleet-OPS prod /asc) + if: github.event_name != 'pull_request' + uses: ./.github/actions/infisical-secrets + with: + names: ASC_KEY_ID ASC_ISSUER_ID + project-id: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} + environment: prod + secret-path: /asc + client-id: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} + client-secret: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} + + - name: Stage the ASC private key (Fleet-OPS prod /asc) if: github.event_name != 'pull_request' 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 }} + INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} + INFISICAL_ENVIRONMENT: prod + INFISICAL_SECRET_PATH: /asc + INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} + INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} + run: node scripts/infisical-fetch-asc-key.mjs + + - name: Check the ASC credentials arrived + if: github.event_name != 'pull_request' 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 + if [ -z "${!name:-}" ]; then echo "::error::Missing $name in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /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 Fleet-OPS prod /asc." >&2 + if [ -z "${ASC_KEY_PATH:-}" ] || [ ! -s "${ASC_KEY_PATH:-}" ]; then + echo '::error::Missing ASC key material in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /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: Decide whether this run can publish id: gate run: | @@ -368,3 +323,62 @@ jobs: run: | security delete-keychain "$RUNNER_TEMP/codecaps-signing.keychain-db" >/dev/null 2>&1 || true if [ -n "${ASC_KEY_PATH:-}" ]; then rm -f -- "$ASC_KEY_PATH"; fi + + # A manual check that the release credentials still load and that Apple's + # notarization service still accepts them. It publishes nothing and builds + # nothing, so it can be run against a branch before a merge. It exists + # because a green pull-request check proves the workflow's own contract, + # not that a credential can be fetched: the Mac release was broken for nine + # days by a credential that no check looked at. + # + # Manual only, on purpose. A pull request must never see the signing key, + # and a scheduled run would fetch it for nothing. + verify-release-credentials: + name: Verify the release credentials load + if: github.event_name == 'workflow_dispatch' + runs-on: macos-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Load ASC credentials (Fleet-OPS prod /asc) + uses: ./.github/actions/infisical-secrets + with: + names: ASC_KEY_ID ASC_ISSUER_ID + project-id: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} + environment: prod + secret-path: /asc + client-id: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} + client-secret: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} + + - name: Stage the ASC private key (Fleet-OPS prod /asc) + env: + INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} + INFISICAL_ENVIRONMENT: prod + INFISICAL_SECRET_PATH: /asc + INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} + INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} + run: node scripts/infisical-fetch-asc-key.mjs + + - name: Check the ASC credentials arrived + run: | + set +o xtrace + set -euo pipefail + for name in ASC_KEY_ID ASC_ISSUER_ID; do + if [ -z "${!name:-}" ]; then + echo "::error::Missing $name in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc." >&2 + exit 1 + fi + done + if [ -z "${ASC_KEY_PATH:-}" ] || [ ! -s "${ASC_KEY_PATH:-}" ]; then + echo '::error::Missing ASC key material in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc.' >&2 + exit 1 + fi + + - name: Verify notarization access before building + run: bash scripts/check-notary-access.sh + + - name: Remove the signing key + if: always() + run: | + if [ -n "${ASC_KEY_PATH:-}" ]; then rm -f -- "$ASC_KEY_PATH"; fi diff --git a/docs/EFFORT-LOG.md b/docs/EFFORT-LOG.md index 41f0d5fc..e5471222 100644 --- a/docs/EFFORT-LOG.md +++ b/docs/EFFORT-LOG.md @@ -693,3 +693,45 @@ repo: CodeCaps; pre-work claim: posted to #agent-sync after reading AGENT-SYNC.m `>= 98.0` vendor-reset annotation for the Glance history chart, so that chart will not mark these points either. Left untouched: it is claimed by CODEX in PR #142, and flagged to them on `#codecaps` instead. + +## 2026-10-20 — The Mac Release Signing Key Loads From Fleet-OPS, Not A Literal [MINIMAX, in progress] + +- Board: `d09b44db` (CC-177). Pull request: #257 (`jet/fix-sparkle-infisical`, + the seat's own diagnosis of the same outage; this change answers its two + gates and its two open review findings rather than opening a second pull + request for the same fault). +- The Mac Release (Sparkle) workflow failed on every push to main from + 2026-10-11 to 2026-10-20, nine days, with the last published release still + `v1.1.0-build.183` from 2026-10-10. The cause was the credential source and + not the release script: the workflow read the App Store Connect key from + repository secrets that are a revoked copy (HTTP 401, CC-163), while the + working key lives in Fleet-OPS prod `/asc`. That was proven read-only, by + authenticating the fleet key against `xcrun notarytool history`. +- The two open review findings on that pull request were both about how the + credential is read. The Infisical project id was written into a public + workflow and interpolated into the job log on every failed notarization, and + the bare `infisical secrets get` CLI bypassed the repository's safe fetch. +- Both are fixed. `ASC_KEY_ID` and `ASC_ISSUER_ID` come from the + `infisical-secrets` composite action, which masks every line of every value + and never prints one. `ASC_KEY_P8` is fetched by + `scripts/infisical-fetch-asc-key.mjs` and streamed into + `scripts/ios-stage-asc-key.sh` over a pipe, so the private key still never + becomes an environment variable and only its mode-600 file path crosses the + step boundary. The project id comes from the `INFISICAL_FLEET_OPS_PROJECT_ID` + secret. +- The gate that could not be answered from the workflow is now answered: that + name is an organisation **secret**, not a variable. The repository has no + Actions variables at all and the organisation has exactly one, which is + unrelated. A variable reference would have resolved to nothing. +- Two workflows read the key — `mac-release.yml` and `ios-ship.yml` — and both + carry the same text, so the fix lands in both. `macos-ship.yml` was + deleted from main by #272 and needed no change. +- A dispatch-only job, "Verify the release credentials load", fetches the key + and checks that Apple's notarization service still accepts it, and publishes + nothing. It exists because a green pull-request check proves the workflow's + own contract, not that a credential can be fetched, which is exactly why this + outage ran for nine days without a check noticing. +- Gates: 32 contract tests and the key-handoff shell test pass, including a + new offline test that drives the real fetch script and the real staging + script against a stubbed Infisical and checks that the key reaches a + mode-600 file and never the environment or the log. diff --git a/scripts/infisical-fetch-asc-key.mjs b/scripts/infisical-fetch-asc-key.mjs new file mode 100644 index 00000000..e595ac0d --- /dev/null +++ b/scripts/infisical-fetch-asc-key.mjs @@ -0,0 +1,86 @@ +// Fetch the ASC private key from Infisical and hand it to the protected +// key-file handoff. +// +// The key is multiline, so it must never become an environment variable. +// This script uses the same safe helpers the composite action uses -- +// universal-auth login, one secrets list for the whole path, every line of +// every value masked -- and then streams the key into +// scripts/ios-stage-asc-key.sh, which writes the mode-600 file and publishes +// only its path. +// +// The bare `infisical secrets get` CLI is not used anywhere in this path. +// The CLI is macOS-only to install, and it prints the value to stdout, which +// is exactly what a secret must never do. +// +// Run: node scripts/infisical-fetch-asc-key.mjs +import { spawn } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { listSecretsRaw, loginUniversalAuth, maskValue } from './infisical-fetch.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const STAGING_SCRIPT = join(here, 'ios-stage-asc-key.sh'); +const SECRET_NAME = 'ASC_KEY_P8'; + +function fail(log, message) { + log(`::error::${message}`); + throw new Error(message); +} + +/** Fetch ASC_KEY_P8 and stream it into the staging script. + * + * The value is masked before it is handed over, so it cannot reach the log + * even if the handoff fails. It is never written to GITHUB_ENV: the + * staging script publishes only the path of the file it wrote. */ +export async function run({ env = process.env, fetchImpl = fetch, log = (line) => console.log(line) } = {}) { + const projectId = env.INFISICAL_PROJECT_ID; + const clientId = env.INFISICAL_CLIENT_ID; + const clientSecret = env.INFISICAL_CLIENT_SECRET; + const siteUrl = env.INFISICAL_SITE_URL || 'https://app.infisical.com'; + const environment = env.INFISICAL_ENVIRONMENT || 'prod'; + const secretPath = env.INFISICAL_SECRET_PATH || '/asc'; + + if (!projectId) { + fail(log, 'INFISICAL_PROJECT_ID is required.'); + } + if (!clientId || !clientSecret) { + fail(log, 'Missing Infisical machine identity. Set INFISICAL_UNIVERSAL_AUTH_CLIENT_ID/SECRET (fallback INFISICAL_CLIENT_ID/SECRET) on the org or repo.'); + } + + const { token } = await loginUniversalAuth({ siteUrl, clientId, clientSecret, fetchImpl }); + maskValue(token, log); + + const values = await listSecretsRaw({ siteUrl, token, projectId, environment, secretPath, fetchImpl }); + const value = values.get(SECRET_NAME); + if (value === undefined || value === null || String(value).trim() === '') { + fail(log, `Missing ${SECRET_NAME} in Fleet-OPS prod ${secretPath}; verify the CI identity has read access to Fleet-OPS prod ${secretPath}.`); + } + maskValue(value, log); + + // The value crosses a pipe and nothing else: not the environment, not the + // command line, not a file the workflow can read. The staging script + // needs GITHUB_ENV and its staging root, so the caller's environment is + // passed through to it unchanged. + await new Promise((resolve, reject) => { + const child = spawn('bash', [STAGING_SCRIPT], { + stdio: ['pipe', 'inherit', 'inherit'], + env: { ...process.env, ...env }, + }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`The ASC key staging script exited ${code}.`)); + }); + child.stdin.on('error', reject); + child.stdin.end(value); + }); +} + +const invokedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (invokedDirectly) { + run().catch((err) => { + console.log(`::error::${err.message}`); + process.exitCode = 1; + }); +} diff --git a/scripts/infisical-fetch-asc-key.node-test.mjs b/scripts/infisical-fetch-asc-key.node-test.mjs new file mode 100644 index 00000000..139a6461 --- /dev/null +++ b/scripts/infisical-fetch-asc-key.node-test.mjs @@ -0,0 +1,129 @@ +// The ASC key handoff must never put the key in the environment or the log. +// This drives scripts/infisical-fetch-asc-key.mjs with a stubbed Infisical +// and the real scripts/ios-stage-asc-key.sh, and checks the file it writes. +// +// Run: node --test scripts/infisical-fetch-asc-key.node-test.mjs +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { run } from './infisical-fetch-asc-key.mjs'; + +const KEY = "-----BEGIN PRIVATE KEY-----\nSYNTHETIC-MULTILINE-FIXTURE-DO-NOT-USE\n-----END PRIVATE KEY-----"; + +/** Stub Infisical: the login succeeds and the list returns the fixture. */ +function stubFetch(vaultSecrets, calls = []) { + return async (url) => { + const u = String(url); + calls.push(u); + if (u.includes("/api/v1/auth/universal-auth/login")) { + return { ok: true, json: async () => ({ accessToken: "test-token", expiresIn: 3600 }) }; + } + if (u.includes("/api/v3/secrets/raw")) { + return { + ok: true, + json: async () => ({ + secrets: Object.entries(vaultSecrets).map(([secretKey, secretValue]) => ({ secretKey, secretValue })), + }), + }; + } + throw new Error(`stubFetch: unexpected URL ${u}`); + }; +} + +function baseEnv(dir, overrides = {}) { + // The staging script makes its own directory inside the staging root, so + // the root has to exist first. + mkdirSync(join(dir, "staging"), { recursive: true }); + return { + INFISICAL_PROJECT_ID: "synthetic-fleet-project", + INFISICAL_ENVIRONMENT: "prod", + INFISICAL_SECRET_PATH: "/asc", + INFISICAL_CLIENT_ID: "synthetic-client", + INFISICAL_CLIENT_SECRET: "synthetic-secret", + GITHUB_ENV: join(dir, "env"), + ASC_STAGING_ROOT: join(dir, "staging"), + ...overrides, + }; +} + +function mode(path) { + return statSync(path).mode & 0o777; +} + +test("the key is staged to a mode-600 file and only its path is published", async () => { + const dir = mkdtempSync(join(tmpdir(), "asc-fetch-")); + try { + const env = baseEnv(dir); + const lines = []; + await run({ env, fetchImpl: stubFetch({ ASC_KEY_P8: KEY }), log: (l) => lines.push(l) }); + + const written = readFileSync(env.GITHUB_ENV, "utf8"); + const match = written.match(/^ASC_KEY_PATH=(.*)$/m); + assert.ok(match, "the staging script must publish ASC_KEY_PATH"); + const keyPath = match[1].trim(); + assert.ok(!written.includes("SYNTHETIC-MULTILINE-FIXTURE"), "the key must never reach GITHUB_ENV"); + assert.equal(mode(keyPath), 0o600, "the key file must be mode 600"); + assert.equal(mode(join(keyPath, "..")), 0o700, "the key directory must be mode 700"); + assert.ok(readFileSync(keyPath, "utf8").includes("SYNTHETIC-MULTILINE-FIXTURE")); + + // The only place the key may appear in the log is on an ::add-mask:: + // line, which is the runner's own instruction to scrub it. Anywhere + // else and it is a leak. + const leaked = lines.filter((l) => l.includes("SYNTHETIC-MULTILINE-FIXTURE") && !l.startsWith("::add-mask::")); + assert.deepEqual(leaked, [], "the key must never reach the log outside an ::add-mask:: line"); + assert.ok(lines.some((l) => l.startsWith("::add-mask::")), "every line of the key must be masked"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("the key is read from prod /asc of the given project, and nowhere else", async () => { + const dir = mkdtempSync(join(tmpdir(), "asc-fetch-")); + try { + const calls = []; + await run({ env: baseEnv(dir), fetchImpl: stubFetch({ ASC_KEY_P8: KEY }, calls), log: () => {} }); + const list = calls.filter((c) => c.includes("/api/v3/secrets/raw")); + assert.equal(list.length, 1, "one list call for the whole path"); + const url = new URL(list[0]); + assert.equal(url.searchParams.get("workspaceId"), "synthetic-fleet-project"); + assert.equal(url.searchParams.get("environment"), "prod"); + assert.equal(url.searchParams.get("secretPath"), "/asc"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("a missing key fails the step and names Fleet-OPS prod /asc, never a value", async () => { + const dir = mkdtempSync(join(tmpdir(), "asc-fetch-")); + try { + const lines = []; + await assert.rejects( + run({ env: baseEnv(dir), fetchImpl: stubFetch({}), log: (l) => lines.push(l) }), + (err) => { + assert.match(err.message, /ASC_KEY_P8/); + assert.match(err.message, /Fleet-OPS prod \/asc/); + return true; + }, + ); + assert.ok(lines.some((l) => l.includes("Fleet-OPS prod /asc")), "the error names the path"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("a missing project id fails before any network call", async () => { + const dir = mkdtempSync(join(tmpdir(), "asc-fetch-")); + try { + const calls = []; + await assert.rejects( + run({ env: baseEnv(dir, { INFISICAL_PROJECT_ID: "" }), fetchImpl: stubFetch({ ASC_KEY_P8: KEY }, calls), log: () => {} }), + /INFISICAL_PROJECT_ID is required/, + ); + assert.equal(calls.length, 0, "nothing may be fetched without a project id"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/ios-ship-workflow.test.mjs b/scripts/ios-ship-workflow.test.mjs index 2fe455ec..3a184f7a 100644 --- a/scripts/ios-ship-workflow.test.mjs +++ b/scripts/ios-ship-workflow.test.mjs @@ -163,13 +163,22 @@ test("ASC credentials come from Fleet-OPS /asc, never the stale repository secre 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/); + // The project id is a secret, never a literal in this public file, and the + // bare Infisical CLI is not used: it prints the value to stdout. + assert.doesNotMatch(code, /8dae6d03/); + assert.doesNotMatch(code, /ASC_INFISICAL_PROJECT_ID/); + assert.match(code, /project-id: \$\{\{ secrets\.INFISICAL_FLEET_OPS_PROJECT_ID \}\}/); + assert.match(code, /secret-path: \/asc/); + assert.match(code, /uses: \.\/\.github\/actions\/infisical-secrets/); 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, /infisical secrets get/); + assert.doesNotMatch(code, /infisical\/get-cli/); + + // The multiline key goes to a mode-600 file, never GITHUB_ENV. The + // protected handoff lives in the fetch script, which hands the key to + // scripts/ios-stage-asc-key.sh over a pipe. + assert.match(code, /scripts\/infisical-fetch-asc-key\.mjs/); + assert.match(readFileSync(join(ROOT, "scripts/infisical-fetch-asc-key.mjs"), "utf8"), /ios-stage-asc-key\.sh/); assert.doesNotMatch(code, /for name in .*ASC_KEY_P8/); assert.doesNotMatch(code, /printf '%s=%s\\n' "ASC_KEY_P8"/); diff --git a/scripts/mac-release-workflow.test.mjs b/scripts/mac-release-workflow.test.mjs index 274def93..0a71e55b 100644 --- a/scripts/mac-release-workflow.test.mjs +++ b/scripts/mac-release-workflow.test.mjs @@ -16,16 +16,57 @@ function step(s, name) { const end = s.indexOf('\n - ', start + 1); return s.slice(start, end < 0 ? undefined : end).trim(); } +const LOADERS = [ + 'Load ASC credentials (Fleet-OPS prod /asc)', + 'Stage the ASC private key (Fleet-OPS prod /asc)', + 'Check the ASC credentials arrived', +]; +const stripGuard = s => s.replaceAll(" if: github.event_name != 'pull_request'\n", ''); +function allSteps(s, name) { + const out = []; + const marker = ` - name: ${name}\n`; + let at = s.indexOf(marker); + while (at >= 0) { + const end = s.indexOf('\n - ', at + 1); + out.push(s.slice(at, end < 0 ? undefined : end).trim()); + at = s.indexOf(marker, at + 1); + } + return out; +} +const iosShip = read('.github/workflows/ios-ship.yml'); test('Sparkle uses the existing fleet ASC loader, never repository ASC secrets', () => { assert.doesNotMatch(code, /secrets\.ASC_/); - const name = 'Load ASC credentials (Fleet-OPS prod /asc)'; - const normalize = s => s.replaceAll('verify the CI identity has read access to project ${ASC_INFISICAL_PROJECT_ID} path /asc.', 'verify the CI identity has read access to Fleet-OPS prod /asc.'); - assert.equal(step(workflow, name).replace(" if: github.event_name != 'pull_request'\n", ''), normalize(step(read('.github/workflows/ios-ship.yml'), name))); - const loader = step(workflow, name); + // One loader, two workflows. The release path and the iOS ship path must + // carry the same text, so a fix to one is a fix to the other. + for (const name of LOADERS) { + assert.equal(stripGuard(step(workflow, name)), step(iosShip, name), `mac-release and ios-ship disagree on "${name}"`); + assert.match(step(workflow, name), /if: github.event_name != 'pull_request'/); + } + // The credential check job runs the same loader as the release job, so a + // change to one cannot leave the other proving nothing. + for (const name of LOADERS) { + const copies = allSteps(workflow, name); + assert.equal(copies.length, 2, `"${name}" must exist in the release job and in the credential check`); + assert.equal(stripGuard(copies[0]), copies[1], `the credential check's "${name}" drifted from the release job's`); + } + const loader = LOADERS.map(name => step(workflow, name)).join('\n'); + // The project id is a secret. A literal in a public workflow is both a + // leak of an internal identifier and a second copy to keep in step. + for (const file of [workflow, iosShip]) { + assert.doesNotMatch(file, /8dae6d03/); + assert.doesNotMatch(file, /ASC_INFISICAL_PROJECT_ID/); + } + assert.match(loader, /project-id: \$\{\{ secrets\.INFISICAL_FLEET_OPS_PROJECT_ID \}\}/); assert.doesNotMatch(loader, /echo .*\$\{ASC_INFISICAL_PROJECT_ID\}/); + // The bare Infisical CLI is gone: it prints the value to stdout, and its + // only install path is macOS-only. + for (const file of [workflow, iosShip]) { + assert.doesNotMatch(file, /infisical secrets get/); + assert.doesNotMatch(file, /infisical\/get-cli/); + } + assert.match(loader, /run: node scripts\/infisical-fetch-asc-key\.mjs/); assert.match(loader, /Missing \$name in Fleet-OPS prod \/asc; verify the CI identity has read access to Fleet-OPS prod \/asc\./); assert.match(loader, /Missing ASC key material in Fleet-OPS prod \/asc; verify the CI identity has read access to Fleet-OPS prod \/asc\./); - assert.match(step(workflow, name), /if: github.event_name != 'pull_request'/); assert.match(code, /NOTARY_KEY_PATH: \$\{\{ env.ASC_KEY_PATH \}\}/); assert.match(code, /NOTARY_KEY_ID: \$\{\{ env.ASC_KEY_ID \}\}/); assert.match(code, /NOTARY_ISSUER_ID: \$\{\{ env.ASC_ISSUER_ID \}\}/); diff --git a/scripts/test-ios-stage-asc-key.sh b/scripts/test-ios-stage-asc-key.sh index 8568140d..89ba94e5 100755 --- a/scripts/test-ios-stage-asc-key.sh +++ b/scripts/test-ios-stage-asc-key.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Offline test for the ASC key handoff in .github/workflows/ios-ship.yml: -# scripts/ios-stage-asc-key.sh itself, then the real "Load ASC credentials" -# run block driven by a fake Infisical CLI. No Apple or Infisical credential -# is used; every key below is a synthetic fixture. +# scripts/ios-stage-asc-key.sh itself, then the real fetch script driven by a +# stubbed Infisical. No Apple or Infisical credential is used; every key +# below is a synthetic fixture. set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -66,92 +66,33 @@ fi cmp "$GITHUB_ENV" "$tmp_dir/before-empty-env" [[ "$(find "$ASC_STAGING_ROOT" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" == 2 ]] -# Exercise the actual workflow run block using a fake Infisical CLI. -python3 - "$workflow" "$tmp_dir/workflow-load.sh" <<'PYTHON' -import pathlib, sys -text = pathlib.Path(sys.argv[1]).read_text() -section = text.split(' - name: Load ASC credentials (Fleet-OPS prod /asc)\n', 1)[1] -section = section.split('\n - name:', 1)[0] -run = section.split(' run: |\n', 1)[1] -pathlib.Path(sys.argv[2]).write_text('\n'.join(line[10:] for line in run.splitlines() if line.startswith(' ')) + '\n') -PYTHON -mkdir "$tmp_dir/bin" "$tmp_dir/workspace" "$tmp_dir/workspace/scripts" -ln -s "$repo_root/scripts/ios-stage-asc-key.sh" "$tmp_dir/workspace/scripts/ios-stage-asc-key.sh" -cat > "$tmp_dir/bin/infisical" <<'CLI' -#!/usr/bin/env bash -# Fake CLI. Records the project and path of every read so the test can prove -# the ASC trio comes from Fleet-OPS /asc and nowhere else. -case "${1:-}" in - login) printf '%s\n' synthetic-session-token ;; - secrets) - printf '%s\n' "$*" >> "$ASC_TEST_CALLS" - if [[ -n "${ASC_TEST_EMPTY:-}" && "${3:-}" == "$ASC_TEST_EMPTY" ]]; then - exit 0 - fi - if [[ "${3:-}" == ASC_KEY_P8 ]]; then - cat "$ASC_TEST_FIXTURE" - else - printf 'synthetic-%s\n' "${3:-value}" - fi - ;; - *) exit 1 ;; -esac -CLI -chmod 700 "$tmp_dir/bin/infisical" -: > "$tmp_dir/workflow-env" -: > "$tmp_dir/calls" -env PATH="$tmp_dir/bin:$PATH" \ - GITHUB_WORKSPACE="$tmp_dir/workspace" GITHUB_ENV="$tmp_dir/workflow-env" \ - ASC_STAGING_ROOT="$tmp_dir/staging" \ - ASC_INFISICAL_PROJECT_ID=synthetic-fleet-project \ - INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=synthetic-client \ - INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=synthetic-secret \ - INFISICAL_CLIENT_ID= INFISICAL_CLIENT_SECRET= \ - ASC_TEST_FIXTURE="$tmp_dir/fixture" ASC_TEST_CALLS="$tmp_dir/calls" \ - bash "$tmp_dir/workflow-load.sh" > "$tmp_dir/workflow-stdout" 2> "$tmp_dir/workflow-stderr" -if grep -Fq -f "$tmp_dir/fixture" "$tmp_dir/workflow-env" "$tmp_dir/workflow-stdout" "$tmp_dir/workflow-stderr"; then - echo 'workflow leaked multiline key material' >&2 +# Guard the workflow handoff itself, not only the staging script. +workflow="$repo_root/.github/workflows/ios-ship.yml" +if grep -Eq 'for name in .*ASC_KEY_P8' "$workflow"; then + echo 'workflow still propagates the multiline key through an environment mask' >&2 exit 1 fi -[[ "$(grep -c '^ASC_KEY_PATH=' "$tmp_dir/workflow-env")" == 1 ]] -grep -q '^ASC_KEY_ID=synthetic-ASC_KEY_ID$' "$tmp_dir/workflow-env" -grep -q '^ASC_ISSUER_ID=synthetic-ASC_ISSUER_ID$' "$tmp_dir/workflow-env" -if grep -q '^ASC_KEY_P8=' "$tmp_dir/workflow-env"; then - echo 'the private key was exported through the environment' >&2 +if ! grep -Fq 'scripts/infisical-fetch-asc-key.mjs' "$workflow"; then + echo 'workflow does not use the protected key-file handoff' >&2 + exit 1 +fi +if grep -Eq 'infisical secrets get|infisical/get-cli' "$workflow"; then + echo 'workflow reads a secret through the bare Infisical CLI' >&2 exit 1 fi -workflow_key="$(sed -n 's/^ASC_KEY_PATH=//p' "$tmp_dir/workflow-env")" -cmp "$tmp_dir/fixture" "$workflow_key" -[[ "$(file_mode "$workflow_key")" == 600 ]] -[[ ! -s "$tmp_dir/workflow-stderr" ]] -# Exactly three reads, all from the Fleet-OPS project at /asc. -[[ "$(wc -l < "$tmp_dir/calls" | tr -d ' ')" == 3 ]] -if grep -v -e '--projectId=synthetic-fleet-project' "$tmp_dir/calls" | grep -q .; then - echo 'an ASC value was read from a project other than Fleet-OPS' >&2 +if grep -Fq '8dae6d03' "$workflow"; then + echo 'workflow hard-codes the Infisical project id' >&2 exit 1 fi -if grep -v -e '--path=/asc' "$tmp_dir/calls" | grep -q .; then - echo 'an ASC value was read from a path other than /asc' >&2 +# The fetch script is what hands the key to the protected handoff. +if ! grep -Fq 'ios-stage-asc-key.sh' "$repo_root/scripts/infisical-fetch-asc-key.mjs"; then + echo 'the fetch script does not hand the key to the staging script' >&2 exit 1 fi -# A missing value must fail the step and name Fleet-OPS /asc, not fall back. -for missing in ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8; do - : > "$tmp_dir/missing-env" - if env PATH="$tmp_dir/bin:$PATH" \ - GITHUB_WORKSPACE="$tmp_dir/workspace" GITHUB_ENV="$tmp_dir/missing-env" \ - ASC_STAGING_ROOT="$tmp_dir/staging" \ - ASC_INFISICAL_PROJECT_ID=synthetic-fleet-project \ - INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=synthetic-client \ - INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=synthetic-secret \ - INFISICAL_CLIENT_ID= INFISICAL_CLIENT_SECRET= \ - ASC_TEST_FIXTURE="$tmp_dir/fixture" ASC_TEST_CALLS="$tmp_dir/calls" \ - ASC_TEST_EMPTY="$missing" bash "$tmp_dir/workflow-load.sh" \ - > "$tmp_dir/missing-stdout" 2> "$tmp_dir/missing-stderr"; then - echo "missing $missing was accepted" >&2 - exit 1 - fi - grep -q 'Fleet-OPS prod /asc' "$tmp_dir/missing-stdout" "$tmp_dir/missing-stderr" -done +# The end-to-end handoff: a stubbed Infisical, the real fetch script and the +# real staging script. The key is staged to a mode-600 file, only its path +# is published, and the value never reaches the environment or the log. +node --test "$repo_root/scripts/infisical-fetch-asc-key.node-test.mjs" > /dev/null echo 'ios-stage-asc-key: all tests passed' From 6f99c2fc7c13a5015b114137835e9098a8a27a2d Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:50:23 -0500 Subject: [PATCH 11/39] fix(release): let a manual run verify the credentials without publishing A workflow_dispatch run of the release job would fetch the signing key and publish a build from whatever branch it was dispatched on. The new verify-only input stops the release job at the publish gate, so the credential check can be run against a branch before a merge without putting a build in front of users. The contract test pins the input and the gate that honours it. --- .github/workflows/mac-release.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index 2f805c08..e8b97a03 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -53,6 +53,13 @@ on: - 'scripts/infisical-fetch.mjs' - 'scripts/infisical-fetch-asc-key.mjs' workflow_dispatch: + inputs: + verify-only: + description: >- + Fetch the release credentials and check that Apple's notarization + service still accepts them, then stop. Publishes nothing. + type: boolean + default: false permissions: contents: write @@ -156,6 +163,11 @@ jobs: - name: Decide whether this run can publish id: gate run: | + if [ "${{ inputs.verify-only }}" = "true" ]; then + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "::notice title=Verify only::The credentials were fetched and checked. Nothing was published." + exit 0 + fi if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then echo "publish=false" >> "$GITHUB_OUTPUT" echo "::notice title=Pull request::Built and verified only. Releases are published from main." From a1711aab9c4827d9ad443e6db293399e0d31431e Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:51:49 -0500 Subject: [PATCH 12/39] test(release): pin the verify-only dispatch input A manual run can now be asked to fetch the release credentials and check that Apple's notarization service still accepts them without publishing anything. The contract test pins the input and the gate that honours it, so the two cannot drift apart. --- scripts/mac-release-workflow.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/mac-release-workflow.test.mjs b/scripts/mac-release-workflow.test.mjs index 0a71e55b..4292823a 100644 --- a/scripts/mac-release-workflow.test.mjs +++ b/scripts/mac-release-workflow.test.mjs @@ -83,7 +83,10 @@ test('PRs cannot notarize or publish, and icon-only changes trigger releases', ( assert.match(step(workflow, name), /if: steps.gate.outputs.publish == 'true'/); } assert.match(code, /'assets\/AppIcon.icns'/); - assert.match(code, /run: node --test scripts\/mac-release-workflow.test.mjs/); + assert.match(code, /run: node --test scripts\/mac-release-workflow.test.mjs scripts\/infisical-fetch-asc-key.node-test.mjs/); + // A manual run can be asked to prove the credentials without publishing. + assert.match(code, /verify-only:/); + assert.match(step(workflow, 'Decide whether this run can publish'), /inputs\.verify-only[\s\S]*publish=false/); assert.match(code, /needs.await-swift-ci.result == 'success'/); assert.match(code, /run: script\/build_and_run.sh --release/); assert.ok(code.indexOf('Verify notarization access') < code.indexOf('Build, sign, notarize')); From 7b953bffed414788593d714c328281b94e7b580c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:02:03 +0000 Subject: [PATCH 13/39] autofix: read the Infisical login token without destructuring it loginUniversalAuth returns the token string directly, so `const { token }` set it to undefined and the list call went out as `Bearer undefined`. Assign the return value straight to `token`, matching the other caller in scripts/infisical-fetch.mjs, and assert the bearer header in the contract test so the stub can no longer accept an unauthenticated call. Co-Authored-By: Claude Code --- scripts/infisical-fetch-asc-key.mjs | 2 +- scripts/infisical-fetch-asc-key.node-test.mjs | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/infisical-fetch-asc-key.mjs b/scripts/infisical-fetch-asc-key.mjs index e595ac0d..b09efeb2 100644 --- a/scripts/infisical-fetch-asc-key.mjs +++ b/scripts/infisical-fetch-asc-key.mjs @@ -48,7 +48,7 @@ export async function run({ env = process.env, fetchImpl = fetch, log = (line) = fail(log, 'Missing Infisical machine identity. Set INFISICAL_UNIVERSAL_AUTH_CLIENT_ID/SECRET (fallback INFISICAL_CLIENT_ID/SECRET) on the org or repo.'); } - const { token } = await loginUniversalAuth({ siteUrl, clientId, clientSecret, fetchImpl }); + const token = await loginUniversalAuth({ siteUrl, clientId, clientSecret, fetchImpl }); maskValue(token, log); const values = await listSecretsRaw({ siteUrl, token, projectId, environment, secretPath, fetchImpl }); diff --git a/scripts/infisical-fetch-asc-key.node-test.mjs b/scripts/infisical-fetch-asc-key.node-test.mjs index 139a6461..500edbba 100644 --- a/scripts/infisical-fetch-asc-key.node-test.mjs +++ b/scripts/infisical-fetch-asc-key.node-test.mjs @@ -15,9 +15,9 @@ const KEY = "-----BEGIN PRIVATE KEY-----\nSYNTHETIC-MULTILINE-FIXTURE-DO-NOT-USE /** Stub Infisical: the login succeeds and the list returns the fixture. */ function stubFetch(vaultSecrets, calls = []) { - return async (url) => { + return async (url, init = {}) => { const u = String(url); - calls.push(u); + calls.push({ url: u, headers: init.headers || {} }); if (u.includes("/api/v1/auth/universal-auth/login")) { return { ok: true, json: async () => ({ accessToken: "test-token", expiresIn: 3600 }) }; } @@ -85,12 +85,15 @@ test("the key is read from prod /asc of the given project, and nowhere else", as try { const calls = []; await run({ env: baseEnv(dir), fetchImpl: stubFetch({ ASC_KEY_P8: KEY }, calls), log: () => {} }); - const list = calls.filter((c) => c.includes("/api/v3/secrets/raw")); + const list = calls.filter((c) => c.url.includes("/api/v3/secrets/raw")); assert.equal(list.length, 1, "one list call for the whole path"); - const url = new URL(list[0]); + const url = new URL(list[0].url); assert.equal(url.searchParams.get("workspaceId"), "synthetic-fleet-project"); assert.equal(url.searchParams.get("environment"), "prod"); assert.equal(url.searchParams.get("secretPath"), "/asc"); + // The login helper returns the token string itself, so a destructured + // read would silently send "Bearer undefined". + assert.equal(list[0].headers.authorization, "Bearer test-token"); } finally { rmSync(dir, { recursive: true, force: true }); } From de969bf4ccacc442278d28c096e1a7cfa353507f Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:25:09 -0500 Subject: [PATCH 14/39] fix(release): read the signing key through the approved action, not the vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reviewers found real problems with the first version of this work, and both were right. `loginUniversalAuth` returns the token as a bare string. The script destructured it as an object, so the vault read would have sent `Authorization: Bearer undefined` and failed with HTTP 401 — which is the same symptom as the outage this pull request fixes. The test missed it because its stub ignored the Authorization header; it now asserts the header. More importantly, a script that reads the vault directly sits outside the approved secret controls. That script is gone. The values now come from the `infisical-secrets` composite action, which is this repository's approved way to read a value out of the vault: it masks every line of every value and never prints one. The only thing a script does now is stage one of them to a file. `scripts/stage-asc-key.mjs` hands the key in the environment to `scripts/ios-stage-asc-key.sh` over a pipe, so the key reaches no process arguments, the key file is mode 600, and the environment variable is blanked so that no later step carries the key. Three further findings, all fixed: - A verify-only run skipped the notarization preflight and still spent minutes building a bundle it would never publish, while announcing that the credentials had been checked. The gate now has a `verify_only` output: the preflight runs and the build does not. - The credential check job inherited `contents: write` from the workflow while holding the signing key. It is now read-only. - The new contract test file was missing from the workflow path filters, so a change to it would not have triggered the checks that run it. The contract tests now pin the properties these findings were about: no workflow reads the vault directly, the credential check job is read-only, and a verify-only run checks the notary without building. Fixes CC-177 --- .github/workflows/ios-ship-contract.yml | 4 +- .github/workflows/ios-ship.yml | 13 +- .github/workflows/mac-release.yml | 36 ++--- docs/AUTO-UPDATE.md | 4 + docs/EFFORT-LOG.md | 23 ++++ scripts/infisical-fetch-asc-key.mjs | 86 ------------ scripts/infisical-fetch-asc-key.node-test.mjs | 129 ------------------ scripts/ios-ship-workflow.test.mjs | 6 +- scripts/mac-release-workflow.test.mjs | 18 ++- scripts/stage-asc-key.mjs | 69 ++++++++++ scripts/stage-asc-key.node-test.mjs | 103 ++++++++++++++ scripts/test-ios-stage-asc-key.sh | 14 +- 12 files changed, 249 insertions(+), 256 deletions(-) delete mode 100644 scripts/infisical-fetch-asc-key.mjs delete mode 100644 scripts/infisical-fetch-asc-key.node-test.mjs create mode 100644 scripts/stage-asc-key.mjs create mode 100644 scripts/stage-asc-key.node-test.mjs diff --git a/.github/workflows/ios-ship-contract.yml b/.github/workflows/ios-ship-contract.yml index 02cbc027..e0cf6f58 100644 --- a/.github/workflows/ios-ship-contract.yml +++ b/.github/workflows/ios-ship-contract.yml @@ -15,6 +15,7 @@ on: - 'ios/CodeCapsCompanion/project.yml' - 'scripts/ios-*' - 'scripts/infisical-fetch*.mjs' + - 'scripts/stage-asc-key*' - 'scripts/ios-fleet/**' - 'scripts/test-ios-stage-asc-key.sh' push: @@ -24,6 +25,7 @@ on: - 'ios/CodeCapsCompanion/project.yml' - 'scripts/ios-*' - 'scripts/infisical-fetch*.mjs' + - 'scripts/stage-asc-key*' - 'scripts/ios-fleet/**' - 'scripts/test-ios-stage-asc-key.sh' workflow_dispatch: @@ -44,6 +46,6 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Pin the auto-ship wiring run: | - node --test scripts/ios-ship-workflow.test.mjs scripts/infisical-fetch-asc-key.node-test.mjs + node --test scripts/ios-ship-workflow.test.mjs scripts/stage-asc-key.node-test.mjs bash scripts/ios-fleet/test-asc-credentials.sh node --test scripts/ios-fleet/test-asc-credentials.mjs diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index f94679f2..90805957 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -73,8 +73,7 @@ on: - '.github/workflows/ios-ship.yml' - '.github/actions/infisical-secrets/**' - 'scripts/infisical-fetch.mjs' - - 'scripts/infisical-fetch-asc-key.mjs' - - 'scripts/infisical-fetch-asc-key.mjs' + - 'scripts/stage-asc-key*' - 'scripts/ios-fleet/**' - '!scripts/ios-fleet/test-*.sh' - 'scripts/ios-ship-testflight.sh' @@ -215,7 +214,7 @@ jobs: - name: Load ASC credentials (Fleet-OPS prod /asc) uses: ./.github/actions/infisical-secrets with: - names: ASC_KEY_ID ASC_ISSUER_ID + names: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 project-id: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} environment: prod secret-path: /asc @@ -223,13 +222,7 @@ jobs: client-secret: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} - name: Stage the ASC private key (Fleet-OPS prod /asc) - env: - INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} - INFISICAL_ENVIRONMENT: prod - INFISICAL_SECRET_PATH: /asc - INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} - INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} - run: node scripts/infisical-fetch-asc-key.mjs + run: node scripts/stage-asc-key.mjs - name: Check the ASC credentials arrived run: | diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index e8b97a03..5b199ae3 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -51,7 +51,7 @@ on: - '.github/workflows/mac-release.yml' - '.github/actions/infisical-secrets/**' - 'scripts/infisical-fetch.mjs' - - 'scripts/infisical-fetch-asc-key.mjs' + - 'scripts/stage-asc-key*' workflow_dispatch: inputs: verify-only: @@ -85,7 +85,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Test Sparkle release contract without credentials - run: node --test scripts/mac-release-workflow.test.mjs scripts/infisical-fetch-asc-key.node-test.mjs + run: node --test scripts/mac-release-workflow.test.mjs scripts/stage-asc-key.node-test.mjs - name: Wait for Swift CI compile checks on this commit env: @@ -128,7 +128,7 @@ jobs: if: github.event_name != 'pull_request' uses: ./.github/actions/infisical-secrets with: - names: ASC_KEY_ID ASC_ISSUER_ID + names: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 project-id: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} environment: prod secret-path: /asc @@ -137,13 +137,7 @@ jobs: - name: Stage the ASC private key (Fleet-OPS prod /asc) if: github.event_name != 'pull_request' - env: - INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} - INFISICAL_ENVIRONMENT: prod - INFISICAL_SECRET_PATH: /asc - INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} - INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} - run: node scripts/infisical-fetch-asc-key.mjs + run: node scripts/stage-asc-key.mjs - name: Check the ASC credentials arrived if: github.event_name != 'pull_request' @@ -165,7 +159,8 @@ jobs: run: | if [ "${{ inputs.verify-only }}" = "true" ]; then echo "publish=false" >> "$GITHUB_OUTPUT" - echo "::notice title=Verify only::The credentials were fetched and checked. Nothing was published." + echo "verify_only=true" >> "$GITHUB_OUTPUT" + echo "::notice title=Verify only::The credentials were fetched and the notarization service was asked whether it accepts them. Nothing was published." exit 0 fi if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then @@ -188,7 +183,7 @@ jobs: fi - name: Verify notarization access before building - if: steps.gate.outputs.publish == 'true' + if: steps.gate.outputs.publish == 'true' || steps.gate.outputs.verify_only == 'true' run: bash scripts/check-notary-access.sh - name: Install XcodeGen @@ -203,7 +198,7 @@ jobs: # the whole nesting passing codesign --verify --deep --strict. The # ad-hoc signature does not prove App Group access. - name: Build and verify the bundle (not publishing) - if: steps.gate.outputs.publish != 'true' + if: steps.gate.outputs.publish != 'true' && steps.gate.outputs.verify_only != 'true' run: CODECAPS_ALLOW_ADHOC_VALIDATION=1 script/build_and_run.sh --build-only - name: Import the Developer ID certificate into a throwaway keychain @@ -350,13 +345,17 @@ jobs: if: github.event_name == 'workflow_dispatch' runs-on: macos-latest timeout-minutes: 10 + # Read-only. This job holds the signing key, so a compromised checkout + # must not also be able to push commits, tags or releases. + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Load ASC credentials (Fleet-OPS prod /asc) uses: ./.github/actions/infisical-secrets with: - names: ASC_KEY_ID ASC_ISSUER_ID + names: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 project-id: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} environment: prod secret-path: /asc @@ -364,13 +363,7 @@ jobs: client-secret: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} - name: Stage the ASC private key (Fleet-OPS prod /asc) - env: - INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} - INFISICAL_ENVIRONMENT: prod - INFISICAL_SECRET_PATH: /asc - INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} - INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} - run: node scripts/infisical-fetch-asc-key.mjs + run: node scripts/stage-asc-key.mjs - name: Check the ASC credentials arrived run: | @@ -386,7 +379,6 @@ jobs: echo '::error::Missing ASC key material in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc.' >&2 exit 1 fi - - name: Verify notarization access before building run: bash scripts/check-notary-access.sh diff --git a/docs/AUTO-UPDATE.md b/docs/AUTO-UPDATE.md index 6e88515c..e5f3733a 100644 --- a/docs/AUTO-UPDATE.md +++ b/docs/AUTO-UPDATE.md @@ -54,6 +54,10 @@ The EdDSA key pair is per app, so one app's key cannot sign another app's update - Stored as the repository secret `SPARKLE_ED_PRIVATE_KEY` by piping the file into `gh secret set`. It reaches `generate_appcast` on standard input, never as an argument. - The public key is `Ou2J0syHZawPSY3JLTLVyhbOylmtyr0QnZPbq7acETQ=`, in `script/build_and_run.sh`. +The App Store Connect signing key is a separate key, and CI reads it from Fleet-OPS prod `/asc` rather than from a repository secret. The repository copy of that key was revoked (HTTP 401, CC-163), which is what kept the Mac release from publishing for nine days. In CI the values come from the `infisical-secrets` composite action, which masks every line of every value and never prints one; the project id comes from the `INFISICAL_FLEET_OPS_PROJECT_ID` secret rather than from a literal in a public workflow. The multiline private key is staged by `scripts/stage-asc-key.mjs` into a mode-600 file, and only that file's path crosses the step boundary. The same loader is used by `mac-release.yml` and `ios-ship.yml`, and `scripts/mac-release-workflow.test.mjs` pins the two to the same text. + +To check that the release credentials still work without publishing anything, run the "Mac Release (Sparkle)" workflow by hand with the `verify-only` input set. It fetches the key, asks Apple's notarization service whether it still accepts it, and stops. + If the private key is lost, installed copies can no longer be updated by Sparkle; they need one manual install of a build carrying a new public key. If it leaks, rotate: generate a new pair, ship one release signed with the **old** key that carries the **new** public key, then switch the secret. ## Hosting diff --git a/docs/EFFORT-LOG.md b/docs/EFFORT-LOG.md index e5471222..ed4c1dbc 100644 --- a/docs/EFFORT-LOG.md +++ b/docs/EFFORT-LOG.md @@ -735,3 +735,26 @@ repo: CodeCaps; pre-work claim: posted to #agent-sync after reading AGENT-SYNC.m new offline test that drives the real fetch script and the real staging script against a stubbed Infisical and checks that the key reaches a mode-600 file and never the environment or the log. + +- Reviewer findings, and what they changed. The first version of this work + added a script that called the Infisical API itself. Two reviewers found + real problems with it, and both were right. `loginUniversalAuth` returns + the token as a bare string, and the script destructured it as an object, so + the vault read would have sent `Authorization: Bearer undefined` and failed + with HTTP 401; the test missed it because its stub ignored the + Authorization header, and that test now asserts the header. More + importantly, a script that reads the vault directly is outside the + approved secret controls, so that script is gone. The values now come from + the `infisical-secrets` composite action, which is the repository's + approved way to read a value, and the only thing a script does is stage one + of them to a file: `scripts/stage-asc-key.mjs` hands the key in the + environment to `scripts/ios-stage-asc-key.sh` over a pipe, writes the + mode-600 file, and blanks the environment variable so no later step + carries the key. +- Three more findings, all fixed. A verify-only run skipped the notarization + preflight and still spent minutes building a bundle it would never publish, + so the gate now has a `verify_only` output: the preflight runs, the build + does not. The credential check job inherited `contents: write` from the + workflow while holding the signing key, so it is now read-only. The new + contract test file was missing from the workflow path filters, so a change + to it would not have triggered the checks that run it. diff --git a/scripts/infisical-fetch-asc-key.mjs b/scripts/infisical-fetch-asc-key.mjs deleted file mode 100644 index e595ac0d..00000000 --- a/scripts/infisical-fetch-asc-key.mjs +++ /dev/null @@ -1,86 +0,0 @@ -// Fetch the ASC private key from Infisical and hand it to the protected -// key-file handoff. -// -// The key is multiline, so it must never become an environment variable. -// This script uses the same safe helpers the composite action uses -- -// universal-auth login, one secrets list for the whole path, every line of -// every value masked -- and then streams the key into -// scripts/ios-stage-asc-key.sh, which writes the mode-600 file and publishes -// only its path. -// -// The bare `infisical secrets get` CLI is not used anywhere in this path. -// The CLI is macOS-only to install, and it prints the value to stdout, which -// is exactly what a secret must never do. -// -// Run: node scripts/infisical-fetch-asc-key.mjs -import { spawn } from 'node:child_process'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { listSecretsRaw, loginUniversalAuth, maskValue } from './infisical-fetch.mjs'; - -const here = dirname(fileURLToPath(import.meta.url)); -const STAGING_SCRIPT = join(here, 'ios-stage-asc-key.sh'); -const SECRET_NAME = 'ASC_KEY_P8'; - -function fail(log, message) { - log(`::error::${message}`); - throw new Error(message); -} - -/** Fetch ASC_KEY_P8 and stream it into the staging script. - * - * The value is masked before it is handed over, so it cannot reach the log - * even if the handoff fails. It is never written to GITHUB_ENV: the - * staging script publishes only the path of the file it wrote. */ -export async function run({ env = process.env, fetchImpl = fetch, log = (line) => console.log(line) } = {}) { - const projectId = env.INFISICAL_PROJECT_ID; - const clientId = env.INFISICAL_CLIENT_ID; - const clientSecret = env.INFISICAL_CLIENT_SECRET; - const siteUrl = env.INFISICAL_SITE_URL || 'https://app.infisical.com'; - const environment = env.INFISICAL_ENVIRONMENT || 'prod'; - const secretPath = env.INFISICAL_SECRET_PATH || '/asc'; - - if (!projectId) { - fail(log, 'INFISICAL_PROJECT_ID is required.'); - } - if (!clientId || !clientSecret) { - fail(log, 'Missing Infisical machine identity. Set INFISICAL_UNIVERSAL_AUTH_CLIENT_ID/SECRET (fallback INFISICAL_CLIENT_ID/SECRET) on the org or repo.'); - } - - const { token } = await loginUniversalAuth({ siteUrl, clientId, clientSecret, fetchImpl }); - maskValue(token, log); - - const values = await listSecretsRaw({ siteUrl, token, projectId, environment, secretPath, fetchImpl }); - const value = values.get(SECRET_NAME); - if (value === undefined || value === null || String(value).trim() === '') { - fail(log, `Missing ${SECRET_NAME} in Fleet-OPS prod ${secretPath}; verify the CI identity has read access to Fleet-OPS prod ${secretPath}.`); - } - maskValue(value, log); - - // The value crosses a pipe and nothing else: not the environment, not the - // command line, not a file the workflow can read. The staging script - // needs GITHUB_ENV and its staging root, so the caller's environment is - // passed through to it unchanged. - await new Promise((resolve, reject) => { - const child = spawn('bash', [STAGING_SCRIPT], { - stdio: ['pipe', 'inherit', 'inherit'], - env: { ...process.env, ...env }, - }); - child.on('error', reject); - child.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`The ASC key staging script exited ${code}.`)); - }); - child.stdin.on('error', reject); - child.stdin.end(value); - }); -} - -const invokedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; -if (invokedDirectly) { - run().catch((err) => { - console.log(`::error::${err.message}`); - process.exitCode = 1; - }); -} diff --git a/scripts/infisical-fetch-asc-key.node-test.mjs b/scripts/infisical-fetch-asc-key.node-test.mjs deleted file mode 100644 index 139a6461..00000000 --- a/scripts/infisical-fetch-asc-key.node-test.mjs +++ /dev/null @@ -1,129 +0,0 @@ -// The ASC key handoff must never put the key in the environment or the log. -// This drives scripts/infisical-fetch-asc-key.mjs with a stubbed Infisical -// and the real scripts/ios-stage-asc-key.sh, and checks the file it writes. -// -// Run: node --test scripts/infisical-fetch-asc-key.node-test.mjs -import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import test from 'node:test'; - -import { run } from './infisical-fetch-asc-key.mjs'; - -const KEY = "-----BEGIN PRIVATE KEY-----\nSYNTHETIC-MULTILINE-FIXTURE-DO-NOT-USE\n-----END PRIVATE KEY-----"; - -/** Stub Infisical: the login succeeds and the list returns the fixture. */ -function stubFetch(vaultSecrets, calls = []) { - return async (url) => { - const u = String(url); - calls.push(u); - if (u.includes("/api/v1/auth/universal-auth/login")) { - return { ok: true, json: async () => ({ accessToken: "test-token", expiresIn: 3600 }) }; - } - if (u.includes("/api/v3/secrets/raw")) { - return { - ok: true, - json: async () => ({ - secrets: Object.entries(vaultSecrets).map(([secretKey, secretValue]) => ({ secretKey, secretValue })), - }), - }; - } - throw new Error(`stubFetch: unexpected URL ${u}`); - }; -} - -function baseEnv(dir, overrides = {}) { - // The staging script makes its own directory inside the staging root, so - // the root has to exist first. - mkdirSync(join(dir, "staging"), { recursive: true }); - return { - INFISICAL_PROJECT_ID: "synthetic-fleet-project", - INFISICAL_ENVIRONMENT: "prod", - INFISICAL_SECRET_PATH: "/asc", - INFISICAL_CLIENT_ID: "synthetic-client", - INFISICAL_CLIENT_SECRET: "synthetic-secret", - GITHUB_ENV: join(dir, "env"), - ASC_STAGING_ROOT: join(dir, "staging"), - ...overrides, - }; -} - -function mode(path) { - return statSync(path).mode & 0o777; -} - -test("the key is staged to a mode-600 file and only its path is published", async () => { - const dir = mkdtempSync(join(tmpdir(), "asc-fetch-")); - try { - const env = baseEnv(dir); - const lines = []; - await run({ env, fetchImpl: stubFetch({ ASC_KEY_P8: KEY }), log: (l) => lines.push(l) }); - - const written = readFileSync(env.GITHUB_ENV, "utf8"); - const match = written.match(/^ASC_KEY_PATH=(.*)$/m); - assert.ok(match, "the staging script must publish ASC_KEY_PATH"); - const keyPath = match[1].trim(); - assert.ok(!written.includes("SYNTHETIC-MULTILINE-FIXTURE"), "the key must never reach GITHUB_ENV"); - assert.equal(mode(keyPath), 0o600, "the key file must be mode 600"); - assert.equal(mode(join(keyPath, "..")), 0o700, "the key directory must be mode 700"); - assert.ok(readFileSync(keyPath, "utf8").includes("SYNTHETIC-MULTILINE-FIXTURE")); - - // The only place the key may appear in the log is on an ::add-mask:: - // line, which is the runner's own instruction to scrub it. Anywhere - // else and it is a leak. - const leaked = lines.filter((l) => l.includes("SYNTHETIC-MULTILINE-FIXTURE") && !l.startsWith("::add-mask::")); - assert.deepEqual(leaked, [], "the key must never reach the log outside an ::add-mask:: line"); - assert.ok(lines.some((l) => l.startsWith("::add-mask::")), "every line of the key must be masked"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("the key is read from prod /asc of the given project, and nowhere else", async () => { - const dir = mkdtempSync(join(tmpdir(), "asc-fetch-")); - try { - const calls = []; - await run({ env: baseEnv(dir), fetchImpl: stubFetch({ ASC_KEY_P8: KEY }, calls), log: () => {} }); - const list = calls.filter((c) => c.includes("/api/v3/secrets/raw")); - assert.equal(list.length, 1, "one list call for the whole path"); - const url = new URL(list[0]); - assert.equal(url.searchParams.get("workspaceId"), "synthetic-fleet-project"); - assert.equal(url.searchParams.get("environment"), "prod"); - assert.equal(url.searchParams.get("secretPath"), "/asc"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("a missing key fails the step and names Fleet-OPS prod /asc, never a value", async () => { - const dir = mkdtempSync(join(tmpdir(), "asc-fetch-")); - try { - const lines = []; - await assert.rejects( - run({ env: baseEnv(dir), fetchImpl: stubFetch({}), log: (l) => lines.push(l) }), - (err) => { - assert.match(err.message, /ASC_KEY_P8/); - assert.match(err.message, /Fleet-OPS prod \/asc/); - return true; - }, - ); - assert.ok(lines.some((l) => l.includes("Fleet-OPS prod /asc")), "the error names the path"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("a missing project id fails before any network call", async () => { - const dir = mkdtempSync(join(tmpdir(), "asc-fetch-")); - try { - const calls = []; - await assert.rejects( - run({ env: baseEnv(dir, { INFISICAL_PROJECT_ID: "" }), fetchImpl: stubFetch({ ASC_KEY_P8: KEY }, calls), log: () => {} }), - /INFISICAL_PROJECT_ID is required/, - ); - assert.equal(calls.length, 0, "nothing may be fetched without a project id"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/scripts/ios-ship-workflow.test.mjs b/scripts/ios-ship-workflow.test.mjs index 3a184f7a..cba3b724 100644 --- a/scripts/ios-ship-workflow.test.mjs +++ b/scripts/ios-ship-workflow.test.mjs @@ -173,12 +173,14 @@ test("ASC credentials come from Fleet-OPS /asc, never the stale repository secre assert.match(code, /secrets\.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID/); assert.doesNotMatch(code, /infisical secrets get/); assert.doesNotMatch(code, /infisical\/get-cli/); + // No new code may talk to the vault directly. + assert.doesNotMatch(code, /listSecretsRaw|loginUniversalAuth/); // The multiline key goes to a mode-600 file, never GITHUB_ENV. The // protected handoff lives in the fetch script, which hands the key to // scripts/ios-stage-asc-key.sh over a pipe. - assert.match(code, /scripts\/infisical-fetch-asc-key\.mjs/); - assert.match(readFileSync(join(ROOT, "scripts/infisical-fetch-asc-key.mjs"), "utf8"), /ios-stage-asc-key\.sh/); + assert.match(code, /scripts\/stage-asc-key\.mjs/); + assert.match(readFileSync(join(ROOT, "scripts/stage-asc-key.mjs"), "utf8"), /ios-stage-asc-key\.sh/); assert.doesNotMatch(code, /for name in .*ASC_KEY_P8/); assert.doesNotMatch(code, /printf '%s=%s\\n' "ASC_KEY_P8"/); diff --git a/scripts/mac-release-workflow.test.mjs b/scripts/mac-release-workflow.test.mjs index 4292823a..4742ee0a 100644 --- a/scripts/mac-release-workflow.test.mjs +++ b/scripts/mac-release-workflow.test.mjs @@ -64,7 +64,7 @@ test('Sparkle uses the existing fleet ASC loader, never repository ASC secrets', assert.doesNotMatch(file, /infisical secrets get/); assert.doesNotMatch(file, /infisical\/get-cli/); } - assert.match(loader, /run: node scripts\/infisical-fetch-asc-key\.mjs/); + assert.match(loader, /run: node scripts\/stage-asc-key\.mjs/); assert.match(loader, /Missing \$name in Fleet-OPS prod \/asc; verify the CI identity has read access to Fleet-OPS prod \/asc\./); assert.match(loader, /Missing ASC key material in Fleet-OPS prod \/asc; verify the CI identity has read access to Fleet-OPS prod \/asc\./); assert.match(code, /NOTARY_KEY_PATH: \$\{\{ env.ASC_KEY_PATH \}\}/); @@ -83,10 +83,24 @@ test('PRs cannot notarize or publish, and icon-only changes trigger releases', ( assert.match(step(workflow, name), /if: steps.gate.outputs.publish == 'true'/); } assert.match(code, /'assets\/AppIcon.icns'/); - assert.match(code, /run: node --test scripts\/mac-release-workflow.test.mjs scripts\/infisical-fetch-asc-key.node-test.mjs/); + assert.match(code, /run: node --test scripts\/mac-release-workflow.test.mjs scripts\/stage-asc-key.node-test.mjs/); // A manual run can be asked to prove the credentials without publishing. assert.match(code, /verify-only:/); assert.match(step(workflow, 'Decide whether this run can publish'), /inputs\.verify-only[\s\S]*publish=false/); + // A verify-only run still asks Apple's notary whether it accepts the key, + // and it does not spend minutes building a bundle it will never publish. + assert.match(step(workflow, 'Decide whether this run can publish'), /verify-only[\s\S]*verify_only=true/); + assert.match(step(workflow, 'Verify notarization access before building'), /publish == 'true' \|\| steps\.gate\.outputs\.verify_only == 'true'/); + assert.match(code, /steps\.gate\.outputs\.publish != 'true' && steps\.gate\.outputs\.verify_only != 'true'/); + // The credential check job holds the signing key, so it must not be able + // to push commits, tags or releases. + assert.match(code, /verify-release-credentials:[\s\S]*permissions:\s*\n\s*contents: read/); + // No new code may talk to the vault directly: the values come from the + // approved composite action, and the only thing a script does is stage + // one of them to a file. + for (const file of [workflow, iosShip]) { + assert.doesNotMatch(file, /listSecretsRaw|loginUniversalAuth/); + } assert.match(code, /needs.await-swift-ci.result == 'success'/); assert.match(code, /run: script\/build_and_run.sh --release/); assert.ok(code.indexOf('Verify notarization access') < code.indexOf('Build, sign, notarize')); diff --git a/scripts/stage-asc-key.mjs b/scripts/stage-asc-key.mjs new file mode 100644 index 00000000..acf60da4 --- /dev/null +++ b/scripts/stage-asc-key.mjs @@ -0,0 +1,69 @@ +// Stage the ASC private key from the environment into the protected key-file +// handoff, and take it back out of the environment. +// +// The key itself is fetched by the infisical-secrets composite action, which +// is this repository's approved way to read a value out of the vault: it +// masks every line of every value and never prints one. This script does +// not talk to the vault itself. +// +// The key is multiline, so it must not stay in the environment. The key is +// streamed into scripts/ios-stage-asc-key.sh, which writes the mode-600 file +// and publishes only its path, and the environment variable is then blanked +// so that no later step carries the key. +// +// The value never reaches a command line: it is written to the staging +// script's standard input, so it appears in no process arguments. +// +// Run: node scripts/stage-asc-key.mjs +import { spawn } from 'node:child_process'; +import { appendFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const STAGING_SCRIPT = join(here, 'ios-stage-asc-key.sh'); +const SECRET_NAME = 'ASC_KEY_P8'; + +function fail(log, message) { + log(`::error::${message}`); + throw new Error(message); +} + +/** Hand the key in the environment to the staging script and blank it. + * + * The key is written to the staging script's standard input rather than to + * its arguments, so it appears in no process listing. */ +export async function run({ env = process.env, log = (line) => console.log(line) } = {}) { + const value = env[SECRET_NAME]; + if (value === undefined || value === null || String(value).trim() === '') { + fail(log, `Missing ASC key material in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc.`); + } + + await new Promise((resolve, reject) => { + const child = spawn('bash', [STAGING_SCRIPT], { + stdio: ['pipe', 'inherit', 'inherit'], + env: { ...process.env, ...env }, + }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`The ASC key staging script exited ${code}.`)); + }); + child.stdin.on('error', reject); + child.stdin.end(value); + }); + + // Take the key back out of the environment. The runner applies these + // entries in order, so the empty value is the one later steps see. + if (env.GITHUB_ENV) { + appendFileSync(env.GITHUB_ENV, `${SECRET_NAME}=\n`); + } +} + +const invokedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (invokedDirectly) { + run().catch((err) => { + console.log(`::error::${err.message}`); + process.exitCode = 1; + }); +} diff --git a/scripts/stage-asc-key.node-test.mjs b/scripts/stage-asc-key.node-test.mjs new file mode 100644 index 00000000..97124bd8 --- /dev/null +++ b/scripts/stage-asc-key.node-test.mjs @@ -0,0 +1,103 @@ +// The ASC key handoff must never leave the key in the environment or the log. +// This drives scripts/stage-asc-key.mjs with the real +// scripts/ios-stage-asc-key.sh, and checks the file it writes. +// +// The key is fetched by the infisical-secrets composite action, which is +// this repository's approved way to read a value out of the vault. This +// script does not talk to the vault, so there is nothing to stub here. +// +// Run: node --test scripts/stage-asc-key.node-test.mjs +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { run } from './stage-asc-key.mjs'; + +const KEY = "-----BEGIN PRIVATE KEY-----\nSYNTHETIC-MULTILINE-FIXTURE-DO-NOT-USE\n-----END PRIVATE KEY-----"; + +function baseEnv(dir, overrides = {}) { + // The staging script makes its own directory inside the staging root, so + // the root has to exist first. + mkdirSync(join(dir, "staging"), { recursive: true }); + return { + ASC_KEY_P8: KEY, + GITHUB_ENV: join(dir, "env"), + ASC_STAGING_ROOT: join(dir, "staging"), + ...overrides, + }; +} + +function mode(path) { + return statSync(path).mode & 0o777; +} + +test("the key is staged to a mode-600 file and only its path is published", async () => { + const dir = mkdtempSync(join(tmpdir(), "asc-stage-")); + try { + const env = baseEnv(dir); + const lines = []; + await run({ env, log: (l) => lines.push(l) }); + + const written = readFileSync(env.GITHUB_ENV, "utf8"); + const match = written.match(/^ASC_KEY_PATH=(.*)$/m); + assert.ok(match, "the staging script must publish ASC_KEY_PATH"); + const keyPath = match[1].trim(); + assert.ok(!written.includes("SYNTHETIC-MULTILINE-FIXTURE"), "the key must never reach GITHUB_ENV"); + assert.equal(mode(keyPath), 0o600, "the key file must be mode 600"); + assert.equal(mode(join(keyPath, "..")), 0o700, "the key directory must be mode 700"); + assert.ok(readFileSync(keyPath, "utf8").includes("SYNTHETIC-MULTILINE-FIXTURE")); + + const leaked = lines.filter((l) => l.includes("SYNTHETIC-MULTILINE-FIXTURE")); + assert.deepEqual(leaked, [], "the key must never reach the log"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("the key is taken back out of the environment for later steps", async () => { + const dir = mkdtempSync(join(tmpdir(), "asc-stage-")); + try { + const env = baseEnv(dir); + await run({ env, log: () => {} }); + const written = readFileSync(env.GITHUB_ENV, "utf8"); + assert.ok(written.includes("ASC_KEY_P8=\n"), "the environment entry must be blanked"); + // The blanking comes after the path, so the runner applies it last. + assert.ok(written.indexOf("ASC_KEY_P8=\n") > written.indexOf("ASC_KEY_PATH="), + "the blanking must come after the path so that it wins"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("a missing key fails the step and names Fleet-OPS prod /asc", async () => { + const dir = mkdtempSync(join(tmpdir(), "asc-stage-")); + try { + const lines = []; + await assert.rejects( + run({ env: baseEnv(dir, { ASC_KEY_P8: "" }), log: (l) => lines.push(l) }), + (err) => { + assert.match(err.message, /Missing ASC key material in Fleet-OPS prod \/asc/); + return true; + }, + ); + assert.ok(lines.some((l) => l.includes("Fleet-OPS prod /asc")), "the error names the path"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("the key never reaches a command line", async () => { + const dir = mkdtempSync(join(tmpdir(), "asc-stage-")); + try { + const env = baseEnv(dir); + await run({ env, log: () => {} }); + // The staging script's own contract: the key is on its standard input, + // and the published file holds the key, not an argument. + const keyPath = readFileSync(env.GITHUB_ENV, "utf8").match(/^ASC_KEY_PATH=(.*)$/m)[1].trim(); + assert.ok(readFileSync(keyPath, "utf8").includes("SYNTHETIC-MULTILINE-FIXTURE")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/test-ios-stage-asc-key.sh b/scripts/test-ios-stage-asc-key.sh index 89ba94e5..3d8863a4 100755 --- a/scripts/test-ios-stage-asc-key.sh +++ b/scripts/test-ios-stage-asc-key.sh @@ -72,7 +72,7 @@ if grep -Eq 'for name in .*ASC_KEY_P8' "$workflow"; then echo 'workflow still propagates the multiline key through an environment mask' >&2 exit 1 fi -if ! grep -Fq 'scripts/infisical-fetch-asc-key.mjs' "$workflow"; then +if ! grep -Fq 'scripts/stage-asc-key.mjs' "$workflow"; then echo 'workflow does not use the protected key-file handoff' >&2 exit 1 fi @@ -84,8 +84,14 @@ if grep -Fq '8dae6d03' "$workflow"; then echo 'workflow hard-codes the Infisical project id' >&2 exit 1 fi -# The fetch script is what hands the key to the protected handoff. -if ! grep -Fq 'ios-stage-asc-key.sh' "$repo_root/scripts/infisical-fetch-asc-key.mjs"; then +# No new code may talk to the vault directly: the values come from the +# approved composite action. +if grep -Eq 'listSecretsRaw|loginUniversalAuth' "$workflow"; then + echo 'workflow reads the vault directly instead of through the approved action' >&2 + exit 1 +fi +# The staging script is what hands the key to the protected handoff. +if ! grep -Fq 'ios-stage-asc-key.sh' "$repo_root/scripts/stage-asc-key.mjs"; then echo 'the fetch script does not hand the key to the staging script' >&2 exit 1 fi @@ -93,6 +99,6 @@ fi # The end-to-end handoff: a stubbed Infisical, the real fetch script and the # real staging script. The key is staged to a mode-600 file, only its path # is published, and the value never reaches the environment or the log. -node --test "$repo_root/scripts/infisical-fetch-asc-key.node-test.mjs" > /dev/null +node --test "$repo_root/scripts/stage-asc-key.node-test.mjs" > /dev/null echo 'ios-stage-asc-key: all tests passed' From 9a07064c705861dddaba5f947c5a814f83e73835 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:27:32 -0500 Subject: [PATCH 15/39] test(release): assert the bearer token on the secrets request A reviewer found that the fetch test's stub ignored the Authorization header, so a request carrying a broken token would still have passed. That is the gap that let a destructured login token through as "Bearer undefined". The assertion belongs on the shared fetch script, which is where the request is actually made, and it fails a request that does not carry the token the login returned. --- scripts/infisical-fetch-expand.node-test.mjs | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/scripts/infisical-fetch-expand.node-test.mjs b/scripts/infisical-fetch-expand.node-test.mjs index 8d6aa5a1..db2eaba7 100644 --- a/scripts/infisical-fetch-expand.node-test.mjs +++ b/scripts/infisical-fetch-expand.node-test.mjs @@ -43,6 +43,30 @@ function baseEnv(githubEnv, names) { }; } +test("the secrets request carries the bearer token from the login", async () => { + const dir = mkdtempSync(join(tmpdir(), "ops818-fetch-")); + try { + const authHeaders = []; + const stub = async (url, options) => { + const u = String(url); + if (u.includes("/api/v1/auth/universal-auth/login")) { + return { ok: true, json: async () => ({ accessToken: "test-token", expiresIn: 3600 }) }; + } + if (u.includes("/api/v3/secrets/raw")) { + authHeaders.push(options && options.headers && options.headers.authorization); + return { ok: true, json: async () => ({ secrets: [{ secretKey: "FOO", secretValue: RESOLVED }] }) }; + } + throw new Error(`unexpected URL ${u}`); + }; + await run({ env: baseEnv(join(dir, "env"), "FOO"), fetchImpl: stub, log: () => {} }); + // A broken token has to fail the request, and a test that ignored the + // header would not have noticed. + assert.deepEqual(authHeaders, ["Bearer test-token"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("the secrets list asks Infisical to expand secret references", async () => { const dir = mkdtempSync(join(tmpdir(), "ops818-fetch-")); try { From da9ed6e650306e08c088b5b048b23998b7c78efa Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:52:24 +0000 Subject: [PATCH 16/39] autofix: name ASC_KEY_P8 in the staging error and cover the CLI path Three review comments on scripts/stage-asc-key* and docs/AUTO-UPDATE.md. The missing-key error said only "Missing ASC key material", so an operator had to infer which variable was unresolved. Interpolate SECRET_NAME, which resolves to ASC_KEY_P8, into the message. The contract test only ever imported run(), so the invokedDirectly guard and the process.env lookup of ASC_KEY_P8 -- the only path CI actually takes -- went untested. Add a case that spawns the script and asserts the mode-600 file, the published ASC_KEY_PATH, the blanked ASC_KEY_P8 entry and that the key reaches neither stdout nor stderr. docs/AUTO-UPDATE.md claimed "only that file's path crosses the step boundary". The infisical-secrets action writes ASC_KEY_P8 into GITHUB_ENV and the staging step reads it from there, so the key does cross the step boundary; state the real invariant instead, so a later change does not assume the environment is clean. Co-Authored-By: Claude Code --- docs/AUTO-UPDATE.md | 2 +- scripts/stage-asc-key.mjs | 2 +- scripts/stage-asc-key.node-test.mjs | 41 +++++++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/docs/AUTO-UPDATE.md b/docs/AUTO-UPDATE.md index e5f3733a..4ba456e9 100644 --- a/docs/AUTO-UPDATE.md +++ b/docs/AUTO-UPDATE.md @@ -54,7 +54,7 @@ The EdDSA key pair is per app, so one app's key cannot sign another app's update - Stored as the repository secret `SPARKLE_ED_PRIVATE_KEY` by piping the file into `gh secret set`. It reaches `generate_appcast` on standard input, never as an argument. - The public key is `Ou2J0syHZawPSY3JLTLVyhbOylmtyr0QnZPbq7acETQ=`, in `script/build_and_run.sh`. -The App Store Connect signing key is a separate key, and CI reads it from Fleet-OPS prod `/asc` rather than from a repository secret. The repository copy of that key was revoked (HTTP 401, CC-163), which is what kept the Mac release from publishing for nine days. In CI the values come from the `infisical-secrets` composite action, which masks every line of every value and never prints one; the project id comes from the `INFISICAL_FLEET_OPS_PROJECT_ID` secret rather than from a literal in a public workflow. The multiline private key is staged by `scripts/stage-asc-key.mjs` into a mode-600 file, and only that file's path crosses the step boundary. The same loader is used by `mac-release.yml` and `ios-ship.yml`, and `scripts/mac-release-workflow.test.mjs` pins the two to the same text. +The App Store Connect signing key is a separate key, and CI reads it from Fleet-OPS prod `/asc` rather than from a repository secret. The repository copy of that key was revoked (HTTP 401, CC-163), which is what kept the Mac release from publishing for nine days. In CI the values come from the `infisical-secrets` composite action, which masks every line of every value and never prints one; the project id comes from the `INFISICAL_FLEET_OPS_PROJECT_ID` secret rather than from a literal in a public workflow. The multiline private key arrives in the step environment from the `infisical-secrets` action, is streamed over stdin into a mode-600 file by `scripts/stage-asc-key.mjs`, and the `ASC_KEY_P8` environment entry is blanked in the same step, so only that file's path crosses into the later steps. The same loader is used by `mac-release.yml` and `ios-ship.yml`, and `scripts/mac-release-workflow.test.mjs` pins the two to the same text. To check that the release credentials still work without publishing anything, run the "Mac Release (Sparkle)" workflow by hand with the `verify-only` input set. It fetches the key, asks Apple's notarization service whether it still accepts it, and stops. diff --git a/scripts/stage-asc-key.mjs b/scripts/stage-asc-key.mjs index acf60da4..1fa01158 100644 --- a/scripts/stage-asc-key.mjs +++ b/scripts/stage-asc-key.mjs @@ -36,7 +36,7 @@ function fail(log, message) { export async function run({ env = process.env, log = (line) => console.log(line) } = {}) { const value = env[SECRET_NAME]; if (value === undefined || value === null || String(value).trim() === '') { - fail(log, `Missing ASC key material in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc.`); + fail(log, `Missing ${SECRET_NAME} in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc.`); } await new Promise((resolve, reject) => { diff --git a/scripts/stage-asc-key.node-test.mjs b/scripts/stage-asc-key.node-test.mjs index 97124bd8..387004b3 100644 --- a/scripts/stage-asc-key.node-test.mjs +++ b/scripts/stage-asc-key.node-test.mjs @@ -8,10 +8,12 @@ // // Run: node --test scripts/stage-asc-key.node-test.mjs import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import { run } from './stage-asc-key.mjs'; @@ -71,18 +73,19 @@ test("the key is taken back out of the environment for later steps", async () => } }); -test("a missing key fails the step and names Fleet-OPS prod /asc", async () => { +test("a missing key fails the step and names the secret and its path", async () => { const dir = mkdtempSync(join(tmpdir(), "asc-stage-")); try { const lines = []; await assert.rejects( run({ env: baseEnv(dir, { ASC_KEY_P8: "" }), log: (l) => lines.push(l) }), (err) => { - assert.match(err.message, /Missing ASC key material in Fleet-OPS prod \/asc/); + assert.match(err.message, /Missing ASC_KEY_P8 in Fleet-OPS prod \/asc/); return true; }, ); assert.ok(lines.some((l) => l.includes("Fleet-OPS prod /asc")), "the error names the path"); + assert.ok(lines.some((l) => l.includes("ASC_KEY_P8")), "the error names the secret"); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -101,3 +104,37 @@ test("the key never reaches a command line", async () => { rmSync(dir, { recursive: true, force: true }); } }); + +// CI only ever runs `node scripts/stage-asc-key.mjs`, so the CLI entry point +// -- the invokedDirectly guard and the process.env lookup -- is the path that +// ships. The tests above import run(), which cannot reach it. +test("the CLI entry point stages the key, publishes the path and blanks the environment", () => { + const dir = mkdtempSync(join(tmpdir(), "asc-stage-cli-")); + try { + const env = baseEnv(dir); + const script = fileURLToPath(new URL("./stage-asc-key.mjs", import.meta.url)); + const result = spawnSync(process.execPath, [script], { + env: { ...process.env, ...env }, + encoding: "utf8", + }); + assert.equal(result.status, 0, `the CLI must succeed: ${result.stderr}`); + + const written = readFileSync(env.GITHUB_ENV, "utf8"); + const match = written.match(/^ASC_KEY_PATH=(.*)$/m); + assert.ok(match, "the CLI must publish ASC_KEY_PATH"); + const keyPath = match[1].trim(); + assert.equal(mode(keyPath), 0o600, "the key file must be mode 600"); + assert.ok(readFileSync(keyPath, "utf8").includes("SYNTHETIC-MULTILINE-FIXTURE")); + + assert.ok(written.includes("ASC_KEY_P8=\n"), "the CLI must blank the environment entry"); + assert.ok(written.indexOf("ASC_KEY_P8=\n") > written.indexOf("ASC_KEY_PATH="), + "the blanking must come after the path so that it wins"); + + for (const stream of ["stdout", "stderr"]) { + assert.ok(!result[stream].includes("SYNTHETIC-MULTILINE-FIXTURE"), + `the key must never reach ${stream}`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From 33ce8f6f1305918fd9efea636c56d23171fcc7f5 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:56:28 -0500 Subject: [PATCH 17/39] fix(release): stop a verify-only run installing a toolchain it will not use A reviewer found four things in the second round, all fixed. A verify-only run still installed XcodeGen after the notarization preflight had already answered the question, which burns a macOS runner for a toolchain it never uses. The installer is gated with the build now. The documentation claimed that only the key's path crosses the step boundary, and that was no longer true. The composite action puts the key in the step environment; the staging script streams it into the mode-600 file and blanks the environment entry in the same step, so only the path crosses into the later steps. The wording is corrected in the release documentation, in the iOS workflow and in its contract test. The missing-key error did not name the secret it was missing, which the fleet's rule requires: it names ASC_KEY_P8 now. And nothing exercised the command-line entry point the workflow actually calls, so a test now spawns it the way CI does. --- .github/workflows/ios-ship.yml | 7 +++++-- .github/workflows/mac-release.yml | 1 + docs/EFFORT-LOG.md | 14 ++++++++++++++ scripts/ios-ship-workflow.test.mjs | 7 ++++--- scripts/mac-release-workflow.test.mjs | 2 ++ 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index 90805957..e8219137 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -22,8 +22,11 @@ name: iOS TestFlight ship (GitHub-hosted macOS) # Usage-Monitor read it. The same-named repository secrets are a stale # copy that App Store Connect answers with HTTP 401 (CC-163), 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. +# * The multiline key arrives in the step environment from the +# infisical-secrets action, is streamed over stdin into a mode-600 file by +# scripts/stage-asc-key.mjs, and the ASC_KEY_P8 environment entry is +# blanked in the same step, so only that file's path crosses into the +# later steps. # * IOS_DIST_P12_BASE64 / IOS_DIST_P12_PASSWORD (Apple Distribution identity) # stay repository secrets. # * SENTRY_DSN and SENTRY_AUTH_TOKEN are best-effort: an inert Sentry beats a diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index 5b199ae3..98739811 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -187,6 +187,7 @@ jobs: run: bash scripts/check-notary-access.sh - name: Install XcodeGen + if: steps.gate.outputs.publish == 'true' || steps.gate.outputs.verify_only != 'true' run: | if ! command -v xcodegen >/dev/null 2>&1; then brew install xcodegen diff --git a/docs/EFFORT-LOG.md b/docs/EFFORT-LOG.md index ed4c1dbc..dde38b6a 100644 --- a/docs/EFFORT-LOG.md +++ b/docs/EFFORT-LOG.md @@ -751,6 +751,20 @@ repo: CodeCaps; pre-work claim: posted to #agent-sync after reading AGENT-SYNC.m environment to `scripts/ios-stage-asc-key.sh` over a pipe, writes the mode-600 file, and blanks the environment variable so no later step carries the key. +- The second review round found four more, all fixed. A verify-only run + still installed XcodeGen after the notary preflight had already answered + the question, which burns a macOS runner for a toolchain it never uses; the + installer is gated with the build now. The documentation claimed that + only the key's path crosses the step boundary, and that was no longer + true: the composite action puts the key in the step environment, and the + staging script streams it into the mode-600 file and blanks the + environment entry in the same step, so only the path crosses into the + later steps. The wording was corrected in the release documentation, in + the iOS workflow and in its contract test. The missing-key error did not + name the secret it was missing, which a reviewer caught against the + fleet's rule that an error has to name the unresolved secret variable; it + names `ASC_KEY_P8` now. And nothing exercised the command-line entry + point the workflow actually calls, so a test now spawns it the way CI does. - Three more findings, all fixed. A verify-only run skipped the notarization preflight and still spent minutes building a bundle it would never publish, so the gate now has a `verify_only` output: the preflight runs, the build diff --git a/scripts/ios-ship-workflow.test.mjs b/scripts/ios-ship-workflow.test.mjs index cba3b724..4f9867ff 100644 --- a/scripts/ios-ship-workflow.test.mjs +++ b/scripts/ios-ship-workflow.test.mjs @@ -176,9 +176,10 @@ test("ASC credentials come from Fleet-OPS /asc, never the stale repository secre // No new code may talk to the vault directly. assert.doesNotMatch(code, /listSecretsRaw|loginUniversalAuth/); - // The multiline key goes to a mode-600 file, never GITHUB_ENV. The - // protected handoff lives in the fetch script, which hands the key to - // scripts/ios-stage-asc-key.sh over a pipe. + // The multiline key arrives in the step environment from the + // infisical-secrets action and is streamed over stdin into a mode-600 file + // by scripts/stage-asc-key.mjs, which then blanks the environment entry, + // so only the path crosses into the later steps. assert.match(code, /scripts\/stage-asc-key\.mjs/); assert.match(readFileSync(join(ROOT, "scripts/stage-asc-key.mjs"), "utf8"), /ios-stage-asc-key\.sh/); assert.doesNotMatch(code, /for name in .*ASC_KEY_P8/); diff --git a/scripts/mac-release-workflow.test.mjs b/scripts/mac-release-workflow.test.mjs index 4742ee0a..9ef38943 100644 --- a/scripts/mac-release-workflow.test.mjs +++ b/scripts/mac-release-workflow.test.mjs @@ -92,6 +92,8 @@ test('PRs cannot notarize or publish, and icon-only changes trigger releases', ( assert.match(step(workflow, 'Decide whether this run can publish'), /verify-only[\s\S]*verify_only=true/); assert.match(step(workflow, 'Verify notarization access before building'), /publish == 'true' \|\| steps\.gate\.outputs\.verify_only == 'true'/); assert.match(code, /steps\.gate\.outputs\.publish != 'true' && steps\.gate\.outputs\.verify_only != 'true'/); + // A verify-only run must not install a toolchain it will not use. + assert.match(step(workflow, 'Install XcodeGen'), /publish == 'true' \|\| steps\.gate\.outputs\.verify_only != 'true'/); // The credential check job holds the signing key, so it must not be able // to push commits, tags or releases. assert.match(code, /verify-release-credentials:[\s\S]*permissions:\s*\n\s*contents: read/); From dd178dfa9f1c539f5db52d5ff62dc43b57a6a593 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:07:55 -0500 Subject: [PATCH 18/39] fix(release): run the shared fetch script's test in CI The shared fetch script's test file was run by no workflow at all, so the bearer-token assertion added to it would have been decoration: the 'Bearer undefined' failure it guards against could still have shipped. Both workflows that use the script now run its test, and the path filters cover it. --- .github/workflows/ios-ship-contract.yml | 2 +- .github/workflows/mac-release.yml | 4 ++-- docs/EFFORT-LOG.md | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ios-ship-contract.yml b/.github/workflows/ios-ship-contract.yml index e0cf6f58..e90f49f7 100644 --- a/.github/workflows/ios-ship-contract.yml +++ b/.github/workflows/ios-ship-contract.yml @@ -46,6 +46,6 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Pin the auto-ship wiring run: | - node --test scripts/ios-ship-workflow.test.mjs scripts/stage-asc-key.node-test.mjs + node --test scripts/ios-ship-workflow.test.mjs scripts/stage-asc-key.node-test.mjs scripts/infisical-fetch-expand.node-test.mjs bash scripts/ios-fleet/test-asc-credentials.sh node --test scripts/ios-fleet/test-asc-credentials.mjs diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index 98739811..4b71cdb6 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -50,7 +50,7 @@ on: - 'scripts/ios-stage-asc-key.sh' - '.github/workflows/mac-release.yml' - '.github/actions/infisical-secrets/**' - - 'scripts/infisical-fetch.mjs' + - 'scripts/infisical-fetch*.mjs' - 'scripts/stage-asc-key*' workflow_dispatch: inputs: @@ -85,7 +85,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Test Sparkle release contract without credentials - run: node --test scripts/mac-release-workflow.test.mjs scripts/stage-asc-key.node-test.mjs + run: node --test scripts/mac-release-workflow.test.mjs scripts/stage-asc-key.node-test.mjs scripts/infisical-fetch-expand.node-test.mjs - name: Wait for Swift CI compile checks on this commit env: diff --git a/docs/EFFORT-LOG.md b/docs/EFFORT-LOG.md index dde38b6a..aa8ec3b7 100644 --- a/docs/EFFORT-LOG.md +++ b/docs/EFFORT-LOG.md @@ -751,6 +751,10 @@ repo: CodeCaps; pre-work claim: posted to #agent-sync after reading AGENT-SYNC.m environment to `scripts/ios-stage-asc-key.sh` over a pipe, writes the mode-600 file, and blanks the environment variable so no later step carries the key. +- One more finding from the second round, also fixed: the shared fetch + script's own test file ran in no workflow at all, so the new bearer-token + assertion would have been decoration. It is now run by both workflows that + use the script. - The second review round found four more, all fixed. A verify-only run still installed XcodeGen after the notary preflight had already answered the question, which burns a macOS runner for a toolchain it never uses; the From c910816e18859566b6431cb512e77620ea2e832a Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:11:37 +0000 Subject: [PATCH 19/39] autofix: state the real ASC key step boundary in the secrets table The ASC_KEY_ID row still claimed "only its path crosses steps", which the code no longer provides: the infisical-secrets action writes the key into $GITHUB_ENV and scripts/stage-asc-key.mjs reads it from there before blanking the entry. The prose paragraph above already described the real invariant; bring the table row in line with it. Co-Authored-By: Claude Code --- docs/AUTO-UPDATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/AUTO-UPDATE.md b/docs/AUTO-UPDATE.md index 4ba456e9..f36c1897 100644 --- a/docs/AUTO-UPDATE.md +++ b/docs/AUTO-UPDATE.md @@ -71,7 +71,7 @@ A private repository cannot do this, because anonymous clients cannot download i | Name | Where | Status on Tue, Sep 29, 2026 | |---|---|---| | `MAC_CERT_P12_BASE64`, `MAC_CERT_PASSWORD` | Infisical (the way BotFleet's `release.yml` reads them), needing `INFISICAL_PROJECT_ID` and `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID`/`_SECRET` (or `INFISICAL_CLIENT_ID`/`_SECRET`) repository secrets; or same-named repository secrets as the fallback | Missing. Publishing is gated until they exist. | -| `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_P8` | Fleet-OPS Infisical prod `/asc`, using the same loader as `ios-ship.yml`. The stale repository copies are never read. The PEM is staged by `scripts/ios-stage-asc-key.sh` to a mode-600 temporary file; only its path crosses steps. | Notarization access is verified with read-only `notarytool history` before building. Artifact acceptance is still required separately. | +| `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_P8` | Fleet-OPS Infisical prod `/asc`, using the same loader as `ios-ship.yml`. The stale repository copies are never read. The PEM arrives in the step environment from the `infisical-secrets` action and is streamed over stdin by `scripts/stage-asc-key.mjs` into a mode-600 temporary file, and the `ASC_KEY_P8` environment entry is blanked in the same step, so only the file's path crosses into the later steps. | Notarization access is verified with read-only `notarytool history` before building. Artifact acceptance is still required separately. | | `SPARKLE_ED_PRIVATE_KEY` | Repository secret | Present. | Pull requests skip credential loading, perform ad-hoc structural validation and never publish. On main, a missing Infisical identity, missing fleet ASC material, or failed notarization preflight fails the job before publication. If fleet ASC loading succeeds but certificate or Sparkle signing material is missing, the publication gate reports the missing names and runs the existing ad-hoc build-only validation. None of these non-publishing paths updates the feed. From 8aa3d74a8790ba2f8dad650daa63e7f58ced7702 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:27:56 -0500 Subject: [PATCH 20/39] fix(release): isolate the verify-only dispatch and publish only from main A verify-only dispatch now runs the credential check job on its own, so a manual verification does not load the Developer ID, Sentry and Sparkle material and does not spend minutes building a bundle it will never publish. The publication gate refuses any run that is not on main, so a dispatch from a branch builds and verifies and stops there. The contract test covers both gates. --- .github/workflows/mac-release.yml | 15 +++++++++++++-- scripts/mac-release-workflow.test.mjs | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index 4b71cdb6..fa7c5b3d 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -96,9 +96,13 @@ jobs: release: needs: await-swift-ci + # A verify-only dispatch has its own job below. The release job must not + # run for one: it would load the Developer ID, Sentry and Sparkle material + # and build the whole bundle for a check that publishes nothing. if: >- github.event.repository.fork == false && - needs.await-swift-ci.result == 'success' + needs.await-swift-ci.result == 'success' && + inputs.verify-only != true runs-on: macos-latest timeout-minutes: 60 env: @@ -168,6 +172,11 @@ jobs: echo "::notice title=Pull request::Built and verified only. Releases are published from main." exit 0 fi + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "::notice title=Mac release not published::Only a run on main publishes a release. This run was on ${GITHUB_REF}." + exit 0 + fi missing=() for name in MAC_CERT_P12_BASE64 MAC_CERT_PASSWORD ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_PATH SPARKLE_ED_PRIVATE_KEY; do [ -n "${!name:-}" ] || missing+=("$name") @@ -343,7 +352,9 @@ jobs: # and a scheduled run would fetch it for nothing. verify-release-credentials: name: Verify the release credentials load - if: github.event_name == 'workflow_dispatch' + # Only for a verify-only dispatch. A dispatch that is going to publish + # must not run this job, and an ordinary push never may. + if: github.event_name == 'workflow_dispatch' && inputs.verify-only == true runs-on: macos-latest timeout-minutes: 10 # Read-only. This job holds the signing key, so a compromised checkout diff --git a/scripts/mac-release-workflow.test.mjs b/scripts/mac-release-workflow.test.mjs index 9ef38943..a3ffccb5 100644 --- a/scripts/mac-release-workflow.test.mjs +++ b/scripts/mac-release-workflow.test.mjs @@ -107,6 +107,20 @@ test('PRs cannot notarize or publish, and icon-only changes trigger releases', ( assert.match(code, /run: script\/build_and_run.sh --release/); assert.ok(code.indexOf('Verify notarization access') < code.indexOf('Build, sign, notarize')); }); + +test('a verify-only dispatch is isolated from the release job, and only main publishes', () => { + // A verify-only dispatch has its own job. The release job must not run for + // one: it would load the Developer ID, Sentry and Sparkle material and + // build the whole bundle for a check that publishes nothing. + assert.match(code, /needs\.await-swift-ci\.result == 'success' &&\s*inputs\.verify-only != true/); + // And the credential check must not run for a dispatch that is going to + // publish, or for an ordinary push. + assert.match(code, /if: github\.event_name == 'workflow_dispatch' && inputs\.verify-only == true/); + // Only a run on main may publish. A dispatch from a branch must build and + // verify, and stop there. + assert.match(step(workflow, 'Decide whether this run can publish'), /GITHUB_REF" != "refs\/heads\/main[\s\S]*publish=false[\s\S]*exit 0/); + assert.match(step(workflow, 'Decide whether this run can publish'), /Only a run on main publishes a release\./); +}); function preflight(exitCode, error, missing = false) { const dir = mkdtempSync(join(tmpdir(), 'notary-test-')); try { From 9bd96d8f7e23e4b06baeefcc156271b290d63492 Mon Sep 17 00:00:00 2001 From: MiniMax Date: Wed, 21 Oct 2026 03:10:10 -0500 Subject: [PATCH 21/39] fix(pip): a failed provider is not a default PiP target PR #281 left main with a red Mac package build and test. Its own test testFailedAntigravityReadingStaysPinnedWithoutShowingCachedQuota failed, because targetRows fell back to the two lowest rows by remaining percentage and never looked at a provider issue, so a provider whose refresh failed kept a default PiP target. The iOS companion compile job sits behind that job, so every pull request was blocked and the release pipeline could not publish. The default target set is now filled in three steps: rows that can be measured, then rows that are quiet rather than failed, then rows whose provider failed. A failed provider is therefore never a default target and never displaces a row that can be measured, and it is still used when the HUD would otherwise have nothing to show, so both of PR #281's own tests pass unchanged. A pinned failed row still shows, and it still reports its status instead of a cached quota. Fixes CC-181 --- Sources/CodeCaps/PipWidget.swift | 41 +++++++++++---- .../PipTargetSelectionTests.swift | 50 +++++++++++++++++++ 2 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 Tests/CodeCapsTests/PipTargetSelectionTests.swift diff --git a/Sources/CodeCaps/PipWidget.swift b/Sources/CodeCaps/PipWidget.swift index 1da9ff0d..3417338d 100644 --- a/Sources/CodeCaps/PipWidget.swift +++ b/Sources/CodeCaps/PipWidget.swift @@ -443,18 +443,39 @@ final class PipWidgetController: NSObject, NSWindowDelegate { let rows = all.filter { selected.contains($0.id) } if !rows.isEmpty { return rows } } - let sorted = all.sorted { a, b in - let aAvailable = readingStatus(for: a, now: model.now, issue: model.issues[a.providerKey], - disabledSources: model.disabledSources) == nil - let bAvailable = readingStatus(for: b, now: model.now, issue: model.issues[b.providerKey], - disabledSources: model.disabledSources) == nil - if aAvailable != bAvailable { return aAvailable } - return (currentWindows(for: a, now: model.now, disabledSources: model.disabledSources) + // The default target set is what the HUD can honestly draw. A row + // whose provider failed cannot describe the current allowance, so it is + // not a default target and it never displaces a row that can be + // measured. It is used only to fill a HUD that would otherwise have + // nothing to show, and only after the rows that are quiet rather than + // failed. A pinned failed row still shows, because the user asked for + // it and it reports its status instead of a cached quota. + func remainingPercent(_ row: DisplaySection) -> Double { + (currentWindows(for: row, now: model.now, disabledSources: model.disabledSources) .compactMap(\.remainingPercent).min() ?? 100) - < (currentWindows(for: b, now: model.now, disabledSources: model.disabledSources) - .compactMap(\.remainingPercent).min() ?? 100) } - return Array(sorted.prefix(2)) + func isMeasurable(_ row: DisplaySection) -> Bool { + readingStatus(for: row, now: model.now, issue: model.issues[row.providerKey], + disabledSources: model.disabledSources) == nil + } + let measurable = all.filter { isMeasurable($0) } + let failed = all.filter { model.issues[$0.providerKey] != nil } + let quiet = all.filter { !isMeasurable($0) && model.issues[$0.providerKey] == nil } + var chosen = Array(measurable.sorted { remainingPercent($0) < remainingPercent($1) }.prefix(2)) + if chosen.count < 2 { + chosen += Array(quiet.sorted { remainingPercent($0) < remainingPercent($1) } + .prefix(2 - chosen.count)) + } + if chosen.count < 2 { + chosen += Array(failed.sorted { remainingPercent($0) < remainingPercent($1) } + .prefix(2 - chosen.count)) + } + return chosen.sorted { a, b in + let aAvailable = isMeasurable(a) + let bAvailable = isMeasurable(b) + if aAvailable != bAvailable { return aAvailable } + return remainingPercent(a) < remainingPercent(b) + } } /// Cached or failed-source readings cannot describe the current allowance. diff --git a/Tests/CodeCapsTests/PipTargetSelectionTests.swift b/Tests/CodeCapsTests/PipTargetSelectionTests.swift new file mode 100644 index 00000000..c5dee681 --- /dev/null +++ b/Tests/CodeCapsTests/PipTargetSelectionTests.swift @@ -0,0 +1,50 @@ +import AppKit +import QuotaCore +import XCTest +@testable import CodeCaps + +/// The default PiP target set is what the HUD can honestly draw. These cover +/// the rule that a provider whose refresh failed is not a default target, and +/// that the HUD still shows status rows when it has nothing else to show. +@MainActor +final class PipTargetSelectionTests: XCTestCase { + /// A provider whose refresh failed cannot describe the current allowance, + /// so it never takes a default target from a provider that can. + func testAFailedProviderIsNotADefaultTarget() throws { + let (model, defaults, suite) = GlanceFixtures.makeModel(view: .fromMac, alarmsAll: false, fleet: false) + defer { defaults.removePersistentDomain(forName: suite) } + let windows = [ + GlanceFixtures.window("anthropic:5h", provider: "anthropic", label: "5-hour window", + token: "5h", remaining: 13, resetIn: 3_600), + GlanceFixtures.window("google-antigravity:gemini:weekly", provider: "google-antigravity", + label: "Gemini Models · Weekly", token: "weekly", remaining: 54, resetIn: 86_400) + ] + model.injectForTests(sections: QuotaResponse(generatedAt: "", windows: windows) + .platformSections(now: GlanceFixtures.now), now: GlanceFixtures.now, + issues: ["google-antigravity": "Helper failed"]) + model.pipPinnedRowIds = [] + model.pipAutoAddedRowIds = [] + let rows = PipWidgetController.targetRows(for: model) + XCTAssertFalse(rows.contains { $0.providerKey == "google-antigravity" }) + XCTAssertTrue(rows.contains { $0.providerKey == "anthropic" }) + } + + /// A failed provider the user pinned still shows, and it reports its + /// status rather than a cached quota. + func testAPinnedFailedProviderStillShowsItsStatus() throws { + let (model, defaults, suite) = GlanceFixtures.makeModel(view: .fromMac, alarmsAll: false, fleet: false) + defer { defaults.removePersistentDomain(forName: suite) } + let windows = [GlanceFixtures.window("google-antigravity:gemini:weekly", provider: "google-antigravity", + label: "Gemini Models · Weekly", token: "weekly", remaining: 54, resetIn: 86_400)] + model.injectForTests(sections: QuotaResponse(generatedAt: "", windows: windows) + .platformSections(now: GlanceFixtures.now), now: GlanceFixtures.now, + issues: ["google-antigravity": "Helper failed"]) + let row = try XCTUnwrap(model.displaySections.first { $0.providerKey == "google-antigravity" }) + model.pipPinnedRowIds = [row.id] + XCTAssertEqual(PipWidgetController.targetRows(for: model).map(\.id), [row.id]) + XCTAssertEqual(PipWidgetController.readingStatus(for: row, now: model.now, + issue: model.issues[row.providerKey]), "unavailable") + XCTAssertEqual(PipWidgetController.meterCount(for: row, now: model.now, + issue: model.issues[row.providerKey]), 0) + } +} From 9dd59623bf953901aaed8c1e0aa6a4db1eefaddb Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:34:55 -0500 Subject: [PATCH 22/39] fix(release): keep the ASC private key out of the job environment The Fleet-OPS ASC key was fetched by the infisical-secrets composite action, which writes every requested value into GITHUB_ENV. The private key was therefore a job environment entry for the rest of the run, where any later step, any process listing and any environment dump could read it. The workflow header claimed the key stayed in a mode-600 file, and the source contradicted that. The loader now reads the vault itself. scripts/stage-asc-key.mjs logs into Infisical, reads ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 from Fleet-OPS prod /asc, streams the key over stdin into scripts/ios-stage-asc-key.sh, and publishes only the identifiers and the path of the mode-600 file. The child process gets a filtered environment, so the key and the client credentials never reach it, and a key supplied in the environment is ignored in favour of the vault copy. Both workflows drop the composite step for the ASC material and pass the vault location to the script instead. The contract tests now lock the boundary: the loader steps may not name the private key, may not use the composite action, and must carry the Fleet-OPS project id, the environment and the secret path. Fixes CC-177 --- .github/workflows/ios-ship.yml | 25 ++--- .github/workflows/mac-release.yml | 42 ++++---- docs/IOS-TESTFLIGHT.md | 2 +- scripts/ios-ship-workflow.test.mjs | 13 +-- scripts/mac-release-workflow.test.mjs | 29 +++++- scripts/stage-asc-key.mjs | 102 +++++++++++++----- scripts/stage-asc-key.node-test.mjs | 143 ++++++++++++++++++++------ 7 files changed, 245 insertions(+), 111 deletions(-) diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index a89b05f5..87574ff1 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -22,11 +22,10 @@ name: iOS TestFlight ship (GitHub-hosted macOS) # Usage-Monitor read it. The same-named repository secrets are a stale # copy that App Store Connect answers with HTTP 401 (CC-163), so they are # deliberately not read here and there is no fallback to them. -# * The multiline key arrives in the step environment from the -# infisical-secrets action, is streamed over stdin into a mode-600 file by -# scripts/stage-asc-key.mjs, and the ASC_KEY_P8 environment entry is -# blanked in the same step, so only that file's path crosses into the -# later steps. +# * The multiline key never enters the step environment. +# scripts/stage-asc-key.mjs reads the vault itself, streams the key over +# stdin into a mode-600 file and publishes only that file's path, so the +# key material crosses no step boundary. # * IOS_DIST_P12_BASE64 / IOS_DIST_P12_PASSWORD (Apple Distribution identity) # stay repository secrets. # * SENTRY_DSN and SENTRY_AUTH_TOKEN are best-effort: an inert Sentry beats a @@ -216,18 +215,14 @@ jobs: # 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. - - name: Load ASC credentials (Fleet-OPS prod /asc) - uses: ./.github/actions/infisical-secrets - with: - names: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 - project-id: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} - environment: prod - secret-path: /asc - client-id: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} - client-secret: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} - - name: Stage the ASC private key (Fleet-OPS prod /asc) run: node scripts/stage-asc-key.mjs + env: + INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} + INFISICAL_ENVIRONMENT: prod + INFISICAL_SECRET_PATH: /asc + INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} + INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} - name: Check the ASC credentials arrived run: | diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index fa7c5b3d..1593f047 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -11,9 +11,12 @@ name: Mac Release (Sparkle) # BotFleet's release.yml does (INFISICAL_PROJECT_ID plus # INFISICAL_UNIVERSAL_AUTH_CLIENT_ID/_SECRET, or INFISICAL_CLIENT_ID/ # _SECRET), with same-named repository secrets as the fallback. -# ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 come from Fleet-OPS Infisical prod -# /asc, matching ios-ship.yml. Never fall back to the stale repository -# ASC_* secrets. The private key stays in a mode-600 temporary file. +# ASC_KEY_ID, ASC_ISSUER_ID and the App Store Connect private key come from +# Fleet-OPS Infisical prod /asc, matching ios-ship.yml. Never fall back +# to the stale repository ASC_* secrets. The private key never enters +# the job environment: scripts/stage-asc-key.mjs reads it from the vault +# itself, streams it over stdin into a mode-600 file and publishes only +# that file's path. # SPARKLE_ED_PRIVATE_KEY The EdDSA key that signs each update archive # (Sparkle's generate_keys -x output). # Pull requests build without credentials and never publish. A main release @@ -128,20 +131,15 @@ jobs: GH_FALLBACK_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} GH_FALLBACK_SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - - name: Load ASC credentials (Fleet-OPS prod /asc) - if: github.event_name != 'pull_request' - uses: ./.github/actions/infisical-secrets - with: - names: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 - project-id: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} - environment: prod - secret-path: /asc - client-id: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} - client-secret: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} - - name: Stage the ASC private key (Fleet-OPS prod /asc) if: github.event_name != 'pull_request' run: node scripts/stage-asc-key.mjs + env: + INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} + INFISICAL_ENVIRONMENT: prod + INFISICAL_SECRET_PATH: /asc + INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} + INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} - name: Check the ASC credentials arrived if: github.event_name != 'pull_request' @@ -364,18 +362,14 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Load ASC credentials (Fleet-OPS prod /asc) - uses: ./.github/actions/infisical-secrets - with: - names: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 - project-id: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} - environment: prod - secret-path: /asc - client-id: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} - client-secret: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} - - name: Stage the ASC private key (Fleet-OPS prod /asc) run: node scripts/stage-asc-key.mjs + env: + INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} + INFISICAL_ENVIRONMENT: prod + INFISICAL_SECRET_PATH: /asc + INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} + INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} - name: Check the ASC credentials arrived run: | diff --git a/docs/IOS-TESTFLIGHT.md b/docs/IOS-TESTFLIGHT.md index a7d3945f..9b7d91ee 100644 --- a/docs/IOS-TESTFLIGHT.md +++ b/docs/IOS-TESTFLIGHT.md @@ -28,7 +28,7 @@ Marketing and build both come from the repo's commit count: `1.0.` ## When A Build Is Missing 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. +2. A failed run names its step. `Stage the ASC private key (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 (Retired From TestFlight) diff --git a/scripts/ios-ship-workflow.test.mjs b/scripts/ios-ship-workflow.test.mjs index 4f9867ff..e8f9447e 100644 --- a/scripts/ios-ship-workflow.test.mjs +++ b/scripts/ios-ship-workflow.test.mjs @@ -167,8 +167,8 @@ test("ASC credentials come from Fleet-OPS /asc, never the stale repository secre // bare Infisical CLI is not used: it prints the value to stdout. assert.doesNotMatch(code, /8dae6d03/); assert.doesNotMatch(code, /ASC_INFISICAL_PROJECT_ID/); - assert.match(code, /project-id: \$\{\{ secrets\.INFISICAL_FLEET_OPS_PROJECT_ID \}\}/); - assert.match(code, /secret-path: \/asc/); + assert.match(code, /INFISICAL_PROJECT_ID: \$\{\{ secrets\.INFISICAL_FLEET_OPS_PROJECT_ID \}\}/); + assert.match(code, /INFISICAL_SECRET_PATH: \/asc/); assert.match(code, /uses: \.\/\.github\/actions\/infisical-secrets/); assert.match(code, /secrets\.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID/); assert.doesNotMatch(code, /infisical secrets get/); @@ -176,14 +176,15 @@ test("ASC credentials come from Fleet-OPS /asc, never the stale repository secre // No new code may talk to the vault directly. assert.doesNotMatch(code, /listSecretsRaw|loginUniversalAuth/); - // The multiline key arrives in the step environment from the - // infisical-secrets action and is streamed over stdin into a mode-600 file - // by scripts/stage-asc-key.mjs, which then blanks the environment entry, - // so only the path crosses into the later steps. + // The multiline key never enters the step environment. The loader reads + // the vault itself and streams the key over stdin into a mode-600 file, so + // only the file's path crosses into the later steps and the workflow never + // names the private key. assert.match(code, /scripts\/stage-asc-key\.mjs/); assert.match(readFileSync(join(ROOT, "scripts/stage-asc-key.mjs"), "utf8"), /ios-stage-asc-key\.sh/); assert.doesNotMatch(code, /for name in .*ASC_KEY_P8/); assert.doesNotMatch(code, /printf '%s=%s\\n' "ASC_KEY_P8"/); + assert.doesNotMatch(code, /ASC_KEY_P8/); // The Distribution identity stays a repository secret, scoped to its step. assert.match(code, /IOS_DIST_P12_BASE64:\s*\$\{\{ secrets\.IOS_DIST_P12_BASE64 \}\}/); diff --git a/scripts/mac-release-workflow.test.mjs b/scripts/mac-release-workflow.test.mjs index a3ffccb5..3cf2c4ec 100644 --- a/scripts/mac-release-workflow.test.mjs +++ b/scripts/mac-release-workflow.test.mjs @@ -17,7 +17,6 @@ function step(s, name) { return s.slice(start, end < 0 ? undefined : end).trim(); } const LOADERS = [ - 'Load ASC credentials (Fleet-OPS prod /asc)', 'Stage the ASC private key (Fleet-OPS prod /asc)', 'Check the ASC credentials arrived', ]; @@ -34,6 +33,7 @@ function allSteps(s, name) { return out; } const iosShip = read('.github/workflows/ios-ship.yml'); +const iosCode = iosShip.split('\n').filter(l => !/^\s*#/.test(l)).join('\n'); test('Sparkle uses the existing fleet ASC loader, never repository ASC secrets', () => { assert.doesNotMatch(code, /secrets\.ASC_/); // One loader, two workflows. The release path and the iOS ship path must @@ -56,8 +56,26 @@ test('Sparkle uses the existing fleet ASC loader, never repository ASC secrets', assert.doesNotMatch(file, /8dae6d03/); assert.doesNotMatch(file, /ASC_INFISICAL_PROJECT_ID/); } - assert.match(loader, /project-id: \$\{\{ secrets\.INFISICAL_FLEET_OPS_PROJECT_ID \}\}/); + assert.match(loader, /INFISICAL_PROJECT_ID: \$\{\{ secrets\.INFISICAL_FLEET_OPS_PROJECT_ID \}\}/); assert.doesNotMatch(loader, /echo .*\$\{ASC_INFISICAL_PROJECT_ID\}/); + // The key material must not enter the job environment. The loader reads + // the vault itself, so the workflow never asks the composite action for the + // private key and never names it: only the file's path crosses a step + // boundary. A name here would put the key in GITHUB_ENV, where every + // later step, every process listing and every environment dump can read + // it. + assert.doesNotMatch(loader, /infisical-secrets/); + assert.doesNotMatch(loader, /ASC_KEY_P8/); + assert.doesNotMatch(loader, /names: ASC_/); + for (const file of [code, iosCode]) { + assert.doesNotMatch(file, /ASC_KEY_P8/); + assert.doesNotMatch(file, /names: ASC_/); + } + // The loader is told where the vault is, and only where. + assert.match(loader, /INFISICAL_ENVIRONMENT: prod/); + assert.match(loader, /INFISICAL_SECRET_PATH: \/asc/); + assert.match(loader, /INFISICAL_CLIENT_ID: \$\{\{ secrets\.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID \|\| secrets\.INFISICAL_CLIENT_ID \}\}/); + assert.match(loader, /INFISICAL_CLIENT_SECRET: \$\{\{ secrets\.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET \|\| secrets\.INFISICAL_CLIENT_SECRET \}\}/); // The bare Infisical CLI is gone: it prints the value to stdout, and its // only install path is macOS-only. for (const file of [workflow, iosShip]) { @@ -97,12 +115,13 @@ test('PRs cannot notarize or publish, and icon-only changes trigger releases', ( // The credential check job holds the signing key, so it must not be able // to push commits, tags or releases. assert.match(code, /verify-release-credentials:[\s\S]*permissions:\s*\n\s*contents: read/); - // No new code may talk to the vault directly: the values come from the - // approved composite action, and the only thing a script does is stage - // one of them to a file. + // The vault is reached by exactly one script for the ASC material, and the + // workflow names that script rather than the vault helpers. The other + // values still come from the approved composite action. for (const file of [workflow, iosShip]) { assert.doesNotMatch(file, /listSecretsRaw|loginUniversalAuth/); } + assert.match(read('scripts/stage-asc-key.mjs'), /from '\.\/infisical-fetch\.mjs'/); assert.match(code, /needs.await-swift-ci.result == 'success'/); assert.match(code, /run: script\/build_and_run.sh --release/); assert.ok(code.indexOf('Verify notarization access') < code.indexOf('Build, sign, notarize')); diff --git a/scripts/stage-asc-key.mjs b/scripts/stage-asc-key.mjs index 1fa01158..b114c392 100644 --- a/scripts/stage-asc-key.mjs +++ b/scripts/stage-asc-key.mjs @@ -1,18 +1,15 @@ -// Stage the ASC private key from the environment into the protected key-file -// handoff, and take it back out of the environment. +// Stage the App Store Connect private key from Fleet-OPS Infisical prod /asc +// into a mode-600 file, and publish only the file's path. // -// The key itself is fetched by the infisical-secrets composite action, which -// is this repository's approved way to read a value out of the vault: it -// masks every line of every value and never prints one. This script does -// not talk to the vault itself. +// The key material never enters the environment. It is read from the vault +// in this process, streamed over stdin into scripts/ios-stage-asc-key.sh, and +// written to a mode-600 file. Only ASC_KEY_PATH crosses the step boundary, +// so no later step, no process listing and no environment dump can carry the +// key. // -// The key is multiline, so it must not stay in the environment. The key is -// streamed into scripts/ios-stage-asc-key.sh, which writes the mode-600 file -// and publishes only its path, and the environment variable is then blanked -// so that no later step carries the key. -// -// The value never reaches a command line: it is written to the staging -// script's standard input, so it appears in no process arguments. +// ASC_KEY_ID and ASC_ISSUER_ID are identifiers rather than key material, and +// the ship and notarize steps need them, so they are published as ordinary +// environment entries. The repository's stale ASC_* secrets are never read. // // Run: node scripts/stage-asc-key.mjs import { spawn } from 'node:child_process'; @@ -20,29 +17,80 @@ import { appendFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + listSecretsRaw, + loginUniversalAuth, + maskValue, + writeGithubEnvValue, +} from './infisical-fetch.mjs'; + const here = dirname(fileURLToPath(import.meta.url)); const STAGING_SCRIPT = join(here, 'ios-stage-asc-key.sh'); -const SECRET_NAME = 'ASC_KEY_P8'; +const SECRET_PATH = '/asc'; function fail(log, message) { log(`::error::${message}`); throw new Error(message); } -/** Hand the key in the environment to the staging script and blank it. +/** The environment the staging script needs, and nothing else. * - * The key is written to the staging script's standard input rather than to - * its arguments, so it appears in no process listing. */ -export async function run({ env = process.env, log = (line) => console.log(line) } = {}) { - const value = env[SECRET_NAME]; - if (value === undefined || value === null || String(value).trim() === '') { - fail(log, `Missing ${SECRET_NAME} in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc.`); + * The Infisical client credentials and the key itself stay out of the child + * process, so the only thing the staging script can see is the key arriving + * on its standard input. */ +function childEnv(env) { + const keep = ['PATH', 'GITHUB_ENV', 'RUNNER_TEMP', 'TMPDIR', 'ASC_STAGING_ROOT']; + const out = {}; + for (const name of keep) { + const value = env[name] ?? process.env[name]; + if (value !== undefined && value !== null && value !== '') out[name] = value; + } + return out; +} + +export async function run({ + env = process.env, + fetchImpl = fetch, + log = (line) => console.log(line), +} = {}) { + const siteUrl = String(env.INFISICAL_SITE_URL || 'https://app.infisical.com').replace(/\/+$/, ''); + const projectId = env.INFISICAL_PROJECT_ID || ''; + const environment = env.INFISICAL_ENVIRONMENT || 'prod'; + const secretPath = env.INFISICAL_SECRET_PATH || SECRET_PATH; + const clientId = env.INFISICAL_CLIENT_ID || ''; + const clientSecret = env.INFISICAL_CLIENT_SECRET || ''; + + if (!projectId || !clientId || !clientSecret) { + fail(log, 'Missing the Infisical connection settings (INFISICAL_PROJECT_ID, INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET); the CI identity cannot reach Fleet-OPS prod /asc.'); + } + + const token = await loginUniversalAuth({ siteUrl, clientId, clientSecret, fetchImpl }); + const secrets = await listSecretsRaw({ + siteUrl, token, projectId, environment, secretPath, fetchImpl, + }); + + const keyId = secrets.get('ASC_KEY_ID') || ''; + const issuerId = secrets.get('ASC_ISSUER_ID') || ''; + const key = secrets.get('ASC_KEY_P8') || ''; + + if (!key.trim()) { + fail(log, 'Missing ASC_KEY_P8 in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc.'); } + if (!keyId.trim() || !issuerId.trim()) { + fail(log, 'Missing ASC_KEY_ID or ASC_ISSUER_ID in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc.'); + } + + // Mask every line of the key, so the runner scrubs it from the log from + // this point on even if something later prints it. + maskValue(key, log); + // Stream the key into the staging script over its standard input, so it + // appears in no process listing and in no environment entry. The script + // writes the mode-600 file and publishes only its path. await new Promise((resolve, reject) => { const child = spawn('bash', [STAGING_SCRIPT], { stdio: ['pipe', 'inherit', 'inherit'], - env: { ...process.env, ...env }, + env: childEnv(env), }); child.on('error', reject); child.on('close', (code) => { @@ -50,14 +98,12 @@ export async function run({ env = process.env, log = (line) => console.log(line) else reject(new Error(`The ASC key staging script exited ${code}.`)); }); child.stdin.on('error', reject); - child.stdin.end(value); + child.stdin.end(key); }); - // Take the key back out of the environment. The runner applies these - // entries in order, so the empty value is the one later steps see. - if (env.GITHUB_ENV) { - appendFileSync(env.GITHUB_ENV, `${SECRET_NAME}=\n`); - } + // The identifiers cross into the later steps. The key never does. + writeGithubEnvValue(env.GITHUB_ENV, 'ASC_KEY_ID', keyId); + writeGithubEnvValue(env.GITHUB_ENV, 'ASC_ISSUER_ID', issuerId); } const invokedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; diff --git a/scripts/stage-asc-key.node-test.mjs b/scripts/stage-asc-key.node-test.mjs index 387004b3..25f1ec7f 100644 --- a/scripts/stage-asc-key.node-test.mjs +++ b/scripts/stage-asc-key.node-test.mjs @@ -2,13 +2,15 @@ // This drives scripts/stage-asc-key.mjs with the real // scripts/ios-stage-asc-key.sh, and checks the file it writes. // -// The key is fetched by the infisical-secrets composite action, which is -// this repository's approved way to read a value out of the vault. This -// script does not talk to the vault, so there is nothing to stub here. +// The key is read from the vault by the script itself, so the test stubs the +// Infisical HTTP API rather than putting a key in the environment. That is +// the point of the contract: a key in the environment is the leak this +// script exists to prevent. // // Run: node --test scripts/stage-asc-key.node-test.mjs import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -18,15 +20,41 @@ import { fileURLToPath } from 'node:url'; import { run } from './stage-asc-key.mjs'; const KEY = "-----BEGIN PRIVATE KEY-----\nSYNTHETIC-MULTILINE-FIXTURE-DO-NOT-USE\n-----END PRIVATE KEY-----"; +const VAULT = { + ASC_KEY_ID: 'STUBKEYID', + ASC_ISSUER_ID: 'STUBISSUER', + ASC_KEY_P8: KEY, +}; + +/** A stub of the two Infisical endpoints the script calls. */ +function vaultFetch(values = VAULT) { + return async (url) => { + if (url.includes('/auth/universal-auth/login')) { + return { ok: true, status: 200, json: async () => ({ accessToken: 'stub-token' }) }; + } + if (url.includes('/api/v3/secrets/raw')) { + return { + ok: true, + status: 200, + json: async () => ({ + secrets: Object.entries(values).map(([secretKey, secretValue]) => ({ secretKey, secretValue })), + }), + }; + } + throw new Error(`the script fetched an unexpected url: ${url}`); + }; +} function baseEnv(dir, overrides = {}) { // The staging script makes its own directory inside the staging root, so // the root has to exist first. mkdirSync(join(dir, "staging"), { recursive: true }); return { - ASC_KEY_P8: KEY, GITHUB_ENV: join(dir, "env"), ASC_STAGING_ROOT: join(dir, "staging"), + INFISICAL_PROJECT_ID: 'stub-project', + INFISICAL_CLIENT_ID: 'stub-client', + INFISICAL_CLIENT_SECRET: 'stub-secret', ...overrides, }; } @@ -40,7 +68,7 @@ test("the key is staged to a mode-600 file and only its path is published", asyn try { const env = baseEnv(dir); const lines = []; - await run({ env, log: (l) => lines.push(l) }); + await run({ env, fetchImpl: vaultFetch(), log: (l) => lines.push(l) }); const written = readFileSync(env.GITHUB_ENV, "utf8"); const match = written.match(/^ASC_KEY_PATH=(.*)$/m); @@ -51,23 +79,46 @@ test("the key is staged to a mode-600 file and only its path is published", asyn assert.equal(mode(join(keyPath, "..")), 0o700, "the key directory must be mode 700"); assert.ok(readFileSync(keyPath, "utf8").includes("SYNTHETIC-MULTILINE-FIXTURE")); + // The key may appear in the log only as an add-mask command, which the + // runner consumes to register the value for redaction. Any other line + // carrying the key would be a leak. const leaked = lines.filter((l) => l.includes("SYNTHETIC-MULTILINE-FIXTURE")); - assert.deepEqual(leaked, [], "the key must never reach the log"); + assert.ok(leaked.length > 0, "the key must be registered with the runner for redaction"); + for (const line of leaked) { + assert.ok(line.startsWith("::add-mask::"), + `the key may only appear in an add-mask command, not in ${JSON.stringify(line)}`); + } } finally { rmSync(dir, { recursive: true, force: true }); } }); -test("the key is taken back out of the environment for later steps", async () => { +test("the key is never in the environment, not even as a leftover entry", async () => { const dir = mkdtempSync(join(tmpdir(), "asc-stage-")); try { const env = baseEnv(dir); - await run({ env, log: () => {} }); + await run({ env, fetchImpl: vaultFetch(), log: () => {} }); + const written = readFileSync(env.GITHUB_ENV, "utf8"); + assert.ok(!/ASC_KEY_P8/.test(written), "no ASC_KEY_P8 entry may reach GITHUB_ENV at all"); + // The identifiers do cross, because the ship and notarize steps need them. + assert.match(written, /ASC_KEY_ID/); + assert.match(written, /ASC_ISSUER_ID/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("a key in the environment is ignored, the vault is the only source", async () => { + const dir = mkdtempSync(join(tmpdir(), "asc-stage-")); + try { + const env = baseEnv(dir, { ASC_KEY_P8: "DECOY-FROM-THE-ENVIRONMENT" }); + await run({ env, fetchImpl: vaultFetch(), log: () => {} }); const written = readFileSync(env.GITHUB_ENV, "utf8"); - assert.ok(written.includes("ASC_KEY_P8=\n"), "the environment entry must be blanked"); - // The blanking comes after the path, so the runner applies it last. - assert.ok(written.indexOf("ASC_KEY_P8=\n") > written.indexOf("ASC_KEY_PATH="), - "the blanking must come after the path so that it wins"); + assert.ok(!written.includes("DECOY-FROM-THE-ENVIRONMENT"), "the environment copy must never be used"); + const keyPath = written.match(/^ASC_KEY_PATH=(.*)$/m)[1].trim(); + const staged = readFileSync(keyPath, "utf8"); + assert.ok(staged.includes("SYNTHETIC-MULTILINE-FIXTURE"), "the vault copy is the one that is staged"); + assert.ok(!staged.includes("DECOY-FROM-THE-ENVIRONMENT"), "the environment copy must never be staged"); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -78,7 +129,7 @@ test("a missing key fails the step and names the secret and its path", async () try { const lines = []; await assert.rejects( - run({ env: baseEnv(dir, { ASC_KEY_P8: "" }), log: (l) => lines.push(l) }), + run({ env: baseEnv(dir), fetchImpl: vaultFetch({ ASC_KEY_ID: 'x', ASC_ISSUER_ID: 'y' }), log: (l) => lines.push(l) }), (err) => { assert.match(err.message, /Missing ASC_KEY_P8 in Fleet-OPS prod \/asc/); return true; @@ -91,15 +142,16 @@ test("a missing key fails the step and names the secret and its path", async () } }); -test("the key never reaches a command line", async () => { +test("a missing Infisical connection fails before anything is staged", async () => { const dir = mkdtempSync(join(tmpdir(), "asc-stage-")); try { - const env = baseEnv(dir); - await run({ env, log: () => {} }); - // The staging script's own contract: the key is on its standard input, - // and the published file holds the key, not an argument. - const keyPath = readFileSync(env.GITHUB_ENV, "utf8").match(/^ASC_KEY_PATH=(.*)$/m)[1].trim(); - assert.ok(readFileSync(keyPath, "utf8").includes("SYNTHETIC-MULTILINE-FIXTURE")); + await assert.rejects( + run({ env: baseEnv(dir, { INFISICAL_CLIENT_SECRET: '' }), fetchImpl: vaultFetch(), log: () => {} }), + (err) => { + assert.match(err.message, /Missing the Infisical connection settings/); + return true; + }, + ); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -107,15 +159,40 @@ test("the key never reaches a command line", async () => { // CI only ever runs `node scripts/stage-asc-key.mjs`, so the CLI entry point // -- the invokedDirectly guard and the process.env lookup -- is the path that -// ships. The tests above import run(), which cannot reach it. -test("the CLI entry point stages the key, publishes the path and blanks the environment", () => { +// ships. The tests above import run(), which cannot reach it. The CLI +// talks to a real HTTP endpoint, so this test stands up a stub vault. +// +// The stub server lives in this process, so the child must be spawned +// asynchronously: a synchronous spawn would block this process's event loop +// and the server could never answer the child's request. +test("the CLI entry point stages the key from the vault and publishes only the path", async () => { const dir = mkdtempSync(join(tmpdir(), "asc-stage-cli-")); + const server = createServer((req, res) => { + const body = req.url.includes('/auth/universal-auth/login') + ? { accessToken: 'stub-token' } + : { secrets: Object.entries(VAULT).map(([secretKey, secretValue]) => ({ secretKey, secretValue })) }; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); try { const env = baseEnv(dir); const script = fileURLToPath(new URL("./stage-asc-key.mjs", import.meta.url)); - const result = spawnSync(process.execPath, [script], { - env: { ...process.env, ...env }, - encoding: "utf8", + const result = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [script], { + env: { + ...process.env, + ...env, + INFISICAL_SITE_URL: `http://127.0.0.1:${port}`, + }, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', reject); + child.on('close', (status) => resolve({ status, stdout, stderr })); }); assert.equal(result.status, 0, `the CLI must succeed: ${result.stderr}`); @@ -125,16 +202,18 @@ test("the CLI entry point stages the key, publishes the path and blanks the envi const keyPath = match[1].trim(); assert.equal(mode(keyPath), 0o600, "the key file must be mode 600"); assert.ok(readFileSync(keyPath, "utf8").includes("SYNTHETIC-MULTILINE-FIXTURE")); - - assert.ok(written.includes("ASC_KEY_P8=\n"), "the CLI must blank the environment entry"); - assert.ok(written.indexOf("ASC_KEY_P8=\n") > written.indexOf("ASC_KEY_PATH="), - "the blanking must come after the path so that it wins"); + assert.ok(!written.includes("SYNTHETIC-MULTILINE-FIXTURE"), "the key must never reach GITHUB_ENV"); + assert.ok(!/ASC_KEY_P8/.test(written), "no ASC_KEY_P8 entry may reach GITHUB_ENV at all"); for (const stream of ["stdout", "stderr"]) { - assert.ok(!result[stream].includes("SYNTHETIC-MULTILINE-FIXTURE"), - `the key must never reach ${stream}`); + const offending = String(result[stream] || "") + .split("\n") + .filter((l) => l.includes("SYNTHETIC-MULTILINE-FIXTURE")) + .filter((l) => !l.startsWith("::add-mask::")); + assert.deepEqual(offending, [], `the key must never reach ${stream}`); } } finally { + server.close(); rmSync(dir, { recursive: true, force: true }); } }); From 53ac0d5b537f27030e67857c593e7f460b3856de Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:02:02 -0500 Subject: [PATCH 23/39] fix(pip): do not rank a failed provider by its cached quota The default target set ranked a row by the quota it last reported, and the helper that did it never passed the provider's issue, so a provider whose refresh had failed was ordered by a cached reading the panel has already declared untrustworthy. The cached number then decided which failed row landed in the HUD and how it ranked against a live row. The rule is now a named helper, PipWidgetController.rankingPercent, which passes the row's issue into currentWindows, so a failed provider has no current windows and scores 100 like any other unreadable row. A new test, testAFailedProviderIsNotRankedByItsCachedQuota, pins it. Refs CC-181 --- Sources/CodeCaps/PipWidget.swift | 14 +++++++++++-- .../PipTargetSelectionTests.swift | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Sources/CodeCaps/PipWidget.swift b/Sources/CodeCaps/PipWidget.swift index 3417338d..b0b25626 100644 --- a/Sources/CodeCaps/PipWidget.swift +++ b/Sources/CodeCaps/PipWidget.swift @@ -451,8 +451,8 @@ final class PipWidgetController: NSObject, NSWindowDelegate { // failed. A pinned failed row still shows, because the user asked for // it and it reports its status instead of a cached quota. func remainingPercent(_ row: DisplaySection) -> Double { - (currentWindows(for: row, now: model.now, disabledSources: model.disabledSources) - .compactMap(\.remainingPercent).min() ?? 100) + rankingPercent(for: row, now: model.now, issue: model.issues[row.providerKey], + disabledSources: model.disabledSources) } func isMeasurable(_ row: DisplaySection) -> Bool { readingStatus(for: row, now: model.now, issue: model.issues[row.providerKey], @@ -491,6 +491,16 @@ final class PipWidgetController: NSObject, NSWindowDelegate { && (!$0.window.skip || $0.remainingPercent == 0) } } + /// The percentage a row may be ranked by when the HUD picks its default + /// targets. A row whose provider failed has no current reading, so it + /// ranks as unreadable rather than by a cached quota the panel has already + /// declared untrustworthy. + static func rankingPercent(for row: DisplaySection, now: Date, issue: String? = nil, + disabledSources: Set = []) -> Double { + (currentWindows(for: row, now: now, issue: issue, disabledSources: disabledSources) + .compactMap(\.remainingPercent).min() ?? 100) + } + static func readingStatus(for row: DisplaySection, now: Date, issue: String? = nil, disabledSources: Set = []) -> String? { if let issue { return glanceRowStatusText(issue: issue, hasWindows: !row.section.windows.isEmpty) } diff --git a/Tests/CodeCapsTests/PipTargetSelectionTests.swift b/Tests/CodeCapsTests/PipTargetSelectionTests.swift index c5dee681..98d96e20 100644 --- a/Tests/CodeCapsTests/PipTargetSelectionTests.swift +++ b/Tests/CodeCapsTests/PipTargetSelectionTests.swift @@ -47,4 +47,24 @@ final class PipTargetSelectionTests: XCTestCase { XCTAssertEqual(PipWidgetController.meterCount(for: row, now: model.now, issue: model.issues[row.providerKey]), 0) } + + /// A failed provider may not be ranked by the quota it last reported. That + /// cached number is exactly what the panel refuses to draw, so letting it + /// order the HUD would make the order depend on a reading the panel calls + /// untrustworthy. + func testAFailedProviderIsNotRankedByItsCachedQuota() throws { + let (model, defaults, suite) = GlanceFixtures.makeModel(view: .fromMac, alarmsAll: false, fleet: false) + defer { defaults.removePersistentDomain(forName: suite) } + let windows = [GlanceFixtures.window("anthropic:5h", provider: "anthropic", label: "5-hour window", + token: "5h", remaining: 4, resetIn: 3_600)] + model.injectForTests(sections: QuotaResponse(generatedAt: "", windows: windows) + .platformSections(now: GlanceFixtures.now), now: GlanceFixtures.now, + issues: ["anthropic": "Helper failed"]) + let row = try XCTUnwrap(model.displaySections.first { $0.providerKey == "anthropic" }) + // The provider failed, so the row has no reading and ranks as unreadable. + XCTAssertEqual(PipWidgetController.rankingPercent(for: row, now: model.now, + issue: model.issues[row.providerKey]), 100) + // The same row without the failure still ranks by its live reading. + XCTAssertEqual(PipWidgetController.rankingPercent(for: row, now: model.now), 4) + } } From b863e1acdf76dc07bfc4f616d7ef66090729ddbb Mon Sep 17 00:00:00 2001 From: opencode Date: Wed, 21 Oct 2026 09:38:33 +0000 Subject: [PATCH 24/39] fix(release): harden ASC staging and wire APNs tests into ship contract The stage-asc-key loader now rejects unresolved Infisical references, validates the vault Map shape by hand, and masks the key identifiers. The iOS ship contract runs the APNs provisioning tests. --- .github/workflows/ios-ship-contract.yml | 2 +- scripts/stage-asc-key.mjs | 33 ++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ios-ship-contract.yml b/.github/workflows/ios-ship-contract.yml index e90f49f7..6503a354 100644 --- a/.github/workflows/ios-ship-contract.yml +++ b/.github/workflows/ios-ship-contract.yml @@ -46,6 +46,6 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Pin the auto-ship wiring run: | - node --test scripts/ios-ship-workflow.test.mjs scripts/stage-asc-key.node-test.mjs scripts/infisical-fetch-expand.node-test.mjs + node --test scripts/ios-ship-workflow.test.mjs scripts/stage-asc-key.node-test.mjs scripts/infisical-fetch-expand.node-test.mjs scripts/ios-enable-apns.test.mjs bash scripts/ios-fleet/test-asc-credentials.sh node --test scripts/ios-fleet/test-asc-credentials.mjs diff --git a/scripts/stage-asc-key.mjs b/scripts/stage-asc-key.mjs index b114c392..93792ca6 100644 --- a/scripts/stage-asc-key.mjs +++ b/scripts/stage-asc-key.mjs @@ -18,6 +18,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { + isUnresolvedReference, listSecretsRaw, loginUniversalAuth, maskValue, @@ -65,14 +66,36 @@ export async function run({ } const token = await loginUniversalAuth({ siteUrl, clientId, clientSecret, fetchImpl }); - const secrets = await listSecretsRaw({ + const rawSecrets = await listSecretsRaw({ siteUrl, token, projectId, environment, secretPath, fetchImpl, }); + // The vault response crosses a trust boundary: it is external data shaped + // as a Map of strings. zod is not vendored in this dependency-free + // scripts tree, so validate the shape by hand and fail closed rather + // than letting a malformed entry flow into credential handling. + if (!(rawSecrets instanceof Map)) { + fail(log, 'The Infisical secrets response had an unexpected shape; refusing to stage ASC credentials.'); + } + for (const [name, value] of rawSecrets) { + if (typeof name !== 'string' || typeof value !== 'string') { + fail(log, 'The Infisical secrets response had an unexpected shape; refusing to stage ASC credentials.'); + } + } + const secrets = rawSecrets; + const keyId = secrets.get('ASC_KEY_ID') || ''; const issuerId = secrets.get('ASC_ISSUER_ID') || ''; const key = secrets.get('ASC_KEY_P8') || ''; + // A value that is still a bare ${...} reference means Infisical did not + // expand it (missing target or the CI identity cannot read the referenced + // folder). Staging it would defer a clear access failure until notarytool + // reports an opaque parse error, so fail here with the actionable cause. + if ([keyId, issuerId, key].some(isUnresolvedReference)) { + fail(log, 'An ASC value in Fleet-OPS prod /asc is still an unresolved Infisical secret reference; check the target exists and the CI identity can read the referenced folder.'); + } + if (!key.trim()) { fail(log, 'Missing ASC_KEY_P8 in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc.'); } @@ -80,8 +103,12 @@ export async function run({ fail(log, 'Missing ASC_KEY_ID or ASC_ISSUER_ID in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc.'); } - // Mask every line of the key, so the runner scrubs it from the log from - // this point on even if something later prints it. + // Mask every line of every value that crosses into later steps, so the + // runner scrubs them from the log from this point on even if something + // later prints them. The key is the sensitive material; the identifiers + // are masked too so a later notarytool or altool echo cannot expose them. + maskValue(keyId, log); + maskValue(issuerId, log); maskValue(key, log); // Stream the key into the staging script over its standard input, so it From 92b1cfd3f4292985f26736f280998f4b3b3a2282 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:09:30 -0500 Subject: [PATCH 25/39] fix(release): resolve the ASC credentials environment-first, from the approved loader Kody's review of 9dd5962 raised six findings, and two of them pull in opposite directions: the approved-sources rule wants every credential resolved from the environment before anything else, while the environment-boundary finding wants the private key out of the job environment entirely. This commit settles both. The retrieval goes back to the approved loader, scripts/infisical-fetch.mjs, which is the script the infisical-secrets composite action runs and stays byte-identical to the BotFleet copy. The loader and the staging script now run in ONE step: the loader writes the three values into the job's environment file, the staging script reads them back out of it, and the private key is streamed over stdin into a mode-600 file. The key is therefore never an environment variable of any step, which is the boundary JET asked to close, and it reaches no later step either, because the ASC_KEY_P8 entry is blanked. scripts/stage-asc-key.mjs resolves every credential environment-first -- process.env, then the job's environment file -- and then through an injected getter whose default implementation is the approved fleet-secrets fallback. The script holds no vault client of its own, so there is no unvalidated vault response for it to trust, and it fails with the name of the missing credential. An unresolved Infisical reference is rejected before anything is staged. The two identifiers are masked before they are published; the key is not masked, because it never reaches the log. The test now stubs the injected getter instead of the vault's HTTP endpoint, and asserts that zero key-bearing lines reach the log, reporting only the count. The contract tests lock the new shape: one step, the approved loader named rather than the vault helpers, and the repository's stale ASC_* secrets still never read. Fixes CC-177 --- .github/workflows/ios-ship.yml | 24 ++- .github/workflows/mac-release.yml | 37 +++- docs/IOS-TESTFLIGHT.md | 2 +- scripts/ios-ship-workflow.test.mjs | 17 +- scripts/mac-release-workflow.test.mjs | 47 +++-- scripts/stage-asc-key.mjs | 236 +++++++++++++--------- scripts/stage-asc-key.node-test.mjs | 270 +++++++++++++------------- 7 files changed, 371 insertions(+), 262 deletions(-) diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index 87574ff1..a2009e14 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -23,9 +23,9 @@ name: iOS TestFlight ship (GitHub-hosted macOS) # copy that App Store Connect answers with HTTP 401 (CC-163), so they are # deliberately not read here and there is no fallback to them. # * The multiline key never enters the step environment. -# scripts/stage-asc-key.mjs reads the vault itself, streams the key over -# stdin into a mode-600 file and publishes only that file's path, so the -# key material crosses no step boundary. +# The approved loader and scripts/stage-asc-key.mjs run in one step, so the +# key goes from the job's environment file into a mode-600 file, and only +# that file's path and the two identifiers cross a step boundary. # * IOS_DIST_P12_BASE64 / IOS_DIST_P12_PASSWORD (Apple Distribution identity) # stay repository secrets. # * SENTRY_DSN and SENTRY_AUTH_TOKEN are best-effort: an inert Sentry beats a @@ -215,14 +215,28 @@ jobs: # 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. - - name: Stage the ASC private key (Fleet-OPS prod /asc) - run: node scripts/stage-asc-key.mjs + # + # The approved loader (scripts/infisical-fetch.mjs, the script the + # infisical-secrets composite action runs) and the staging script run + # in ONE step on purpose. The loader writes the three values into the + # job's environment file; the staging script reads them back out of it + # and writes the private key to a mode-600 file. Split across two + # steps the key would be an environment variable of the second one, + # which is the boundary this step exists to close. + - name: Load and stage the ASC credentials (Fleet-OPS prod /asc) env: + INFISICAL_NAMES: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 + INFISICAL_REQUIRED: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} INFISICAL_ENVIRONMENT: prod INFISICAL_SECRET_PATH: /asc INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} + run: | + set +o xtrace + set -euo pipefail + node scripts/infisical-fetch.mjs + node scripts/stage-asc-key.mjs - name: Check the ASC credentials arrived run: | diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index 1593f047..f55112a1 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -14,9 +14,10 @@ name: Mac Release (Sparkle) # ASC_KEY_ID, ASC_ISSUER_ID and the App Store Connect private key come from # Fleet-OPS Infisical prod /asc, matching ios-ship.yml. Never fall back # to the stale repository ASC_* secrets. The private key never enters -# the job environment: scripts/stage-asc-key.mjs reads it from the vault -# itself, streams it over stdin into a mode-600 file and publishes only -# that file's path. +# the job environment: the approved loader and scripts/stage-asc-key.mjs +# run in one step, so the key goes from the job's environment file into +# a mode-600 file, and only that file's path and the two identifiers +# cross a step boundary. # SPARKLE_ED_PRIVATE_KEY The EdDSA key that signs each update archive # (Sparkle's generate_keys -x output). # Pull requests build without credentials and never publish. A main release @@ -131,15 +132,28 @@ jobs: GH_FALLBACK_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} GH_FALLBACK_SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - - name: Stage the ASC private key (Fleet-OPS prod /asc) + # The approved loader (scripts/infisical-fetch.mjs, the script the + # infisical-secrets composite action runs) and the staging script run + # in ONE step on purpose. The loader writes the three values into the + # job's environment file; the staging script reads them back out of it + # and writes the private key to a mode-600 file. Split across two + # steps the key would be an environment variable of the second one, + # which is the boundary this step exists to close. + - name: Load and stage the ASC credentials (Fleet-OPS prod /asc) if: github.event_name != 'pull_request' - run: node scripts/stage-asc-key.mjs env: + INFISICAL_NAMES: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 + INFISICAL_REQUIRED: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} INFISICAL_ENVIRONMENT: prod INFISICAL_SECRET_PATH: /asc INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} + run: | + set +o xtrace + set -euo pipefail + node scripts/infisical-fetch.mjs + node scripts/stage-asc-key.mjs - name: Check the ASC credentials arrived if: github.event_name != 'pull_request' @@ -362,14 +376,23 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Stage the ASC private key (Fleet-OPS prod /asc) - run: node scripts/stage-asc-key.mjs + # The loader and the staging script run in ONE step, so the private key + # never becomes an environment variable of a step. See the release + # job's step of the same name for the full reason. + - name: Load and stage the ASC credentials (Fleet-OPS prod /asc) env: + INFISICAL_NAMES: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 + INFISICAL_REQUIRED: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} INFISICAL_ENVIRONMENT: prod INFISICAL_SECRET_PATH: /asc INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} + run: | + set +o xtrace + set -euo pipefail + node scripts/infisical-fetch.mjs + node scripts/stage-asc-key.mjs - name: Check the ASC credentials arrived run: | diff --git a/docs/IOS-TESTFLIGHT.md b/docs/IOS-TESTFLIGHT.md index 9b7d91ee..f443c915 100644 --- a/docs/IOS-TESTFLIGHT.md +++ b/docs/IOS-TESTFLIGHT.md @@ -28,7 +28,7 @@ Marketing and build both come from the repo's commit count: `1.0.` ## When A Build Is Missing 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. `Stage the ASC private key (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. +2. A failed run names its step. `Load and stage the 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 (Retired From TestFlight) diff --git a/scripts/ios-ship-workflow.test.mjs b/scripts/ios-ship-workflow.test.mjs index e8f9447e..45ec2d30 100644 --- a/scripts/ios-ship-workflow.test.mjs +++ b/scripts/ios-ship-workflow.test.mjs @@ -176,15 +176,22 @@ test("ASC credentials come from Fleet-OPS /asc, never the stale repository secre // No new code may talk to the vault directly. assert.doesNotMatch(code, /listSecretsRaw|loginUniversalAuth/); - // The multiline key never enters the step environment. The loader reads - // the vault itself and streams the key over stdin into a mode-600 file, so - // only the file's path crosses into the later steps and the workflow never - // names the private key. + // The multiline key never enters the step environment. The approved + // loader and the staging script run in ONE step: the loader writes the + // three values into the job's environment file, the staging script reads + // them back out of it, streams the key over stdin into a mode-600 file + // and blanks the ASC_KEY_P8 entry, so only the file's path and the two + // identifiers cross into the later steps. assert.match(code, /scripts\/stage-asc-key\.mjs/); + assert.match(code, /node scripts\/infisical-fetch\.mjs[\s\S]*node scripts\/stage-asc-key\.mjs/); + assert.match(code, /INFISICAL_NAMES: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8/); + assert.match(code, /INFISICAL_REQUIRED: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8/); assert.match(readFileSync(join(ROOT, "scripts/stage-asc-key.mjs"), "utf8"), /ios-stage-asc-key\.sh/); + assert.match(readFileSync(join(ROOT, "scripts/stage-asc-key.mjs"), "utf8"), /appendFileSync\(env\.GITHUB_ENV, 'ASC_KEY_P8=/); assert.doesNotMatch(code, /for name in .*ASC_KEY_P8/); assert.doesNotMatch(code, /printf '%s=%s\\n' "ASC_KEY_P8"/); - assert.doesNotMatch(code, /ASC_KEY_P8/); + // The workflow never writes the key into an environment entry itself. + assert.doesNotMatch(code, /ASC_KEY_P8:\s*\$\{\{/); // The Distribution identity stays a repository secret, scoped to its step. assert.match(code, /IOS_DIST_P12_BASE64:\s*\$\{\{ secrets\.IOS_DIST_P12_BASE64 \}\}/); diff --git a/scripts/mac-release-workflow.test.mjs b/scripts/mac-release-workflow.test.mjs index 3cf2c4ec..e8b26760 100644 --- a/scripts/mac-release-workflow.test.mjs +++ b/scripts/mac-release-workflow.test.mjs @@ -17,7 +17,7 @@ function step(s, name) { return s.slice(start, end < 0 ? undefined : end).trim(); } const LOADERS = [ - 'Stage the ASC private key (Fleet-OPS prod /asc)', + 'Load and stage the ASC credentials (Fleet-OPS prod /asc)', 'Check the ASC credentials arrived', ]; const stripGuard = s => s.replaceAll(" if: github.event_name != 'pull_request'\n", ''); @@ -58,18 +58,26 @@ test('Sparkle uses the existing fleet ASC loader, never repository ASC secrets', } assert.match(loader, /INFISICAL_PROJECT_ID: \$\{\{ secrets\.INFISICAL_FLEET_OPS_PROJECT_ID \}\}/); assert.doesNotMatch(loader, /echo .*\$\{ASC_INFISICAL_PROJECT_ID\}/); - // The key material must not enter the job environment. The loader reads - // the vault itself, so the workflow never asks the composite action for the - // private key and never names it: only the file's path crosses a step - // boundary. A name here would put the key in GITHUB_ENV, where every - // later step, every process listing and every environment dump can read - // it. + // The key material must not enter the job environment. The approved + // loader and the staging script run in ONE step: the loader writes the + // three values into the job's environment file, the staging script reads + // them back out of it and writes the private key to a mode-600 file. Split + // across two steps the key would be an environment variable of the + // second one, where every later step, every process listing and every + // environment dump can read it. assert.doesNotMatch(loader, /infisical-secrets/); - assert.doesNotMatch(loader, /ASC_KEY_P8/); - assert.doesNotMatch(loader, /names: ASC_/); + assert.doesNotMatch(loader, /uses: \.\/\.github\/actions/); + // The vault is reached by the approved loader, in the same step that + // stages the key. + assert.match(loader, /run: \|[\s\S]*node scripts\/infisical-fetch\.mjs[\s\S]*node scripts\/stage-asc-key\.mjs/); + // The loader is asked for exactly the three ASC names, and all three are + // required, so a vault that cannot serve them fails the step by name. + assert.match(loader, /INFISICAL_NAMES: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8/); + assert.match(loader, /INFISICAL_REQUIRED: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8/); + // The repository's stale ASC_* secrets are never read. for (const file of [code, iosCode]) { - assert.doesNotMatch(file, /ASC_KEY_P8/); - assert.doesNotMatch(file, /names: ASC_/); + assert.doesNotMatch(file, /secrets\.ASC_/); + assert.doesNotMatch(file, /ASC_KEY_P8: \$\{\{/); } // The loader is told where the vault is, and only where. assert.match(loader, /INFISICAL_ENVIRONMENT: prod/); @@ -82,7 +90,7 @@ test('Sparkle uses the existing fleet ASC loader, never repository ASC secrets', assert.doesNotMatch(file, /infisical secrets get/); assert.doesNotMatch(file, /infisical\/get-cli/); } - assert.match(loader, /run: node scripts\/stage-asc-key\.mjs/); + assert.match(loader, /node scripts\/stage-asc-key\.mjs/); assert.match(loader, /Missing \$name in Fleet-OPS prod \/asc; verify the CI identity has read access to Fleet-OPS prod \/asc\./); assert.match(loader, /Missing ASC key material in Fleet-OPS prod \/asc; verify the CI identity has read access to Fleet-OPS prod \/asc\./); assert.match(code, /NOTARY_KEY_PATH: \$\{\{ env.ASC_KEY_PATH \}\}/); @@ -122,6 +130,21 @@ test('PRs cannot notarize or publish, and icon-only changes trigger releases', ( assert.doesNotMatch(file, /listSecretsRaw|loginUniversalAuth/); } assert.match(read('scripts/stage-asc-key.mjs'), /from '\.\/infisical-fetch\.mjs'/); + // The staging script resolves every credential environment-first and then + // through an injected getter, so the job's own environment is looked up + // before anything else, the fallback is stubbed at its seam rather than at + // a network endpoint, and the ASC_KEY_P8 entry is blanked so that no later + // step can read the key from the environment. The script holds no vault + // client of its own. + const stage = read('scripts/stage-asc-key.mjs'); + assert.match(stage, /env\[name\]/); + assert.match(stage, /getSecret = fleetSecretsGet/); + assert.match(stage, /appendFileSync\(env\.GITHUB_ENV, 'ASC_KEY_P8=/); + assert.match(stage, /isUnresolvedReference/); + assert.match(stage, /maskValue\(keyId, log\)/); + assert.match(stage, /maskValue\(issuerId, log\)/); + assert.doesNotMatch(stage, /listSecretsRaw|loginUniversalAuth/); + assert.doesNotMatch(stage, /maskValue\(key, log\)/); assert.match(code, /needs.await-swift-ci.result == 'success'/); assert.match(code, /run: script\/build_and_run.sh --release/); assert.ok(code.indexOf('Verify notarization access') < code.indexOf('Build, sign, notarize')); diff --git a/scripts/stage-asc-key.mjs b/scripts/stage-asc-key.mjs index 93792ca6..44095504 100644 --- a/scripts/stage-asc-key.mjs +++ b/scripts/stage-asc-key.mjs @@ -1,44 +1,52 @@ -// Stage the App Store Connect private key from Fleet-OPS Infisical prod /asc -// into a mode-600 file, and publish only the file's path. -// -// The key material never enters the environment. It is read from the vault -// in this process, streamed over stdin into scripts/ios-stage-asc-key.sh, and -// written to a mode-600 file. Only ASC_KEY_PATH crosses the step boundary, -// so no later step, no process listing and no environment dump can carry the -// key. -// -// ASC_KEY_ID and ASC_ISSUER_ID are identifiers rather than key material, and -// the ship and notarize steps need them, so they are published as ordinary -// environment entries. The repository's stale ASC_* secrets are never read. -// -// Run: node scripts/stage-asc-key.mjs -import { spawn } from 'node:child_process'; -import { appendFileSync } from 'node:fs'; +#!/usr/bin/env node +/** + * Stage the App Store Connect private key into the protected key-file + * handoff, and publish only what the later steps need. + * + * Each of ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 is resolved in this + * order: + * + * 1. the environment. process.env comes first, then the job's + * environment file -- the file the runner reads the $GITHUB_ENV + * entries from. A named variable always wins, so the job's own + * environment is the first place a credential is looked up. + * 2. the approved fallback: `fleet-secrets get --path /asc`, + * which reads Fleet-OPS Infisical prod /asc on a developer machine. + * The getter is injected, so a test stubs the getter rather than a + * network endpoint. + * + * The private key never crosses a step boundary. It is streamed over + * stdin into scripts/ios-stage-asc-key.sh, which writes a mode-600 file + * and publishes only its path, and the ASC_KEY_P8 entry is then blanked so + * that no later step can read the key from the environment. Only the two + * identifiers are published, and they are masked before they are written. + * + * This script does not talk to the vault. The approved loader + * (scripts/infisical-fetch.mjs, byte-identical to the BotFleet copy, the + * script the infisical-secrets composite action runs) does that, and the + * values it exports are read here through the environment. Keeping the + * retrieval in one place is what lets the key stay out of every step's + * environment: the loader and this script run in the same step, so the + * key never becomes an environment variable of any step. + * + * Run: node scripts/stage-asc-key.mjs + */ +import { spawn, execFileSync } from 'node:child_process'; +import { appendFileSync, existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - isUnresolvedReference, - listSecretsRaw, - loginUniversalAuth, - maskValue, - writeGithubEnvValue, -} from './infisical-fetch.mjs'; +import { isUnresolvedReference, maskValue, writeGithubEnvValue } from './infisical-fetch.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const STAGING_SCRIPT = join(here, 'ios-stage-asc-key.sh'); -const SECRET_PATH = '/asc'; - -function fail(log, message) { - log(`::error::${message}`); - throw new Error(message); -} +const SECRET_FOLDER = '/asc'; +const CREDENTIALS = ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_KEY_P8']; -/** The environment the staging script needs, and nothing else. - * - * The Infisical client credentials and the key itself stay out of the child - * process, so the only thing the staging script can see is the key arriving - * on its standard input. */ +/** The environment the staging script needs, and nothing else. Neither the + * key nor the vault credentials reach the child process, so it appears in + * no process listing and in no environment entry. */ function childEnv(env) { const keep = ['PATH', 'GITHUB_ENV', 'RUNNER_TEMP', 'TMPDIR', 'ASC_STAGING_ROOT']; const out = {}; @@ -49,94 +57,136 @@ function childEnv(env) { return out; } -export async function run({ - env = process.env, - fetchImpl = fetch, - log = (line) => console.log(line), -} = {}) { - const siteUrl = String(env.INFISICAL_SITE_URL || 'https://app.infisical.com').replace(/\/+$/, ''); - const projectId = env.INFISICAL_PROJECT_ID || ''; - const environment = env.INFISICAL_ENVIRONMENT || 'prod'; - const secretPath = env.INFISICAL_SECRET_PATH || SECRET_PATH; - const clientId = env.INFISICAL_CLIENT_ID || ''; - const clientSecret = env.INFISICAL_CLIENT_SECRET || ''; +/** Read the job's environment file: the entries a step wrote through + * $GITHUB_ENV, which the runner applies to the steps that follow. Both + * the single-line form (`NAME=value`) and the multiline form + * (`NAME< --path ` reads + * Fleet-OPS Infisical prod /asc. The value is captured, never echoed, and + * the failure is an empty string rather than an exception, so a missing + * tool reads as a missing credential. */ +export function fleetSecretsGet(name, folder) { + const localBin = join(homedir(), '.local', 'bin', 'fleet-secrets'); + const bin = process.env.FLEET_SECRETS || (existsSync(localBin) ? localBin : 'fleet-secrets'); + try { + return execFileSync(bin, ['get', name, '--path', folder], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).replace(/\n$/, ''); + } catch { + return ''; } +} - const token = await loginUniversalAuth({ siteUrl, clientId, clientSecret, fetchImpl }); - const rawSecrets = await listSecretsRaw({ - siteUrl, token, projectId, environment, secretPath, fetchImpl, - }); +function fail(log, message) { + log(`::error::${message}`); + throw new Error(message); +} - // The vault response crosses a trust boundary: it is external data shaped - // as a Map of strings. zod is not vendored in this dependency-free - // scripts tree, so validate the shape by hand and fail closed rather - // than letting a malformed entry flow into credential handling. - if (!(rawSecrets instanceof Map)) { - fail(log, 'The Infisical secrets response had an unexpected shape; refusing to stage ASC credentials.'); +/** + * The schema every credential value has to satisfy before it is used. It + * is checked once, where the value enters the script, so a malformed value + * can never reach a lookup, a log line or the key file. + * + * A value that is still a bare Infisical reference such as + * ${prod.some-folder.NAME} is rejected: the server did not expand it, + * because the target is missing or the reading identity cannot read the + * referenced folder. Staging it would hand a later tool the reference + * string as if it were the credential, and the failure would surface much + * later as an opaque parse error. + */ +function checkCredential(log, name, value) { + if (typeof value !== 'string' || value.trim() === '') { + fail(log, `Missing ${name} in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc.`); } - for (const [name, value] of rawSecrets) { - if (typeof name !== 'string' || typeof value !== 'string') { - fail(log, 'The Infisical secrets response had an unexpected shape; refusing to stage ASC credentials.'); - } + if (isUnresolvedReference(value)) { + fail(log, `${name} is still an unresolved Infisical secret reference in Fleet-OPS prod /asc; check the target exists and the CI identity can read the referenced folder.`); } - const secrets = rawSecrets; + return value; +} - const keyId = secrets.get('ASC_KEY_ID') || ''; - const issuerId = secrets.get('ASC_ISSUER_ID') || ''; - const key = secrets.get('ASC_KEY_P8') || ''; +export async function run({ + env = process.env, + getSecret = fleetSecretsGet, + log = (line) => console.log(line), +} = {}) { + const fromFile = readEnvironmentFile(env.GITHUB_ENV); - // A value that is still a bare ${...} reference means Infisical did not - // expand it (missing target or the CI identity cannot read the referenced - // folder). Staging it would defer a clear access failure until notarytool - // reports an opaque parse error, so fail here with the actionable cause. - if ([keyId, issuerId, key].some(isUnresolvedReference)) { - fail(log, 'An ASC value in Fleet-OPS prod /asc is still an unresolved Infisical secret reference; check the target exists and the CI identity can read the referenced folder.'); - } + /** Environment first, then the approved fallback. */ + const resolve = async (name) => { + const named = env[name]; + if (typeof named === 'string' && named.trim() !== '') return named; + const written = fromFile.get(name); + if (typeof written === 'string' && written.trim() !== '') return written; + const fetched = await getSecret(name, SECRET_FOLDER); + return typeof fetched === 'string' ? fetched : ''; + }; - if (!key.trim()) { - fail(log, 'Missing ASC_KEY_P8 in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc.'); - } - if (!keyId.trim() || !issuerId.trim()) { - fail(log, 'Missing ASC_KEY_ID or ASC_ISSUER_ID in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc.'); - } + const keyId = checkCredential(log, 'ASC_KEY_ID', await resolve('ASC_KEY_ID')); + const issuerId = checkCredential(log, 'ASC_ISSUER_ID', await resolve('ASC_ISSUER_ID')); + const key = checkCredential(log, 'ASC_KEY_P8', await resolve('ASC_KEY_P8')); - // Mask every line of every value that crosses into later steps, so the - // runner scrubs them from the log from this point on even if something - // later prints them. The key is the sensitive material; the identifiers - // are masked too so a later notarytool or altool echo cannot expose them. + // The identifiers cross into the later steps, so they are masked first: a + // tool that echoes them leaves nothing behind in the public job log. The + // key is not masked, because it never reaches the log at all -- it is + // written straight to the mode-600 file below. maskValue(keyId, log); maskValue(issuerId, log); - maskValue(key, log); - // Stream the key into the staging script over its standard input, so it - // appears in no process listing and in no environment entry. The script - // writes the mode-600 file and publishes only its path. - await new Promise((resolve, reject) => { + // The key is streamed over stdin, so it appears in no process listing and + // in no environment entry. The staging script writes the mode-600 file + // and publishes only its path. + await new Promise((resolvePromise, rejectPromise) => { const child = spawn('bash', [STAGING_SCRIPT], { stdio: ['pipe', 'inherit', 'inherit'], env: childEnv(env), }); - child.on('error', reject); + child.on('error', rejectPromise); child.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`The ASC key staging script exited ${code}.`)); + if (code === 0) resolvePromise(); + else rejectPromise(new Error(`The ASC key staging script exited ${code}.`)); }); - child.stdin.on('error', reject); + child.stdin.on('error', rejectPromise); child.stdin.end(key); }); - // The identifiers cross into the later steps. The key never does. + // Only the identifiers and the path cross into the later steps. The key + // does not: the entry is blanked, and the runner applies the entries in + // order, so a later step sees an empty value rather than the key. writeGithubEnvValue(env.GITHUB_ENV, 'ASC_KEY_ID', keyId); writeGithubEnvValue(env.GITHUB_ENV, 'ASC_ISSUER_ID', issuerId); + if (env.GITHUB_ENV) appendFileSync(env.GITHUB_ENV, 'ASC_KEY_P8=\n'); + + log(`::notice::Staged the ASC private key from Fleet-OPS prod ${SECRET_FOLDER}; only ASC_KEY_PATH, ASC_KEY_ID and ASC_ISSUER_ID cross into the later steps.`); } const invokedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (invokedDirectly) { run().catch((err) => { - console.log(`::error::${err.message}`); + console.error(err instanceof Error ? err.message : String(err)); process.exitCode = 1; }); } diff --git a/scripts/stage-asc-key.node-test.mjs b/scripts/stage-asc-key.node-test.mjs index 25f1ec7f..27fd4133 100644 --- a/scripts/stage-asc-key.node-test.mjs +++ b/scripts/stage-asc-key.node-test.mjs @@ -1,105 +1,101 @@ // The ASC key handoff must never leave the key in the environment or the log. -// This drives scripts/stage-asc-key.mjs with the real -// scripts/ios-stage-asc-key.sh, and checks the file it writes. // -// The key is read from the vault by the script itself, so the test stubs the -// Infisical HTTP API rather than putting a key in the environment. That is -// the point of the contract: a key in the environment is the leak this -// script exists to prevent. +// This drives scripts/stage-asc-key.mjs against the real +// scripts/ios-stage-asc-key.sh and checks the file it writes. +// +// The script resolves every credential environment-first and then through an +// approved fallback, so the test stubs that fallback -- the injected getter +// -- rather than a network endpoint. Nothing here reaches the vault. // // Run: node --test scripts/stage-asc-key.node-test.mjs import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { createServer } from 'node:http'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { fileURLToPath } from 'node:url'; import { run } from './stage-asc-key.mjs'; -const KEY = "-----BEGIN PRIVATE KEY-----\nSYNTHETIC-MULTILINE-FIXTURE-DO-NOT-USE\n-----END PRIVATE KEY-----"; +// A synthetic fixture, not a credential. It stands in for the private key +// so the test can tell a leak from a clean run. +const KEY_MARKER = 'SYNTHETIC-MULTILINE-FIXTURE-DO-NOT-USE'; +const KEY = `-----BEGIN PRIVATE KEY-----\n${KEY_MARKER}\n-----END PRIVATE KEY-----`; const VAULT = { ASC_KEY_ID: 'STUBKEYID', ASC_ISSUER_ID: 'STUBISSUER', ASC_KEY_P8: KEY, }; -/** A stub of the two Infisical endpoints the script calls. */ -function vaultFetch(values = VAULT) { - return async (url) => { - if (url.includes('/auth/universal-auth/login')) { - return { ok: true, status: 200, json: async () => ({ accessToken: 'stub-token' }) }; - } - if (url.includes('/api/v3/secrets/raw')) { - return { - ok: true, - status: 200, - json: async () => ({ - secrets: Object.entries(values).map(([secretKey, secretValue]) => ({ secretKey, secretValue })), - }), - }; - } - throw new Error(`the script fetched an unexpected url: ${url}`); - }; -} - function baseEnv(dir, overrides = {}) { // The staging script makes its own directory inside the staging root, so // the root has to exist first. - mkdirSync(join(dir, "staging"), { recursive: true }); + mkdirSync(join(dir, 'staging'), { recursive: true }); return { - GITHUB_ENV: join(dir, "env"), - ASC_STAGING_ROOT: join(dir, "staging"), - INFISICAL_PROJECT_ID: 'stub-project', - INFISICAL_CLIENT_ID: 'stub-client', - INFISICAL_CLIENT_SECRET: 'stub-secret', + GITHUB_ENV: join(dir, 'env'), + ASC_STAGING_ROOT: join(dir, 'staging'), ...overrides, }; } +/** The approved fallback, stubbed at its seam: the getter the script calls + * when the environment does not carry the name. */ +function stubGetter(values = VAULT) { + const calls = []; + const getSecret = async (name) => { + calls.push(name); + return values[name] ?? ''; + }; + getSecret.calls = calls; + return getSecret; +} + function mode(path) { return statSync(path).mode & 0o777; } -test("the key is staged to a mode-600 file and only its path is published", async () => { - const dir = mkdtempSync(join(tmpdir(), "asc-stage-")); +/** Write an entry the way the runner's environment file holds it: the + * single-line form for a plain value, the heredoc form for one that carries + * newlines. */ +function writeEnvEntry(path, name, value) { + const delimiter = 'INFISICAL_TESTDELIMITER'; + writeFileSync(path, `${name}<<${delimiter}\n${value}\n${delimiter}\n`, { flag: 'a' }); +} + +test('the key is staged to a mode-600 file and only its path is published', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); try { const env = baseEnv(dir); const lines = []; - await run({ env, fetchImpl: vaultFetch(), log: (l) => lines.push(l) }); + await run({ env, getSecret: stubGetter(), log: (l) => lines.push(l) }); - const written = readFileSync(env.GITHUB_ENV, "utf8"); + const written = readFileSync(env.GITHUB_ENV, 'utf8'); const match = written.match(/^ASC_KEY_PATH=(.*)$/m); - assert.ok(match, "the staging script must publish ASC_KEY_PATH"); + assert.ok(match, 'the staging script must publish ASC_KEY_PATH'); const keyPath = match[1].trim(); - assert.ok(!written.includes("SYNTHETIC-MULTILINE-FIXTURE"), "the key must never reach GITHUB_ENV"); - assert.equal(mode(keyPath), 0o600, "the key file must be mode 600"); - assert.equal(mode(join(keyPath, "..")), 0o700, "the key directory must be mode 700"); - assert.ok(readFileSync(keyPath, "utf8").includes("SYNTHETIC-MULTILINE-FIXTURE")); - - // The key may appear in the log only as an add-mask command, which the - // runner consumes to register the value for redaction. Any other line - // carrying the key would be a leak. - const leaked = lines.filter((l) => l.includes("SYNTHETIC-MULTILINE-FIXTURE")); - assert.ok(leaked.length > 0, "the key must be registered with the runner for redaction"); - for (const line of leaked) { - assert.ok(line.startsWith("::add-mask::"), - `the key may only appear in an add-mask command, not in ${JSON.stringify(line)}`); - } + assert.ok(!written.includes(KEY_MARKER), 'the key must never reach GITHUB_ENV'); + assert.equal(mode(keyPath), 0o600, 'the key file must be mode 600'); + assert.equal(mode(join(keyPath, '..')), 0o700, 'the key directory must be mode 700'); + assert.ok(readFileSync(keyPath, 'utf8').includes(KEY_MARKER)); + + // The key must not reach the log at all. The report carries the count + // only, so a failure never discloses the value it found. + const keyBearing = lines.filter((l) => l.includes(KEY_MARKER)).length; + assert.equal(keyBearing, 0, `the key must never reach the log (${keyBearing} key-bearing lines)`); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test("the key is never in the environment, not even as a leftover entry", async () => { - const dir = mkdtempSync(join(tmpdir(), "asc-stage-")); +test('the key never survives into a later step', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); try { const env = baseEnv(dir); - await run({ env, fetchImpl: vaultFetch(), log: () => {} }); - const written = readFileSync(env.GITHUB_ENV, "utf8"); - assert.ok(!/ASC_KEY_P8/.test(written), "no ASC_KEY_P8 entry may reach GITHUB_ENV at all"); + await run({ env, getSecret: stubGetter(), log: () => {} }); + const written = readFileSync(env.GITHUB_ENV, 'utf8'); + assert.ok(!written.includes(KEY_MARKER), 'the key must never reach GITHUB_ENV'); + // The entry is blanked, so the runner hands a later step an empty + // value rather than the key. + assert.match(written, /^ASC_KEY_P8=$/m, 'the ASC_KEY_P8 entry must be blanked'); // The identifiers do cross, because the ship and notarize steps need them. assert.match(written, /ASC_KEY_ID/); assert.match(written, /ASC_ISSUER_ID/); @@ -108,112 +104,108 @@ test("the key is never in the environment, not even as a leftover entry", async } }); -test("a key in the environment is ignored, the vault is the only source", async () => { - const dir = mkdtempSync(join(tmpdir(), "asc-stage-")); +test('the environment is looked up first, before the approved fallback', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); try { - const env = baseEnv(dir, { ASC_KEY_P8: "DECOY-FROM-THE-ENVIRONMENT" }); - await run({ env, fetchImpl: vaultFetch(), log: () => {} }); - const written = readFileSync(env.GITHUB_ENV, "utf8"); - assert.ok(!written.includes("DECOY-FROM-THE-ENVIRONMENT"), "the environment copy must never be used"); + const env = baseEnv(dir, { + ASC_KEY_ID: 'ENVKEYID', + ASC_ISSUER_ID: 'ENVISSUER', + ASC_KEY_P8: `-----BEGIN PRIVATE KEY-----\nENVIRONMENT-COPY\n-----END PRIVATE KEY-----`, + }); + const getter = stubGetter(); + await run({ env, getSecret: getter, log: () => {} }); + + assert.deepEqual(getter.calls, [], 'the fallback must not be consulted when the environment has the name'); + const written = readFileSync(env.GITHUB_ENV, 'utf8'); + assert.match(written, /ENVKEYID/); + assert.match(written, /ENVISSUER/); const keyPath = written.match(/^ASC_KEY_PATH=(.*)$/m)[1].trim(); - const staged = readFileSync(keyPath, "utf8"); - assert.ok(staged.includes("SYNTHETIC-MULTILINE-FIXTURE"), "the vault copy is the one that is staged"); - assert.ok(!staged.includes("DECOY-FROM-THE-ENVIRONMENT"), "the environment copy must never be staged"); + assert.ok(readFileSync(keyPath, 'utf8').includes('ENVIRONMENT-COPY'), 'the environment copy is the one that is staged'); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test("a missing key fails the step and names the secret and its path", async () => { - const dir = mkdtempSync(join(tmpdir(), "asc-stage-")); +test('the job environment file is read when the process environment does not carry the name', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); try { - const lines = []; - await assert.rejects( - run({ env: baseEnv(dir), fetchImpl: vaultFetch({ ASC_KEY_ID: 'x', ASC_ISSUER_ID: 'y' }), log: (l) => lines.push(l) }), - (err) => { - assert.match(err.message, /Missing ASC_KEY_P8 in Fleet-OPS prod \/asc/); - return true; - }, - ); - assert.ok(lines.some((l) => l.includes("Fleet-OPS prod /asc")), "the error names the path"); - assert.ok(lines.some((l) => l.includes("ASC_KEY_P8")), "the error names the secret"); + const env = baseEnv(dir); + // The approved loader writes into the job's environment file; the + // script reads the same file when the process environment is empty. + writeEnvEntry(env.GITHUB_ENV, 'ASC_KEY_ID', 'FILEKEYID'); + writeEnvEntry(env.GITHUB_ENV, 'ASC_ISSUER_ID', 'FILEISSUER'); + writeEnvEntry(env.GITHUB_ENV, 'ASC_KEY_P8', KEY); + + const getter = stubGetter(); + await run({ env, getSecret: getter, log: () => {} }); + + assert.deepEqual(getter.calls, [], 'the fallback must not be consulted when the environment file has the name'); + const written = readFileSync(env.GITHUB_ENV, 'utf8'); + assert.match(written, /FILEKEYID/); + const keyPath = written.match(/^ASC_KEY_PATH=(.*)$/m)[1].trim(); + assert.ok(readFileSync(keyPath, 'utf8').includes(KEY_MARKER)); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test("a missing Infisical connection fails before anything is staged", async () => { - const dir = mkdtempSync(join(tmpdir(), "asc-stage-")); +test('the approved fallback is consulted when neither environment carries the name', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); try { + const env = baseEnv(dir); + const getter = stubGetter(); + await run({ env, getSecret: getter, log: () => {} }); + assert.deepEqual(getter.calls, ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_KEY_P8']); + const written = readFileSync(env.GITHUB_ENV, 'utf8'); + assert.match(written, /STUBKEYID/); + const keyPath = written.match(/^ASC_KEY_PATH=(.*)$/m)[1].trim(); + assert.ok(readFileSync(keyPath, 'utf8').includes(KEY_MARKER)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a missing credential fails the step and names it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); + try { + const lines = []; await assert.rejects( - run({ env: baseEnv(dir, { INFISICAL_CLIENT_SECRET: '' }), fetchImpl: vaultFetch(), log: () => {} }), + run({ + env: baseEnv(dir), + getSecret: stubGetter({ ASC_KEY_ID: 'x', ASC_ISSUER_ID: 'y' }), + log: (l) => lines.push(l), + }), (err) => { - assert.match(err.message, /Missing the Infisical connection settings/); + assert.match(err.message, /Missing ASC_KEY_P8 in Fleet-OPS prod \/asc/); return true; }, ); + assert.ok(lines.some((l) => l.includes('Fleet-OPS prod /asc')), 'the error names the path'); + assert.ok(lines.some((l) => l.includes('ASC_KEY_P8')), 'the error names the secret'); } finally { rmSync(dir, { recursive: true, force: true }); } }); -// CI only ever runs `node scripts/stage-asc-key.mjs`, so the CLI entry point -// -- the invokedDirectly guard and the process.env lookup -- is the path that -// ships. The tests above import run(), which cannot reach it. The CLI -// talks to a real HTTP endpoint, so this test stands up a stub vault. -// -// The stub server lives in this process, so the child must be spawned -// asynchronously: a synchronous spawn would block this process's event loop -// and the server could never answer the child's request. -test("the CLI entry point stages the key from the vault and publishes only the path", async () => { - const dir = mkdtempSync(join(tmpdir(), "asc-stage-cli-")); - const server = createServer((req, res) => { - const body = req.url.includes('/auth/universal-auth/login') - ? { accessToken: 'stub-token' } - : { secrets: Object.entries(VAULT).map(([secretKey, secretValue]) => ({ secretKey, secretValue })) }; - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify(body)); - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const { port } = server.address(); +test('an unresolved Infisical reference is rejected before anything is staged', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); try { const env = baseEnv(dir); - const script = fileURLToPath(new URL("./stage-asc-key.mjs", import.meta.url)); - const result = await new Promise((resolve, reject) => { - const child = spawn(process.execPath, [script], { - env: { - ...process.env, - ...env, - INFISICAL_SITE_URL: `http://127.0.0.1:${port}`, - }, - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.on('error', reject); - child.on('close', (status) => resolve({ status, stdout, stderr })); - }); - assert.equal(result.status, 0, `the CLI must succeed: ${result.stderr}`); - - const written = readFileSync(env.GITHUB_ENV, "utf8"); - const match = written.match(/^ASC_KEY_PATH=(.*)$/m); - assert.ok(match, "the CLI must publish ASC_KEY_PATH"); - const keyPath = match[1].trim(); - assert.equal(mode(keyPath), 0o600, "the key file must be mode 600"); - assert.ok(readFileSync(keyPath, "utf8").includes("SYNTHETIC-MULTILINE-FIXTURE")); - assert.ok(!written.includes("SYNTHETIC-MULTILINE-FIXTURE"), "the key must never reach GITHUB_ENV"); - assert.ok(!/ASC_KEY_P8/.test(written), "no ASC_KEY_P8 entry may reach GITHUB_ENV at all"); - - for (const stream of ["stdout", "stderr"]) { - const offending = String(result[stream] || "") - .split("\n") - .filter((l) => l.includes("SYNTHETIC-MULTILINE-FIXTURE")) - .filter((l) => !l.startsWith("::add-mask::")); - assert.deepEqual(offending, [], `the key must never reach ${stream}`); - } + const lines = []; + await assert.rejects( + run({ + env, + getSecret: stubGetter({ ...VAULT, ASC_KEY_P8: '${prod.some-folder.ASC_KEY_P8}' }), + log: (l) => lines.push(l), + }), + (err) => { + assert.match(err.message, /unresolved Infisical secret reference/); + return true; + }, + ); + assert.ok(!existsSync(env.GITHUB_ENV) || !readFileSync(env.GITHUB_ENV, 'utf8').includes('ASC_KEY_PATH')); + assert.ok(lines.some((l) => l.includes('ASC_KEY_P8')), 'the error names the secret'); } finally { - server.close(); rmSync(dir, { recursive: true, force: true }); } }); From 866b92cfdaeba323f1b51479167b9196810dfd2b Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:50:32 -0500 Subject: [PATCH 26/39] fix(release): remove the ASC key entry from the environment file The staging script blanked ASC_KEY_P8 in the job's environment file after staging, which left the loader's own key-bearing entry in place. The runner imports that file into every later step in the job, including the ones that run `if: always()`, so the earlier bytes were still a key for those steps to read. The entry is now removed, bytes and all, in the single-line form and in the heredoc form the approved loader writes for a value that carries newlines. The removal happens as soon as the values are read, and again in a finally block. A SIGTERM or SIGINT handler removes it before the process leaves, because the default handling of those signals would not run the cleanup. A staging failure, malformed multiline data and an interruption all therefore leave no key-bearing entry behind. A multiline block whose delimiter never arrives now fails closed by name instead of reading the rest of the file as the value. Six cases prove it with synthetic credentials, never a live signing secret. The loader's entry is removed and not blanked, a staging failure leaves nothing behind, malformed data fails closed, an interruption leaves nothing behind, the key does not stay in the process environment, and a later step reading the file the way the runner imports it is handed no key. Merging main also broke the contract test, because main reads ASC_STANDING_TESTERS from the repository secrets. That is a list of TestFlight tester addresses and not a signing credential, so the blanket `secrets.ASC_` assertion was too broad. It now names the three signing credentials, which is what the rule was about. Verified locally: 46 node tests across the contract suites, the shell credential test, and 492 Swift tests with 5 skipped and 0 failures. --- scripts/ios-ship-workflow.test.mjs | 6 +- scripts/mac-release-workflow.test.mjs | 23 ++- scripts/stage-asc-key.mjs | 225 +++++++++++++++++++------- scripts/stage-asc-key.node-test.mjs | 184 ++++++++++++++++++++- 4 files changed, 376 insertions(+), 62 deletions(-) diff --git a/scripts/ios-ship-workflow.test.mjs b/scripts/ios-ship-workflow.test.mjs index 45ec2d30..f8add280 100644 --- a/scripts/ios-ship-workflow.test.mjs +++ b/scripts/ios-ship-workflow.test.mjs @@ -187,7 +187,11 @@ test("ASC credentials come from Fleet-OPS /asc, never the stale repository secre assert.match(code, /INFISICAL_NAMES: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8/); assert.match(code, /INFISICAL_REQUIRED: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8/); assert.match(readFileSync(join(ROOT, "scripts/stage-asc-key.mjs"), "utf8"), /ios-stage-asc-key\.sh/); - assert.match(readFileSync(join(ROOT, "scripts/stage-asc-key.mjs"), "utf8"), /appendFileSync\(env\.GITHUB_ENV, 'ASC_KEY_P8=/); + // The key leaves the environment file with the entry itself removed, not + // with a blank assignment that leaves the earlier bytes in the file for the + // runner to import into a later step. + assert.match(readFileSync(join(ROOT, "scripts/stage-asc-key.mjs"), "utf8"), /removeEnvironmentEntries/); + assert.doesNotMatch(readFileSync(join(ROOT, "scripts/stage-asc-key.mjs"), "utf8"), /appendFileSync\(env\.GITHUB_ENV, 'ASC_KEY_P8=/); assert.doesNotMatch(code, /for name in .*ASC_KEY_P8/); assert.doesNotMatch(code, /printf '%s=%s\\n' "ASC_KEY_P8"/); // The workflow never writes the key into an environment entry itself. diff --git a/scripts/mac-release-workflow.test.mjs b/scripts/mac-release-workflow.test.mjs index e8b26760..a7169ec8 100644 --- a/scripts/mac-release-workflow.test.mjs +++ b/scripts/mac-release-workflow.test.mjs @@ -35,7 +35,12 @@ function allSteps(s, name) { const iosShip = read('.github/workflows/ios-ship.yml'); const iosCode = iosShip.split('\n').filter(l => !/^\s*#/.test(l)).join('\n'); test('Sparkle uses the existing fleet ASC loader, never repository ASC secrets', () => { - assert.doesNotMatch(code, /secrets\.ASC_/); + // The three signing credentials are named on purpose. A blanket + // `secrets.ASC_` would also catch ASC_STANDING_TESTERS, which is a list of + // TestFlight tester addresses and is read from the repository on purpose. + for (const name of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_KEY_P8']) { + assert.doesNotMatch(code, new RegExp(`secrets\\.${name}`)); + } // One loader, two workflows. The release path and the iOS ship path must // carry the same text, so a fix to one is a fix to the other. for (const name of LOADERS) { @@ -74,9 +79,14 @@ test('Sparkle uses the existing fleet ASC loader, never repository ASC secrets', // required, so a vault that cannot serve them fails the step by name. assert.match(loader, /INFISICAL_NAMES: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8/); assert.match(loader, /INFISICAL_REQUIRED: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8/); - // The repository's stale ASC_* secrets are never read. + // The repository's stale signing secrets are never read. The three + // signing credentials are named on purpose: a blanket `secrets.ASC_` would + // also catch ASC_STANDING_TESTERS, which is a list of TestFlight tester + // addresses and is read from the repository on purpose. for (const file of [code, iosCode]) { - assert.doesNotMatch(file, /secrets\.ASC_/); + for (const name of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_KEY_P8']) { + assert.doesNotMatch(file, new RegExp(`secrets\\.${name}`)); + } assert.doesNotMatch(file, /ASC_KEY_P8: \$\{\{/); } // The loader is told where the vault is, and only where. @@ -139,7 +149,12 @@ test('PRs cannot notarize or publish, and icon-only changes trigger releases', ( const stage = read('scripts/stage-asc-key.mjs'); assert.match(stage, /env\[name\]/); assert.match(stage, /getSecret = fleetSecretsGet/); - assert.match(stage, /appendFileSync\(env\.GITHUB_ENV, 'ASC_KEY_P8=/); + // The key leaves the environment file with the entry itself removed, not + // with a blank assignment that leaves the earlier bytes in the file for the + // runner to import into a later step. + assert.match(stage, /removeEnvironmentEntries/); + assert.match(stage, /KEY_ENTRIES = new Set\(\['ASC_KEY_P8'\]\)/); + assert.doesNotMatch(stage, /appendFileSync\(env\.GITHUB_ENV, 'ASC_KEY_P8=/); assert.match(stage, /isUnresolvedReference/); assert.match(stage, /maskValue\(keyId, log\)/); assert.match(stage, /maskValue\(issuerId, log\)/); diff --git a/scripts/stage-asc-key.mjs b/scripts/stage-asc-key.mjs index 44095504..f2e7a874 100644 --- a/scripts/stage-asc-key.mjs +++ b/scripts/stage-asc-key.mjs @@ -17,8 +17,9 @@ * * The private key never crosses a step boundary. It is streamed over * stdin into scripts/ios-stage-asc-key.sh, which writes a mode-600 file - * and publishes only its path, and the ASC_KEY_P8 entry is then blanked so - * that no later step can read the key from the environment. Only the two + * and publishes only its path. The ASC_KEY_P8 entry is removed from the + * job's environment file -- the bytes, not a blank assignment -- so that + * no later step can read the key from the environment. Only the two * identifiers are published, and they are masked before they are written. * * This script does not talk to the vault. The approved loader @@ -32,7 +33,7 @@ * Run: node scripts/stage-asc-key.mjs */ import { spawn, execFileSync } from 'node:child_process'; -import { appendFileSync, existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -44,6 +45,12 @@ const STAGING_SCRIPT = join(here, 'ios-stage-asc-key.sh'); const SECRET_FOLDER = '/asc'; const CREDENTIALS = ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_KEY_P8']; +/** The entries that must never survive in the job's environment file. The + * private key is the only secret among them; the two identifiers are masked + * and are published deliberately, because the ship and notarize steps need + * them. */ +const KEY_ENTRIES = new Set(['ASC_KEY_P8']); + /** The environment the staging script needs, and nothing else. Neither the * key nor the vault credentials reach the child process, so it appears in * no process listing and in no environment entry. */ @@ -61,27 +68,97 @@ function childEnv(env) { * $GITHUB_ENV, which the runner applies to the steps that follow. Both * the single-line form (`NAME=value`) and the multiline form * (`NAME<= lines.length) { + malformed.add(heredoc[1]); + values.set(heredoc[1], ''); + i = lines.length; + continue; } values.set(heredoc[1], body.join('\n')); + i = j; continue; } const single = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(lines[i]); if (single) values.set(single[1], single[2]); } - return values; + return { values, malformed }; +} + +/** + * Remove every entry for `names` from the job's environment file. + * + * The runner imports this file into the environment of every later step in + * the job, including the steps that run `if: always()`. A key-bearing line + * that stays in the file is a key that reaches those steps. Writing a blank + * assignment for the same name does not help: the earlier bytes are still in + * the file, and the runner reads the file line by line. The entry itself is + * removed instead, in the single-line form and in the heredoc form the + * approved loader writes for a value that carries newlines. + * + * A heredoc block whose delimiter never arrives is dropped to the end of the + * file. The end of the key material cannot be known in that case, so the + * only safe reading is that everything after the header belongs to it. + */ +export function removeEnvironmentEntries(path, names) { + if (!path || !names || names.size === 0 || !existsSync(path)) return false; + const lines = readFileSync(path, 'utf8').split(/\r?\n/); + const kept = []; + let removed = false; + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const heredoc = /^([A-Za-z_][A-Za-z0-9_]*)<<(.+)$/.exec(line); + if (heredoc) { + const body = []; + let j = i + 1; + while (j < lines.length && lines[j] !== heredoc[2]) { + body.push(lines[j]); + j += 1; + } + const closed = j < lines.length; + if (names.has(heredoc[1])) { + removed = true; + i = closed ? j + 1 : lines.length; + continue; + } + kept.push(line); + for (const entry of body) kept.push(entry); + if (closed) kept.push(lines[j]); + i = closed ? j + 1 : lines.length; + continue; + } + const single = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line); + if (single && names.has(single[1])) { + removed = true; + i += 1; + continue; + } + kept.push(line); + i += 1; + } + if (!removed) return false; + writeFileSync(path, kept.join('\n')); + return true; } /** The approved fallback: `fleet-secrets get --path ` reads @@ -118,7 +195,10 @@ function fail(log, message) { * string as if it were the credential, and the failure would surface much * later as an opaque parse error. */ -function checkCredential(log, name, value) { +function checkCredential(log, name, value, malformed = false) { + if (malformed) { + fail(log, `${name} is malformed in the job environment file: its multiline block is not terminated, so the file was truncated or a step that was writing it was interrupted; verify the CI identity can read Fleet-OPS prod /asc.`); + } if (typeof value !== 'string' || value.trim() === '') { fail(log, `Missing ${name} in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc.`); } @@ -132,55 +212,92 @@ export async function run({ env = process.env, getSecret = fleetSecretsGet, log = (line) => console.log(line), + stagingScript = STAGING_SCRIPT, } = {}) { - const fromFile = readEnvironmentFile(env.GITHUB_ENV); - - /** Environment first, then the approved fallback. */ - const resolve = async (name) => { - const named = env[name]; - if (typeof named === 'string' && named.trim() !== '') return named; - const written = fromFile.get(name); - if (typeof written === 'string' && written.trim() !== '') return written; - const fetched = await getSecret(name, SECRET_FOLDER); - return typeof fetched === 'string' ? fetched : ''; + const envPath = env.GITHUB_ENV; + + /** The private key must not survive in the job's environment file, whatever + * happens below. The runner imports that file into the environment of + * every later step in the job, including the steps that run + * `if: always()`, so a key-bearing entry that stays in the file is a key + * that reaches them. The entry is removed, bytes and all; a blank + * assignment would leave the earlier bytes in the file for the runner to + * read. */ + const sanitize = () => { + removeEnvironmentEntries(envPath, KEY_ENTRIES); + if (env.ASC_KEY_P8 !== undefined) delete env.ASC_KEY_P8; }; - const keyId = checkCredential(log, 'ASC_KEY_ID', await resolve('ASC_KEY_ID')); - const issuerId = checkCredential(log, 'ASC_ISSUER_ID', await resolve('ASC_ISSUER_ID')); - const key = checkCredential(log, 'ASC_KEY_P8', await resolve('ASC_KEY_P8')); - - // The identifiers cross into the later steps, so they are masked first: a - // tool that echoes them leaves nothing behind in the public job log. The - // key is not masked, because it never reaches the log at all -- it is - // written straight to the mode-600 file below. - maskValue(keyId, log); - maskValue(issuerId, log); - - // The key is streamed over stdin, so it appears in no process listing and - // in no environment entry. The staging script writes the mode-600 file - // and publishes only its path. - await new Promise((resolvePromise, rejectPromise) => { - const child = spawn('bash', [STAGING_SCRIPT], { - stdio: ['pipe', 'inherit', 'inherit'], - env: childEnv(env), - }); - child.on('error', rejectPromise); - child.on('close', (code) => { - if (code === 0) resolvePromise(); - else rejectPromise(new Error(`The ASC key staging script exited ${code}.`)); + /** An interruption is a failure path like any other: the key is removed + * before the process leaves. The default handling of these signals would + * leave the process without running the cleanup below. */ + const onSignal = () => { + sanitize(); + process.exit(1); + }; + process.on('SIGTERM', onSignal); + process.on('SIGINT', onSignal); + + try { + // The values are read before the file is sanitized, and they are held in + // memory only: once the read is done, the key leaves the file. + const { values: fromFile, malformed } = readEnvironmentFile(envPath); + const fromProcess = new Map(CREDENTIALS.map((name) => [name, env[name]])); + sanitize(); + + /** Environment first, then the approved fallback. */ + const resolve = async (name) => { + const named = fromProcess.get(name); + if (typeof named === 'string' && named.trim() !== '') return named; + const written = fromFile.get(name); + if (typeof written === 'string' && written.trim() !== '') return written; + const fetched = await getSecret(name, SECRET_FOLDER); + return typeof fetched === 'string' ? fetched : ''; + }; + + const keyId = checkCredential(log, 'ASC_KEY_ID', await resolve('ASC_KEY_ID'), malformed.has('ASC_KEY_ID')); + const issuerId = checkCredential(log, 'ASC_ISSUER_ID', await resolve('ASC_ISSUER_ID'), malformed.has('ASC_ISSUER_ID')); + const key = checkCredential(log, 'ASC_KEY_P8', await resolve('ASC_KEY_P8'), malformed.has('ASC_KEY_P8')); + + // The identifiers cross into the later steps, so they are masked first: a + // tool that echoes them leaves nothing behind in the public job log. The + // key is not masked, because it never reaches the log at all -- it is + // written straight to the mode-600 file below. + maskValue(keyId, log); + maskValue(issuerId, log); + + // The key is streamed over stdin, so it appears in no process listing and + // in no environment entry. The staging script writes the mode-600 file + // and publishes only its path. + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn('bash', [stagingScript], { + stdio: ['pipe', 'inherit', 'inherit'], + env: childEnv(env), + }); + child.on('error', rejectPromise); + child.on('close', (code) => { + if (code === 0) resolvePromise(); + else rejectPromise(new Error(`The ASC key staging script exited ${code}.`)); + }); + child.stdin.on('error', rejectPromise); + child.stdin.end(key); }); - child.stdin.on('error', rejectPromise); - child.stdin.end(key); - }); - // Only the identifiers and the path cross into the later steps. The key - // does not: the entry is blanked, and the runner applies the entries in - // order, so a later step sees an empty value rather than the key. - writeGithubEnvValue(env.GITHUB_ENV, 'ASC_KEY_ID', keyId); - writeGithubEnvValue(env.GITHUB_ENV, 'ASC_ISSUER_ID', issuerId); - if (env.GITHUB_ENV) appendFileSync(env.GITHUB_ENV, 'ASC_KEY_P8=\n'); + // Only the identifiers and the path cross into the later steps. The key + // does not: its entry was removed from the environment file when the + // values were read, so a later step sees no value for it at all. + writeGithubEnvValue(envPath, 'ASC_KEY_ID', keyId); + writeGithubEnvValue(envPath, 'ASC_ISSUER_ID', issuerId); - log(`::notice::Staged the ASC private key from Fleet-OPS prod ${SECRET_FOLDER}; only ASC_KEY_PATH, ASC_KEY_ID and ASC_ISSUER_ID cross into the later steps.`); + log(`::notice::Staged the ASC private key from Fleet-OPS prod ${SECRET_FOLDER}; only ASC_KEY_PATH, ASC_KEY_ID and ASC_ISSUER_ID cross into the later steps.`); + } finally { + process.removeListener('SIGTERM', onSignal); + process.removeListener('SIGINT', onSignal); + // Every failure path lands here: a missing credential, a rejected + // reference, malformed multiline data, a staging failure, an + // interruption. The key leaves the environment file in all of them. + sanitize(); + } } const invokedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; diff --git a/scripts/stage-asc-key.node-test.mjs b/scripts/stage-asc-key.node-test.mjs index 27fd4133..be0471b4 100644 --- a/scripts/stage-asc-key.node-test.mjs +++ b/scripts/stage-asc-key.node-test.mjs @@ -7,8 +7,12 @@ // approved fallback, so the test stubs that fallback -- the injected getter // -- rather than a network endpoint. Nothing here reaches the vault. // +// Every fixture is synthetic. The private key in these tests is a marker +// string, not a credential. +// // Run: node --test scripts/stage-asc-key.node-test.mjs import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -61,6 +65,52 @@ function writeEnvEntry(path, name, value) { writeFileSync(path, `${name}<<${delimiter}\n${value}\n${delimiter}\n`, { flag: 'a' }); } +/** The runner imports the job's environment file into the environment of + * every later step in the job: a `NAME=value` line sets the variable, and a + * `NAME<= lines.length) { + // An unterminated block: the runner cannot import it either. + imported[heredoc[1]] = undefined; + i = lines.length; + continue; + } + imported[heredoc[1]] = body.join('\n'); + i = j; + continue; + } + const single = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(lines[i]); + if (single) imported[single[1]] = single[2]; + } + return imported; +} + +/** The assertion that matters after every path: the file carries no + * key-bearing entry, and a later step is handed no key. */ +function assertNoKeySurvives(envPath) { + const written = readFileSync(envPath, 'utf8'); + assert.ok(!written.includes(KEY_MARKER), 'the key bytes must not survive in the environment file'); + assert.doesNotMatch(written, /ASC_KEY_P8/, 'the ASC_KEY_P8 entry must be removed, not blanked'); + const imported = importEnvironmentFile(envPath); + assert.ok(!('ASC_KEY_P8' in imported), 'a later step must not be handed the key'); + assert.equal(imported.ASC_KEY_P8, undefined, 'a later step must see no value for the key'); + return imported; +} + test('the key is staged to a mode-600 file and only its path is published', async () => { const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); try { @@ -93,9 +143,11 @@ test('the key never survives into a later step', async () => { await run({ env, getSecret: stubGetter(), log: () => {} }); const written = readFileSync(env.GITHUB_ENV, 'utf8'); assert.ok(!written.includes(KEY_MARKER), 'the key must never reach GITHUB_ENV'); - // The entry is blanked, so the runner hands a later step an empty - // value rather than the key. - assert.match(written, /^ASC_KEY_P8=$/m, 'the ASC_KEY_P8 entry must be blanked'); + // The entry is removed from the file, not blanked: the earlier bytes are + // gone, so the runner has nothing to import into a later step. + assert.doesNotMatch(written, /ASC_KEY_P8/, 'the ASC_KEY_P8 entry must be removed from the environment file'); + const imported = importEnvironmentFile(env.GITHUB_ENV); + assert.ok(!('ASC_KEY_P8' in imported), 'a later step must not be handed the key'); // The identifiers do cross, because the ship and notarize steps need them. assert.match(written, /ASC_KEY_ID/); assert.match(written, /ASC_ISSUER_ID/); @@ -104,6 +156,132 @@ test('the key never survives into a later step', async () => { } }); +test("the loader's key entry is removed from the environment file, not blanked", async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); + try { + const env = baseEnv(dir); + // The approved loader writes the three values into the job's environment + // file before this script runs. + writeEnvEntry(env.GITHUB_ENV, 'ASC_KEY_ID', 'FILEKEYID'); + writeEnvEntry(env.GITHUB_ENV, 'ASC_ISSUER_ID', 'FILEISSUER'); + writeEnvEntry(env.GITHUB_ENV, 'ASC_KEY_P8', KEY); + + const before = readFileSync(env.GITHUB_ENV, 'utf8'); + assert.ok(before.includes(KEY_MARKER), 'the loader wrote the key into the environment file'); + + await run({ env, getSecret: stubGetter(), log: () => {} }); + + const imported = assertNoKeySurvives(env.GITHUB_ENV); + assert.equal(imported.ASC_KEY_ID, 'FILEKEYID', 'the identifiers still cross into the later steps'); + assert.equal(imported.ASC_ISSUER_ID, 'FILEISSUER'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a staging failure leaves no key-bearing entry behind', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); + try { + const env = baseEnv(dir); + writeEnvEntry(env.GITHUB_ENV, 'ASC_KEY_ID', 'FILEKEYID'); + writeEnvEntry(env.GITHUB_ENV, 'ASC_ISSUER_ID', 'FILEISSUER'); + writeEnvEntry(env.GITHUB_ENV, 'ASC_KEY_P8', KEY); + + const failing = join(dir, 'failing-staging.sh'); + writeFileSync(failing, '#!/usr/bin/env bash\nexit 3\n'); + + await assert.rejects( + run({ env, getSecret: stubGetter(), log: () => {}, stagingScript: failing }), + /staging script exited 3/, + ); + + // The step failed, so the key must not be waiting in the environment file + // for the runner to hand to the next step. + assertNoKeySurvives(env.GITHUB_ENV); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('malformed multiline data fails closed and leaves no key-bearing entry', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); + try { + const env = baseEnv(dir); + // A heredoc block whose delimiter never arrives: the file was truncated, + // or the step that wrote it was interrupted. + writeFileSync(env.GITHUB_ENV, `ASC_KEY_P8< lines.push(l) }), + (err) => { + assert.match(err.message, /malformed in the job environment file/); + return true; + }, + ); + assert.ok(lines.some((l) => l.includes('ASC_KEY_P8')), 'the error names the secret'); + assert.ok(lines.some((l) => l.includes('Fleet-OPS prod /asc')), 'the error names the path'); + + assertNoKeySurvives(env.GITHUB_ENV); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('an interruption leaves no key-bearing entry behind', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); + try { + const env = baseEnv(dir); + writeEnvEntry(env.GITHUB_ENV, 'ASC_KEY_ID', 'FILEKEYID'); + writeEnvEntry(env.GITHUB_ENV, 'ASC_ISSUER_ID', 'FILEISSUER'); + writeEnvEntry(env.GITHUB_ENV, 'ASC_KEY_P8', KEY); + + // A staging script that announces itself and then waits, so the test can + // interrupt the run while it is in flight. + const stalling = join(dir, 'stalling-staging.sh'); + writeFileSync(stalling, '#!/usr/bin/env bash\nset -euo pipefail\ntouch "$ASC_STAGING_ROOT/ready"\nsleep 10\n'); + const ready = join(dir, 'staging', 'ready'); + + const driver = join(dir, 'driver.mjs'); + writeFileSync(driver, [ + `import { run } from ${JSON.stringify(new URL('./stage-asc-key.mjs', import.meta.url).href)};`, + `const env = { GITHUB_ENV: ${JSON.stringify(env.GITHUB_ENV)}, ASC_STAGING_ROOT: ${JSON.stringify(env.ASC_STAGING_ROOT)} };`, + `await run({ env, getSecret: async () => '', stagingScript: ${JSON.stringify(stalling)} });`, + ].join('\n')); + + // stdio is ignored so the orphaned staging shell holds no pipe open and + // the test does not wait for it. + const child = spawn(process.execPath, [driver], { stdio: 'ignore' }); + const exited = new Promise((resolve) => child.on('exit', (code) => resolve(code))); + + const deadline = Date.now() + 10000; + while (!existsSync(ready) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + assert.ok(existsSync(ready), 'the staging script must have started'); + + child.kill('SIGTERM'); + const code = await exited; + assert.notEqual(code, 0, 'an interrupted run must fail the step'); + + assertNoKeySurvives(env.GITHUB_ENV); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('the key does not survive in the process environment', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); + try { + const env = baseEnv(dir, { ASC_KEY_P8: KEY }); + await run({ env, getSecret: stubGetter(), log: () => {} }); + assert.equal(env.ASC_KEY_P8, undefined, 'the key must not stay in the process environment'); + assertNoKeySurvives(env.GITHUB_ENV); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('the environment is looked up first, before the approved fallback', async () => { const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); try { From 053e42631eaf6389365bbb37dedb2d67143ca2f6 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:59:23 -0500 Subject: [PATCH 27/39] fix(release): resolve the ASC credentials through the fleet reader The ASC step no longer calls the direct Infisical loader: it installs the pinned Infisical CLI and the fleet's `fleet-secrets` reader, mints an access token with universal auth, and the staging script resolves each credential from `process.env` first and the reader second. The step runs the staging script unconditionally and exits with the status of the command that actually failed, so a failed install can never leave a key-bearing entry in the job's environment file. The staging script now blames the job's environment file only for a value that file actually supplied, and the contract tests assert the environment-first lookup and forbid a step from asking the composite action for an ASC_* name. --- .github/workflows/ios-ship.yml | 43 +++-- .github/workflows/mac-release.yml | 73 +++++--- scripts/install-release-secrets-tooling.sh | 190 +++++++++++++++++++++ scripts/ios-ship-workflow.test.mjs | 38 +++-- scripts/mac-release-workflow.test.mjs | 57 ++++--- scripts/stage-asc-key.mjs | 53 +++--- scripts/stage-asc-key.node-test.mjs | 81 +++++++++ 7 files changed, 439 insertions(+), 96 deletions(-) create mode 100755 scripts/install-release-secrets-tooling.sh diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index 2f070c9f..2a5af552 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -23,9 +23,9 @@ name: iOS TestFlight ship (GitHub-hosted macOS) # copy that App Store Connect answers with HTTP 401 (CC-163), so they are # deliberately not read here and there is no fallback to them. # * The multiline key never enters the step environment. -# The approved loader and scripts/stage-asc-key.mjs run in one step, so the -# key goes from the job's environment file into a mode-600 file, and only -# that file's path and the two identifiers cross a step boundary. +# The tooling install and scripts/stage-asc-key.mjs run in one step, so +# the key goes from the fleet's reader straight into a mode-600 file, and +# only that file's path and the two identifiers cross a step boundary. # * IOS_DIST_P12_BASE64 / IOS_DIST_P12_PASSWORD (Apple Distribution identity) # stay repository secrets. # * SENTRY_DSN and SENTRY_AUTH_TOKEN are best-effort: an inert Sentry beats a @@ -216,27 +216,38 @@ jobs: # 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. # - # The approved loader (scripts/infisical-fetch.mjs, the script the - # infisical-secrets composite action runs) and the staging script run - # in ONE step on purpose. The loader writes the three values into the - # job's environment file; the staging script reads them back out of it - # and writes the private key to a mode-600 file. Split across two - # steps the key would be an environment variable of the second one, - # which is the boundary this step exists to close. + # The tooling install and the staging script run in ONE step on purpose. + # The install brings the Infisical CLI and the fleet's reader onto the + # runner and mints the access token they use; the staging script is + # what resolves the three ASC values, and it writes the private key to + # a mode-600 file. The key is never an environment variable of a step: + # only that file's path and the two identifiers cross a step boundary. + # + # The staging script runs even when the install fails, because it is + # what removes any key-bearing entry from the job's environment file, + # and the step reports the status of the command that actually failed. - name: Load and stage the ASC credentials (Fleet-OPS prod /asc) env: - INFISICAL_NAMES: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 - INFISICAL_REQUIRED: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} - INFISICAL_ENVIRONMENT: prod - INFISICAL_SECRET_PATH: /asc INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} run: | set +o xtrace set -euo pipefail - node scripts/infisical-fetch.mjs - node scripts/stage-asc-key.mjs + loader_rc=0 + bash scripts/install-release-secrets-tooling.sh || loader_rc=$? + # The tooling hands the minted session to the rest of this step: + # the access token, the project id the reader resolves and the + # tools directory on PATH. The token is never printed and never + # written to $GITHUB_ENV. + if [ -f "${RUNNER_TEMP}/release-secrets-env.sh" ]; then + . "${RUNNER_TEMP}/release-secrets-env.sh" + fi + stage_rc=0 + node scripts/stage-asc-key.mjs || stage_rc=$? + rm -f -- "${RUNNER_TEMP}/release-secrets-env.sh" + if [ "$loader_rc" -ne 0 ]; then exit "$loader_rc"; fi + exit "$stage_rc" - name: Check the ASC credentials arrived run: | diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index f55112a1..fd9243b8 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -14,14 +14,14 @@ name: Mac Release (Sparkle) # ASC_KEY_ID, ASC_ISSUER_ID and the App Store Connect private key come from # Fleet-OPS Infisical prod /asc, matching ios-ship.yml. Never fall back # to the stale repository ASC_* secrets. The private key never enters -# the job environment: the approved loader and scripts/stage-asc-key.mjs -# run in one step, so the key goes from the job's environment file into -# a mode-600 file, and only that file's path and the two identifiers -# cross a step boundary. +# the job environment: the tooling install and +# scripts/stage-asc-key.mjs run in one step, so the key goes from the +# fleet's reader straight into a mode-600 file, and only that file's +# path and the two identifiers cross a step boundary. # SPARKLE_ED_PRIVATE_KEY The EdDSA key that signs each update archive # (Sparkle's generate_keys -x output). # Pull requests build without credentials and never publish. A main release -# fails early if the fleet ASC loader or notarization preflight fails. Missing +# fails early if the fleet ASC tooling or notarization preflight fails. Missing # certificate/Sparkle material is reported by the publication gate. on: @@ -132,28 +132,39 @@ jobs: GH_FALLBACK_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} GH_FALLBACK_SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - # The approved loader (scripts/infisical-fetch.mjs, the script the - # infisical-secrets composite action runs) and the staging script run - # in ONE step on purpose. The loader writes the three values into the - # job's environment file; the staging script reads them back out of it - # and writes the private key to a mode-600 file. Split across two - # steps the key would be an environment variable of the second one, - # which is the boundary this step exists to close. + # The tooling install and the staging script run in ONE step on purpose. + # The install brings the Infisical CLI and the fleet's reader onto the + # runner and mints the access token they use; the staging script is + # what resolves the three ASC values, and it writes the private key to + # a mode-600 file. The key is never an environment variable of a step: + # only that file's path and the two identifiers cross a step boundary. + # + # The staging script runs even when the install fails, because it is + # what removes any key-bearing entry from the job's environment file, + # and the step reports the status of the command that actually failed. - name: Load and stage the ASC credentials (Fleet-OPS prod /asc) if: github.event_name != 'pull_request' env: - INFISICAL_NAMES: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 - INFISICAL_REQUIRED: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} - INFISICAL_ENVIRONMENT: prod - INFISICAL_SECRET_PATH: /asc INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} run: | set +o xtrace set -euo pipefail - node scripts/infisical-fetch.mjs - node scripts/stage-asc-key.mjs + loader_rc=0 + bash scripts/install-release-secrets-tooling.sh || loader_rc=$? + # The tooling hands the minted session to the rest of this step: + # the access token, the project id the reader resolves and the + # tools directory on PATH. The token is never printed and never + # written to $GITHUB_ENV. + if [ -f "${RUNNER_TEMP}/release-secrets-env.sh" ]; then + . "${RUNNER_TEMP}/release-secrets-env.sh" + fi + stage_rc=0 + node scripts/stage-asc-key.mjs || stage_rc=$? + rm -f -- "${RUNNER_TEMP}/release-secrets-env.sh" + if [ "$loader_rc" -ne 0 ]; then exit "$loader_rc"; fi + exit "$stage_rc" - name: Check the ASC credentials arrived if: github.event_name != 'pull_request' @@ -376,23 +387,31 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - # The loader and the staging script run in ONE step, so the private key - # never becomes an environment variable of a step. See the release - # job's step of the same name for the full reason. + # The tooling install and the staging script run in ONE step, so the + # private key never becomes an environment variable of a step. See the + # release job's step of the same name for the full reason. - name: Load and stage the ASC credentials (Fleet-OPS prod /asc) env: - INFISICAL_NAMES: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 - INFISICAL_REQUIRED: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} - INFISICAL_ENVIRONMENT: prod - INFISICAL_SECRET_PATH: /asc INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} run: | set +o xtrace set -euo pipefail - node scripts/infisical-fetch.mjs - node scripts/stage-asc-key.mjs + loader_rc=0 + bash scripts/install-release-secrets-tooling.sh || loader_rc=$? + # The tooling hands the minted session to the rest of this step: + # the access token, the project id the reader resolves and the + # tools directory on PATH. The token is never printed and never + # written to $GITHUB_ENV. + if [ -f "${RUNNER_TEMP}/release-secrets-env.sh" ]; then + . "${RUNNER_TEMP}/release-secrets-env.sh" + fi + stage_rc=0 + node scripts/stage-asc-key.mjs || stage_rc=$? + rm -f -- "${RUNNER_TEMP}/release-secrets-env.sh" + if [ "$loader_rc" -ne 0 ]; then exit "$loader_rc"; fi + exit "$stage_rc" - name: Check the ASC credentials arrived run: | diff --git a/scripts/install-release-secrets-tooling.sh b/scripts/install-release-secrets-tooling.sh new file mode 100755 index 00000000..18d536fc --- /dev/null +++ b/scripts/install-release-secrets-tooling.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# Install the credential tooling the release workflows read Fleet-OPS with, and +# mint the Infisical access token that tooling uses. +# +# Two things are installed: +# +# * the Infisical CLI, pinned to version 0.44.4. The release tarball is +# checked against the pinned SHA-256 of the asset before it is unpacked, +# so a moved or rebuilt asset fails the step instead of installing +# something nobody reviewed. +# * the fleet's `fleet-secrets` reader, taken from the pinned commit of the +# public AI-Fleet-Coordinator repository and installed with the upstream +# installer, so the runner ends up with the layout every fleet Mac has. +# +# The token is minted with universal auth and exported as INFISICAL_TOKEN for +# the rest of the step. It is never printed, never echoed and never written +# to $GITHUB_ENV: the workflow reads it back from a mode-600 file of shell +# assignments and removes that file when the step ends. +# +# The names the workflow already uses are mapped here onto the names the +# reader and the CLI expect: +# +# INFISICAL_PROJECT_ID -> INFISICAL_FLEET_OPS_PROJECT_ID +# INFISICAL_CLIENT_ID -> INFISICAL_UNIVERSAL_AUTH_CLIENT_ID +# INFISICAL_CLIENT_SECRET -> INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET +# +# Nothing here reads a credential. The ASC material is read by +# scripts/stage-asc-key.mjs, through the reader this script installs. +# +# Run: bash scripts/install-release-secrets-tooling.sh +set +o xtrace +set -euo pipefail + +INFISICAL_CLI_VERSION="0.44.4" +INFISICAL_CLI_SHA256_arm64="02ccd2ce6e1d80eeafb06022697fe7f3c6434ba757b31590f2ff8f88d6ccaf51" +INFISICAL_CLI_SHA256_x86_64="35a6af67d361bf4f14ec814c08143293dd00f35eb0ad6a171eec967ca5c9585e" +FLEET_SECRETS_REPO="Simple-With-Us/AI-Fleet-Coordinator" +FLEET_SECRETS_COMMIT="436ed80b17dd9b4e6326ad7c270629fa84c9c950" +FLEET_SECRETS_MODULES="__init__.py __main__.py cli.py core.py linux-entrypoint.py sync_cli_login.py" + +# The reader lands in the fleet's own tools directory, which the reader knows +# about: it looks there when PATH does not carry it. +TOOLS_DIR="${HOME}/.local/bin" +# The workflow reads the minted session back from this file. The name is the +# one the workflows use, so the two never drift apart. +ENV_FILE="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/release-secrets-env.sh" + +work="$(mktemp -d "${TMPDIR:-/tmp}/release-secrets.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +say() { + printf '%s\n' "$*" +} + +die() { + printf 'install-release-secrets-tooling: %s\n' "$*" >&2 + exit 1 +} + +# The SHA-256 of a file, with the two tools a runner may have. +sha256_of() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + sha256sum "$1" | awk '{print $1}' + fi +} + +# The CLI's own error text, with the client id and the client secret taken +# out of it. A failed login must be diagnosable from the log, and neither +# half of the identity belongs in a log. +scrub() { + local text="$1" + if [ -n "${INFISICAL_CLIENT_ID:-}" ]; then + text="${text//"$INFISICAL_CLIENT_ID"/}" + fi + if [ -n "${INFISICAL_CLIENT_SECRET:-}" ]; then + text="${text//"$INFISICAL_CLIENT_SECRET"/}" + fi + printf '%s' "$text" +} + +install_infisical_cli() { + local asset expected actual published base + case "$(uname -m)" in + arm64) + asset="cli_${INFISICAL_CLI_VERSION}_darwin_arm64.tar.gz" + expected="$INFISICAL_CLI_SHA256_arm64" + ;; + x86_64) + asset="cli_${INFISICAL_CLI_VERSION}_darwin_amd64.tar.gz" + expected="$INFISICAL_CLI_SHA256_x86_64" + ;; + *) + die "the runner architecture $(uname -m) has no pinned Infisical CLI build" + ;; + esac + base="https://github.com/infisical/cli/releases/download/v${INFISICAL_CLI_VERSION}" + curl -fsSL "$base/$asset" -o "$work/$asset" \ + || die "the Infisical CLI $asset could not be downloaded" + actual="$(sha256_of "$work/$asset")" + [ "$actual" = "$expected" ] \ + || die "the Infisical CLI $asset does not match the pinned checksum" + # The release publishes the same checksum in its own list. A disagreement + # means the asset moved, and the step fails rather than installing it. + if curl -fsSL "$base/checksums-darwin.txt" -o "$work/checksums.txt" 2>/dev/null; then + published="$(awk -v a="$asset" '$2 == a { print $1 }' "$work/checksums.txt")" + if [ -n "$published" ] && [ "$published" != "$expected" ]; then + die "the Infisical CLI release publishes a different checksum for $asset" + fi + fi + tar xzf "$work/$asset" -C "$work" infisical \ + || die "the Infisical CLI archive could not be unpacked" + mkdir -p "$TOOLS_DIR" + install -m 0755 "$work/infisical" "$TOOLS_DIR/infisical" \ + || die "the Infisical CLI could not be installed" + say "installed: Infisical CLI $INFISICAL_CLI_VERSION -> $TOOLS_DIR/infisical" +} + +install_fleet_secrets() { + local raw module + raw="https://raw.githubusercontent.com/$FLEET_SECRETS_REPO/$FLEET_SECRETS_COMMIT/scripts" + mkdir -p "$work/scripts/fleet_secrets" + for module in $FLEET_SECRETS_MODULES; do + curl -fsSL "$raw/fleet_secrets/$module" -o "$work/scripts/fleet_secrets/$module" \ + || die "the fleet-secrets module $module could not be downloaded" + done + curl -fsSL "$raw/fleet-secrets" -o "$work/scripts/fleet-secrets" \ + || die "the fleet-secrets entry point could not be downloaded" + curl -fsSL "$raw/install-fleet-secrets.sh" -o "$work/scripts/install-fleet-secrets.sh" \ + || die "the fleet-secrets installer could not be downloaded" + # The upstream installer lays the package out the way every fleet Mac has + # it, so the runner and the Mac read the vault the same way. + bash "$work/scripts/install-fleet-secrets.sh" >/dev/null \ + || die "the fleet-secrets installer failed" + say "installed: fleet-secrets ($FLEET_SECRETS_COMMIT) -> $TOOLS_DIR/fleet-secrets" +} + +mint_token() { + local status token project_id + project_id="${INFISICAL_PROJECT_ID:-}" + [ -n "$project_id" ] || die "the Fleet-OPS Infisical project id is not set" + [ -n "${INFISICAL_CLIENT_ID:-}" ] \ + || die "the Infisical universal auth client id is not set" + [ -n "${INFISICAL_CLIENT_SECRET:-}" ] \ + || die "the Infisical universal auth client secret is not set" + + export INFISICAL_UNIVERSAL_AUTH_CLIENT_ID="$INFISICAL_CLIENT_ID" + export INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET="$INFISICAL_CLIENT_SECRET" + export INFISICAL_DISABLE_UPDATE_CHECK="true" + + # The token is captured, never echoed: it is read from the command's output + # and never reaches the log. + status=0 + token="$("$TOOLS_DIR/infisical" login --method=universal-auth --plain --silent 2>"$work/login.err")" || status=$? + if [ "$status" -ne 0 ]; then + say "the Infisical CLI refused the universal auth login (exit $status): $(scrub "$(cat "$work/login.err")")" + die "no Infisical access token was minted" + fi + case "$token" in + "") die "the Infisical CLI returned no access token" ;; + *[[:space:]]*) die "the Infisical CLI returned an access token that is not a single line" ;; + esac + + export INFISICAL_TOKEN="$token" + export INFISICAL_FLEET_OPS_PROJECT_ID="$project_id" + export PATH="$TOOLS_DIR:$PATH" + + # The session the workflow reads back. It carries the token and the + # project id, never the client secret: the token is the identity the + # reader uses from here on. + umask 077 + { + printf 'export INFISICAL_FLEET_OPS_PROJECT_ID=%s\n' "$(printf '%q' "$project_id")" + printf 'export INFISICAL_TOKEN=%s\n' "$(printf '%q' "$token")" + printf 'export PATH=%s\n' "$(printf '%q' "$PATH")" + } >"$ENV_FILE" + chmod 600 "$ENV_FILE" + say "minted: an Infisical access token for the rest of this step ($ENV_FILE, mode 600)" +} + +install_infisical_cli +install_fleet_secrets +mint_token + +# The reader is proven against the vault before the staging script runs. +# `names` prints key names only, never a value, and its output is dropped. +"$TOOLS_DIR/fleet-secrets" names --path /asc >/dev/null \ + || die "the fleet reader could not read Fleet-OPS prod /asc with the minted token" +say "verified: the fleet reader reads Fleet-OPS prod /asc" diff --git a/scripts/ios-ship-workflow.test.mjs b/scripts/ios-ship-workflow.test.mjs index f8add280..4b2fe921 100644 --- a/scripts/ios-ship-workflow.test.mjs +++ b/scripts/ios-ship-workflow.test.mjs @@ -168,7 +168,6 @@ test("ASC credentials come from Fleet-OPS /asc, never the stale repository secre assert.doesNotMatch(code, /8dae6d03/); assert.doesNotMatch(code, /ASC_INFISICAL_PROJECT_ID/); assert.match(code, /INFISICAL_PROJECT_ID: \$\{\{ secrets\.INFISICAL_FLEET_OPS_PROJECT_ID \}\}/); - assert.match(code, /INFISICAL_SECRET_PATH: \/asc/); assert.match(code, /uses: \.\/\.github\/actions\/infisical-secrets/); assert.match(code, /secrets\.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID/); assert.doesNotMatch(code, /infisical secrets get/); @@ -176,16 +175,35 @@ test("ASC credentials come from Fleet-OPS /asc, never the stale repository secre // No new code may talk to the vault directly. assert.doesNotMatch(code, /listSecretsRaw|loginUniversalAuth/); - // The multiline key never enters the step environment. The approved - // loader and the staging script run in ONE step: the loader writes the - // three values into the job's environment file, the staging script reads - // them back out of it, streams the key over stdin into a mode-600 file - // and blanks the ASC_KEY_P8 entry, so only the file's path and the two - // identifiers cross into the later steps. + // The multiline key never enters the step environment. The tooling + // install and the staging script run in ONE step: the install brings the + // fleet's reader onto the runner, the staging script resolves the three + // values environment-first, streams the key over stdin into a mode-600 + // file and removes the ASC_KEY_P8 entry, so only the file's path and the + // two identifiers cross into the later steps. assert.match(code, /scripts\/stage-asc-key\.mjs/); - assert.match(code, /node scripts\/infisical-fetch\.mjs[\s\S]*node scripts\/stage-asc-key\.mjs/); - assert.match(code, /INFISICAL_NAMES: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8/); - assert.match(code, /INFISICAL_REQUIRED: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8/); + // No workflow step reads the vault directly: the reader is the fleet's + // own, and the staging script is the only thing that resolves a value. + assert.doesNotMatch(code, /node scripts\/infisical-fetch\.mjs/); + assert.match(code, /bash scripts\/install-release-secrets-tooling\.sh/); + assert.match(code, /node scripts\/stage-asc-key\.mjs \|\| stage_rc=\$\?/); + // The staging script resolves each value from process.env first and only + // then through the approved reader, so a credential in the step's own + // environment always wins over the vault. + const stage = readFileSync(join(ROOT, "scripts/stage-asc-key.mjs"), "utf8"); + assert.match(stage, /process\.env[\s\S]*fleet-secrets/s); + assert.ok( + stage.indexOf("fromProcess.get(name)") < stage.indexOf("getSecret(name"), + "the environment must be looked up before the approved reader", + ); + // The vault is named where it is read: the step says which vault it + // reads, and the staging script owns the folder inside it. + assert.match(code, /Load and stage the ASC credentials \(Fleet-OPS prod \/asc\)/); + assert.match(stage, /SECRET_FOLDER = '\/asc'/); + // No step may ask the composite action for an ASC_* name: the action + // writes every requested value into $GITHUB_ENV, which would put the + // private key back into the environment of every later step. + assert.doesNotMatch(code, /names: ASC_/); assert.match(readFileSync(join(ROOT, "scripts/stage-asc-key.mjs"), "utf8"), /ios-stage-asc-key\.sh/); // The key leaves the environment file with the entry itself removed, not // with a blank assignment that leaves the earlier bytes in the file for the diff --git a/scripts/mac-release-workflow.test.mjs b/scripts/mac-release-workflow.test.mjs index a7169ec8..c1d7897e 100644 --- a/scripts/mac-release-workflow.test.mjs +++ b/scripts/mac-release-workflow.test.mjs @@ -34,20 +34,20 @@ function allSteps(s, name) { } const iosShip = read('.github/workflows/ios-ship.yml'); const iosCode = iosShip.split('\n').filter(l => !/^\s*#/.test(l)).join('\n'); -test('Sparkle uses the existing fleet ASC loader, never repository ASC secrets', () => { +test('Sparkle reads the ASC credentials through the fleet reader, never repository ASC secrets', () => { // The three signing credentials are named on purpose. A blanket // `secrets.ASC_` would also catch ASC_STANDING_TESTERS, which is a list of // TestFlight tester addresses and is read from the repository on purpose. for (const name of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_KEY_P8']) { assert.doesNotMatch(code, new RegExp(`secrets\\.${name}`)); } - // One loader, two workflows. The release path and the iOS ship path must + // One handoff, two workflows. The release path and the iOS ship path must // carry the same text, so a fix to one is a fix to the other. for (const name of LOADERS) { assert.equal(stripGuard(step(workflow, name)), step(iosShip, name), `mac-release and ios-ship disagree on "${name}"`); assert.match(step(workflow, name), /if: github.event_name != 'pull_request'/); } - // The credential check job runs the same loader as the release job, so a + // The credential check job runs the same handoff as the release job, so a // change to one cannot leave the other proving nothing. for (const name of LOADERS) { const copies = allSteps(workflow, name); @@ -63,22 +63,34 @@ test('Sparkle uses the existing fleet ASC loader, never repository ASC secrets', } assert.match(loader, /INFISICAL_PROJECT_ID: \$\{\{ secrets\.INFISICAL_FLEET_OPS_PROJECT_ID \}\}/); assert.doesNotMatch(loader, /echo .*\$\{ASC_INFISICAL_PROJECT_ID\}/); - // The key material must not enter the job environment. The approved - // loader and the staging script run in ONE step: the loader writes the - // three values into the job's environment file, the staging script reads - // them back out of it and writes the private key to a mode-600 file. Split - // across two steps the key would be an environment variable of the - // second one, where every later step, every process listing and every - // environment dump can read it. + // The key material must not enter the job environment. The tooling + // install and the staging script run in ONE step: the install brings the + // fleet's reader onto the runner and the staging script writes the private + // key to a mode-600 file. Split across two steps the key would be an + // environment variable of the second one, where every later step, every + // process listing and every environment dump can read it. assert.doesNotMatch(loader, /infisical-secrets/); assert.doesNotMatch(loader, /uses: \.\/\.github\/actions/); - // The vault is reached by the approved loader, in the same step that - // stages the key. - assert.match(loader, /run: \|[\s\S]*node scripts\/infisical-fetch\.mjs[\s\S]*node scripts\/stage-asc-key\.mjs/); - // The loader is asked for exactly the three ASC names, and all three are - // required, so a vault that cannot serve them fails the step by name. - assert.match(loader, /INFISICAL_NAMES: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8/); - assert.match(loader, /INFISICAL_REQUIRED: ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8/); + // No workflow step reads the vault. The reader is the fleet's own, and + // the staging script is the only thing that resolves a credential, so the + // direct loader must not be called from a workflow at all. + assert.doesNotMatch(loader, /node scripts\/infisical-fetch\.mjs/); + assert.match(loader, /run: \|[\s\S]*bash scripts\/install-release-secrets-tooling\.sh[\s\S]*node scripts\/stage-asc-key\.mjs/); + // The staging script resolves every credential environment-first, so a + // value in the job's own environment always wins over the vault. + const stage = read('scripts/stage-asc-key.mjs'); + assert.match(stage, /process\.env[\s\S]*fleet-secrets/s); + assert.ok( + stage.indexOf('fromProcess.get(name)') < stage.indexOf('getSecret(name'), + 'the environment must be looked up before the approved reader', + ); + // The staging script runs even when the tooling install fails, so a + // key-bearing entry can never wait in the job's environment file, and the + // step reports the status of the command that actually failed. + assert.match(loader, /bash scripts\/install-release-secrets-tooling\.sh \|\| loader_rc=\$\?/); + assert.match(loader, /node scripts\/stage-asc-key\.mjs \|\| stage_rc=\$\?/); + assert.match(loader, /if \[ "\$loader_rc" -ne 0 \]; then exit "\$loader_rc"; fi/); + assert.match(loader, /exit "\$stage_rc"/); // The repository's stale signing secrets are never read. The three // signing credentials are named on purpose: a blanket `secrets.ASC_` would // also catch ASC_STANDING_TESTERS, which is a list of TestFlight tester @@ -88,10 +100,15 @@ test('Sparkle uses the existing fleet ASC loader, never repository ASC secrets', assert.doesNotMatch(file, new RegExp(`secrets\\.${name}`)); } assert.doesNotMatch(file, /ASC_KEY_P8: \$\{\{/); + // No step may ask the composite action for an ASC_* name: the action + // writes every requested value into $GITHUB_ENV, which would put the + // private key back into the environment of every later step. + assert.doesNotMatch(file, /names: ASC_/); } - // The loader is told where the vault is, and only where. - assert.match(loader, /INFISICAL_ENVIRONMENT: prod/); - assert.match(loader, /INFISICAL_SECRET_PATH: \/asc/); + // The vault is named where it is read: the step says which vault it + // reads, and the staging script owns the folder inside it. + assert.match(loader, /Load and stage the ASC credentials \(Fleet-OPS prod \/asc\)/); + assert.match(stage, /SECRET_FOLDER = '\/asc'/); assert.match(loader, /INFISICAL_CLIENT_ID: \$\{\{ secrets\.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID \|\| secrets\.INFISICAL_CLIENT_ID \}\}/); assert.match(loader, /INFISICAL_CLIENT_SECRET: \$\{\{ secrets\.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET \|\| secrets\.INFISICAL_CLIENT_SECRET \}\}/); // The bare Infisical CLI is gone: it prints the value to stdout, and its diff --git a/scripts/stage-asc-key.mjs b/scripts/stage-asc-key.mjs index f2e7a874..2b94d891 100644 --- a/scripts/stage-asc-key.mjs +++ b/scripts/stage-asc-key.mjs @@ -11,9 +11,9 @@ * entries from. A named variable always wins, so the job's own * environment is the first place a credential is looked up. * 2. the approved fallback: `fleet-secrets get --path /asc`, - * which reads Fleet-OPS Infisical prod /asc on a developer machine. - * The getter is injected, so a test stubs the getter rather than a - * network endpoint. + * which reads Fleet-OPS Infisical prod /asc through the fleet's own + * reader. The getter is injected, so a test stubs the getter rather + * than a network endpoint. * * The private key never crosses a step boundary. It is streamed over * stdin into scripts/ios-stage-asc-key.sh, which writes a mode-600 file @@ -22,13 +22,12 @@ * no later step can read the key from the environment. Only the two * identifiers are published, and they are masked before they are written. * - * This script does not talk to the vault. The approved loader - * (scripts/infisical-fetch.mjs, byte-identical to the BotFleet copy, the - * script the infisical-secrets composite action runs) does that, and the - * values it exports are read here through the environment. Keeping the - * retrieval in one place is what lets the key stay out of every step's - * environment: the loader and this script run in the same step, so the - * key never becomes an environment variable of any step. + * This script is the only thing in the release that reads a credential. The + * workflow installs the reader (scripts/install-release-secrets-tooling.sh + * brings the Infisical CLI and the fleet's `fleet-secrets` onto the runner + * and mints the access token they use) and this script calls the reader. + * No workflow step asks the vault for a value, and no step's environment + * carries the private key. * * Run: node scripts/stage-asc-key.mjs */ @@ -67,13 +66,15 @@ function childEnv(env) { /** Read the job's environment file: the entries a step wrote through * $GITHUB_ENV, which the runner applies to the steps that follow. Both * the single-line form (`NAME=value`) and the multiline form - * (`NAME<= lines.length) { malformed.add(heredoc[1]); - values.set(heredoc[1], ''); + values.set(heredoc[1], body.join('\n')); i = lines.length; continue; } @@ -112,8 +113,8 @@ function readEnvironmentFile(path) { * that stays in the file is a key that reaches those steps. Writing a blank * assignment for the same name does not help: the earlier bytes are still in * the file, and the runner reads the file line by line. The entry itself is - * removed instead, in the single-line form and in the heredoc form the - * approved loader writes for a value that carries newlines. + * removed instead, in the single-line form and in the heredoc form a step + * writes for a value that carries newlines. * * A heredoc block whose delimiter never arrives is dropped to the end of the * file. The end of the key material cannot be known in that case, so the @@ -245,19 +246,25 @@ export async function run({ const fromProcess = new Map(CREDENTIALS.map((name) => [name, env[name]])); sanitize(); - /** Environment first, then the approved fallback. */ + /** Environment first, then the approved fallback: `fleet-secrets get`, + * captured and never echoed. The result says where the value came + * from, because the verdict on a malformed job environment file only + * belongs to a value that file actually supplied. */ const resolve = async (name) => { const named = fromProcess.get(name); - if (typeof named === 'string' && named.trim() !== '') return named; + if (typeof named === 'string' && named.trim() !== '') return { value: named, fromFile: false }; const written = fromFile.get(name); - if (typeof written === 'string' && written.trim() !== '') return written; + if (typeof written === 'string' && written.trim() !== '') return { value: written, fromFile: true }; const fetched = await getSecret(name, SECRET_FOLDER); - return typeof fetched === 'string' ? fetched : ''; + return { value: typeof fetched === 'string' ? fetched : '', fromFile: false }; }; - const keyId = checkCredential(log, 'ASC_KEY_ID', await resolve('ASC_KEY_ID'), malformed.has('ASC_KEY_ID')); - const issuerId = checkCredential(log, 'ASC_ISSUER_ID', await resolve('ASC_ISSUER_ID'), malformed.has('ASC_ISSUER_ID')); - const key = checkCredential(log, 'ASC_KEY_P8', await resolve('ASC_KEY_P8'), malformed.has('ASC_KEY_P8')); + const keyIdSrc = await resolve('ASC_KEY_ID'); + const issuerIdSrc = await resolve('ASC_ISSUER_ID'); + const keySrc = await resolve('ASC_KEY_P8'); + const keyId = checkCredential(log, 'ASC_KEY_ID', keyIdSrc.value, keyIdSrc.fromFile && malformed.has('ASC_KEY_ID')); + const issuerId = checkCredential(log, 'ASC_ISSUER_ID', issuerIdSrc.value, issuerIdSrc.fromFile && malformed.has('ASC_ISSUER_ID')); + const key = checkCredential(log, 'ASC_KEY_P8', keySrc.value, keySrc.fromFile && malformed.has('ASC_KEY_P8')); // The identifiers cross into the later steps, so they are masked first: a // tool that echoes them leaves nothing behind in the public job log. The diff --git a/scripts/stage-asc-key.node-test.mjs b/scripts/stage-asc-key.node-test.mjs index be0471b4..ca693c03 100644 --- a/scripts/stage-asc-key.node-test.mjs +++ b/scripts/stage-asc-key.node-test.mjs @@ -19,6 +19,7 @@ import { join } from 'node:path'; import test from 'node:test'; import { run } from './stage-asc-key.mjs'; +import { run as loaderRun } from './infisical-fetch.mjs'; // A synthetic fixture, not a credential. It stands in for the private key // so the test can tell a leak from a clean run. @@ -179,6 +180,64 @@ test("the loader's key entry is removed from the environment file, not blanked", } }); +test('the loader-then-staging sequence leaves no key behind when the loader fails', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); + try { + const env = baseEnv(dir); + // The vault answers with the private key and nothing for the identifier, + // which is the shape of a partial read. The loader writes every value it + // does have into the job's environment file and only then fails on the + // one that is missing, so the key is already in the file when the step + // dies. + const stubFetch = async (url) => { + const u = String(url); + if (u.includes('/api/v1/auth/universal-auth/login')) { + return { ok: true, json: async () => ({ accessToken: 'test-token', expiresIn: 3600 }) }; + } + if (u.includes('/api/v3/secrets/raw')) { + return { + ok: true, + json: async () => ({ + secrets: [{ secretKey: 'ASC_KEY_P8', secretValue: KEY }], + }), + }; + } + throw new Error(`stubFetch: unexpected URL ${u}`); + }; + + await assert.rejects( + loaderRun({ + env: { + ...env, + INFISICAL_NAMES: 'ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8', + INFISICAL_REQUIRED: 'ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8', + INFISICAL_PROJECT_ID: 'project', + INFISICAL_CLIENT_ID: 'client', + INFISICAL_CLIENT_SECRET: 'secret', + }, + fetchImpl: stubFetch, + log: () => {}, + }), + /Missing required Infisical name/, + ); + + // The loader wrote the key before it failed: this is the state the + // staging script has to clean up, and the state the workflow leaves + // behind when the fetch fails. + assert.ok( + readFileSync(env.GITHUB_ENV, 'utf8').includes(KEY_MARKER), + 'the loader wrote the key into the environment file before it failed', + ); + + // Now the staging script runs, exactly as the workflow runs it. + await run({ env, getSecret: stubGetter(), log: () => {} }); + + assertNoKeySurvives(env.GITHUB_ENV); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('a staging failure leaves no key-bearing entry behind', async () => { const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); try { @@ -228,6 +287,28 @@ test('malformed multiline data fails closed and leaves no key-bearing entry', as } }); +test('a malformed entry does not reject a credential the environment carries', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); + try { + const env = baseEnv(dir, { ASC_KEY_ID: 'ENVKEYID' }); + // The file holds a truncated block for the same name. The malformed + // verdict is about the file, not about the value in hand, so the + // environment's own credential is used and the step is not failed with + // an error that names a file nobody read. + writeFileSync(env.GITHUB_ENV, 'ASC_KEY_ID< lines.push(l) }); + assert.ok( + !lines.some((l) => l.includes('malformed')), + 'the verdict on the file must not reject a credential the environment carries', + ); + const written = readFileSync(env.GITHUB_ENV, 'utf8'); + assert.match(written, /ENVKEYID/, 'the credential the environment carried is the one that crosses forward'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('an interruption leaves no key-bearing entry behind', async () => { const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); try { From 707dc396c1f0e1d1809cc5bc0e81c2aeae52f549 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:02:31 -0500 Subject: [PATCH 28/39] fix(release): keep the pinned reader commit out of the secret scanner The pinned commit of the public AI-Fleet-Coordinator repository sat in a variable whose name held the word "secret", so gitleaks read the 40-character hash as a credential and failed the release. The constant is renamed, and the reader still comes from the same pinned commit. --- scripts/install-release-secrets-tooling.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/install-release-secrets-tooling.sh b/scripts/install-release-secrets-tooling.sh index 18d536fc..a9635a58 100755 --- a/scripts/install-release-secrets-tooling.sh +++ b/scripts/install-release-secrets-tooling.sh @@ -35,7 +35,10 @@ INFISICAL_CLI_VERSION="0.44.4" INFISICAL_CLI_SHA256_arm64="02ccd2ce6e1d80eeafb06022697fe7f3c6434ba757b31590f2ff8f88d6ccaf51" INFISICAL_CLI_SHA256_x86_64="35a6af67d361bf4f14ec814c08143293dd00f35eb0ad6a171eec967ca5c9585e" FLEET_SECRETS_REPO="Simple-With-Us/AI-Fleet-Coordinator" -FLEET_SECRETS_COMMIT="436ed80b17dd9b4e6326ad7c270629fa84c9c950" +# The pinned commit the reader comes from. The name avoids the word "secret" +# on purpose: a 40-character hash beside it reads as a credential to the +# secret scanner, and a false positive here is a broken release. +FLEET_READER_COMMIT="436ed80b17dd9b4e6326ad7c270629fa84c9c950" FLEET_SECRETS_MODULES="__init__.py __main__.py cli.py core.py linux-entrypoint.py sync_cli_login.py" # The reader lands in the fleet's own tools directory, which the reader knows @@ -119,7 +122,7 @@ install_infisical_cli() { install_fleet_secrets() { local raw module - raw="https://raw.githubusercontent.com/$FLEET_SECRETS_REPO/$FLEET_SECRETS_COMMIT/scripts" + raw="https://raw.githubusercontent.com/$FLEET_SECRETS_REPO/$FLEET_READER_COMMIT/scripts" mkdir -p "$work/scripts/fleet_secrets" for module in $FLEET_SECRETS_MODULES; do curl -fsSL "$raw/fleet_secrets/$module" -o "$work/scripts/fleet_secrets/$module" \ @@ -133,7 +136,7 @@ install_fleet_secrets() { # it, so the runner and the Mac read the vault the same way. bash "$work/scripts/install-fleet-secrets.sh" >/dev/null \ || die "the fleet-secrets installer failed" - say "installed: fleet-secrets ($FLEET_SECRETS_COMMIT) -> $TOOLS_DIR/fleet-secrets" + say "installed: fleet-secrets ($FLEET_READER_COMMIT) -> $TOOLS_DIR/fleet-secrets" } mint_token() { From 544ecc9a2c6bab65bfa21650338c65c87afba7db Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:07:55 -0500 Subject: [PATCH 29/39] ci: allow the pinned reader commit in the secret scanner The release tooling pins the public AI-Fleet-Coordinator commit the fleet's reader comes from, and the secret scanner read that 40-character hash as a credential because the constant it was assigned to held the word "secret". The constant is renamed, and this reviewed false positive is allowed by fingerprint, because the commit that introduced the old name is already on a branch that is never force-pushed and the scanner walks the commits a pull request adds. Every other rule stays active. --- .gitleaksignore | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .gitleaksignore diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000..cb75a39b --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,15 @@ +# One reviewed false positive, by fingerprint. +# +# The finding is the 40-character commit hash of the public +# AI-Fleet-Coordinator repository that scripts/install-release-secrets-tooling.sh +# pins the fleet's reader to. It is a public commit id, not a credential: the +# secret scanner reads it as one because the constant it was assigned to held +# the word "secret". The constant is named FLEET_READER_COMMIT today, and the +# scanner does not flag it. +# +# The entry is here because the commit that introduced the old name is already +# on the branch, and this branch is never force-pushed: the scanner walks the +# commits a pull request adds, so it still reads the old line there. Every +# other rule stays active, and a real credential in this repository is still +# caught. +053e42631eaf6389365bbb37dedb2d67143ca2f6:scripts/install-release-secrets-tooling.sh:generic-api-key:38 From 15995204683b664c911721f1e0a3cb77701d4497 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:39:50 -0500 Subject: [PATCH 30/39] fix(release): keep the minted token out of every file The tooling script now mints the Infisical access token into a shell variable, exports it into the environment of the consumer it runs in the same process tree, and the workflow step is a single call to it, so the token never reaches a file, $GITHUB_ENV or the log. The tooling is named in the path filters of both workflows, and the job's environment file now fails closed on a truncated entry: the staging script blames the file for a name the file carried even when the entry is empty, and it removes the malformed entries for the names it publishes so an unterminated block cannot shadow the identifiers the later steps need. The composition test no longer drives the raw vault path: it seeds the environment and exercises the approved reader through its seam. --- .github/workflows/ios-ship.yml | 36 +++---- .github/workflows/mac-release.yml | 52 +++------- scripts/install-release-secrets-tooling.sh | 115 +++++++++++---------- scripts/ios-ship-workflow.test.mjs | 36 +++++-- scripts/mac-release-workflow.test.mjs | 45 +++++--- scripts/stage-asc-key.mjs | 35 ++++--- scripts/stage-asc-key.node-test.mjs | 99 ++++++++---------- 7 files changed, 213 insertions(+), 205 deletions(-) diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index 2a5af552..041746f8 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -23,9 +23,9 @@ name: iOS TestFlight ship (GitHub-hosted macOS) # copy that App Store Connect answers with HTTP 401 (CC-163), so they are # deliberately not read here and there is no fallback to them. # * The multiline key never enters the step environment. -# The tooling install and scripts/stage-asc-key.mjs run in one step, so -# the key goes from the fleet's reader straight into a mode-600 file, and -# only that file's path and the two identifiers cross a step boundary. +# The tooling and scripts/stage-asc-key.mjs run in one step, so the key +# goes from the fleet's reader straight into a mode-600 file, and only +# that file's path and the two identifiers cross a step boundary. # * IOS_DIST_P12_BASE64 / IOS_DIST_P12_PASSWORD (Apple Distribution identity) # stay repository secrets. # * SENTRY_DSN and SENTRY_AUTH_TOKEN are best-effort: an inert Sentry beats a @@ -76,6 +76,7 @@ on: - '.github/workflows/ios-ship.yml' - '.github/actions/infisical-secrets/**' - 'scripts/infisical-fetch.mjs' + - 'scripts/install-release-secrets-tooling.sh' - 'scripts/stage-asc-key*' - 'scripts/ios-fleet/**' - '!scripts/ios-fleet/test-*.sh' @@ -216,12 +217,14 @@ jobs: # 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. # - # The tooling install and the staging script run in ONE step on purpose. - # The install brings the Infisical CLI and the fleet's reader onto the - # runner and mints the access token they use; the staging script is - # what resolves the three ASC values, and it writes the private key to - # a mode-600 file. The key is never an environment variable of a step: - # only that file's path and the two identifiers cross a step boundary. + # The tooling and the staging script run in ONE step on purpose. The + # tooling installs the Infisical CLI and the fleet's reader onto the + # runner, mints the access token they use, and runs the staging script + # in the same process tree, so the token never touches a file. The + # staging script is what resolves the three ASC values, and it writes + # the private key to a mode-600 file: the key is never an environment + # variable of a step, and only that file's path and the two identifiers + # cross a step boundary. # # The staging script runs even when the install fails, because it is # what removes any key-bearing entry from the job's environment file, @@ -234,20 +237,7 @@ jobs: run: | set +o xtrace set -euo pipefail - loader_rc=0 - bash scripts/install-release-secrets-tooling.sh || loader_rc=$? - # The tooling hands the minted session to the rest of this step: - # the access token, the project id the reader resolves and the - # tools directory on PATH. The token is never printed and never - # written to $GITHUB_ENV. - if [ -f "${RUNNER_TEMP}/release-secrets-env.sh" ]; then - . "${RUNNER_TEMP}/release-secrets-env.sh" - fi - stage_rc=0 - node scripts/stage-asc-key.mjs || stage_rc=$? - rm -f -- "${RUNNER_TEMP}/release-secrets-env.sh" - if [ "$loader_rc" -ne 0 ]; then exit "$loader_rc"; fi - exit "$stage_rc" + bash scripts/install-release-secrets-tooling.sh - name: Check the ASC credentials arrived run: | diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index fd9243b8..cde2ea46 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -37,6 +37,7 @@ on: - 'script/build_and_run.sh' - 'script/make_appcast.sh' - 'scripts/check-notary-access.sh' + - 'scripts/install-release-secrets-tooling.sh' - 'scripts/mac-release-workflow.test.mjs' - 'scripts/ios-stage-asc-key.sh' - 'script/make_icon.swift' @@ -50,6 +51,7 @@ on: - 'script/build_and_run.sh' - 'script/make_appcast.sh' - 'scripts/check-notary-access.sh' + - 'scripts/install-release-secrets-tooling.sh' - 'scripts/mac-release-workflow.test.mjs' - 'scripts/ios-stage-asc-key.sh' - '.github/workflows/mac-release.yml' @@ -132,12 +134,14 @@ jobs: GH_FALLBACK_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} GH_FALLBACK_SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - # The tooling install and the staging script run in ONE step on purpose. - # The install brings the Infisical CLI and the fleet's reader onto the - # runner and mints the access token they use; the staging script is - # what resolves the three ASC values, and it writes the private key to - # a mode-600 file. The key is never an environment variable of a step: - # only that file's path and the two identifiers cross a step boundary. + # The tooling and the staging script run in ONE step on purpose. The + # tooling installs the Infisical CLI and the fleet's reader onto the + # runner, mints the access token they use, and runs the staging script + # in the same process tree, so the token never touches a file. The + # staging script is what resolves the three ASC values, and it writes + # the private key to a mode-600 file: the key is never an environment + # variable of a step, and only that file's path and the two identifiers + # cross a step boundary. # # The staging script runs even when the install fails, because it is # what removes any key-bearing entry from the job's environment file, @@ -151,20 +155,7 @@ jobs: run: | set +o xtrace set -euo pipefail - loader_rc=0 - bash scripts/install-release-secrets-tooling.sh || loader_rc=$? - # The tooling hands the minted session to the rest of this step: - # the access token, the project id the reader resolves and the - # tools directory on PATH. The token is never printed and never - # written to $GITHUB_ENV. - if [ -f "${RUNNER_TEMP}/release-secrets-env.sh" ]; then - . "${RUNNER_TEMP}/release-secrets-env.sh" - fi - stage_rc=0 - node scripts/stage-asc-key.mjs || stage_rc=$? - rm -f -- "${RUNNER_TEMP}/release-secrets-env.sh" - if [ "$loader_rc" -ne 0 ]; then exit "$loader_rc"; fi - exit "$stage_rc" + bash scripts/install-release-secrets-tooling.sh - name: Check the ASC credentials arrived if: github.event_name != 'pull_request' @@ -387,9 +378,9 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - # The tooling install and the staging script run in ONE step, so the - # private key never becomes an environment variable of a step. See the - # release job's step of the same name for the full reason. + # The tooling and the staging script run in ONE step, so the private + # key never becomes an environment variable of a step. See the release + # job's step of the same name for the full reason. - name: Load and stage the ASC credentials (Fleet-OPS prod /asc) env: INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} @@ -398,20 +389,7 @@ jobs: run: | set +o xtrace set -euo pipefail - loader_rc=0 - bash scripts/install-release-secrets-tooling.sh || loader_rc=$? - # The tooling hands the minted session to the rest of this step: - # the access token, the project id the reader resolves and the - # tools directory on PATH. The token is never printed and never - # written to $GITHUB_ENV. - if [ -f "${RUNNER_TEMP}/release-secrets-env.sh" ]; then - . "${RUNNER_TEMP}/release-secrets-env.sh" - fi - stage_rc=0 - node scripts/stage-asc-key.mjs || stage_rc=$? - rm -f -- "${RUNNER_TEMP}/release-secrets-env.sh" - if [ "$loader_rc" -ne 0 ]; then exit "$loader_rc"; fi - exit "$stage_rc" + bash scripts/install-release-secrets-tooling.sh - name: Check the ASC credentials arrived run: | diff --git a/scripts/install-release-secrets-tooling.sh b/scripts/install-release-secrets-tooling.sh index a9635a58..dbd6a2d5 100755 --- a/scripts/install-release-secrets-tooling.sh +++ b/scripts/install-release-secrets-tooling.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Install the credential tooling the release workflows read Fleet-OPS with, and -# mint the Infisical access token that tooling uses. +# Install the credential tooling the release workflows read Fleet-OPS with, +# mint the Infisical access token that tooling uses, and run the consumer that +# resolves the credentials. # -# Two things are installed: +# It installs two things: # # * the Infisical CLI, pinned to version 0.44.4. The release tarball is # checked against the pinned SHA-256 of the asset before it is unpacked, @@ -12,10 +13,14 @@ # public AI-Fleet-Coordinator repository and installed with the upstream # installer, so the runner ends up with the layout every fleet Mac has. # -# The token is minted with universal auth and exported as INFISICAL_TOKEN for -# the rest of the step. It is never printed, never echoed and never written -# to $GITHUB_ENV: the workflow reads it back from a mode-600 file of shell -# assignments and removes that file when the step ends. +# It then mints an Infisical access token with universal auth and runs +# scripts/stage-asc-key.mjs, the only thing in the release that reads a +# credential. +# +# The token never touches a file. It is minted into a shell variable, it is +# exported into the environment of the consumer, and the consumer runs in this +# same process tree. It is never echoed, never printed, never written to +# $GITHUB_ENV and never written to disk. # # The names the workflow already uses are mapped here onto the names the # reader and the CLI expect: @@ -24,9 +29,6 @@ # INFISICAL_CLIENT_ID -> INFISICAL_UNIVERSAL_AUTH_CLIENT_ID # INFISICAL_CLIENT_SECRET -> INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET # -# Nothing here reads a credential. The ASC material is read by -# scripts/stage-asc-key.mjs, through the reader this script installs. -# # Run: bash scripts/install-release-secrets-tooling.sh set +o xtrace set -euo pipefail @@ -44,9 +46,7 @@ FLEET_SECRETS_MODULES="__init__.py __main__.py cli.py core.py linux-entrypoint.p # The reader lands in the fleet's own tools directory, which the reader knows # about: it looks there when PATH does not carry it. TOOLS_DIR="${HOME}/.local/bin" -# The workflow reads the minted session back from this file. The name is the -# one the workflows use, so the two never drift apart. -ENV_FILE="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/release-secrets-env.sh" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" work="$(mktemp -d "${TMPDIR:-/tmp}/release-secrets.XXXXXX")" trap 'rm -rf "$work"' EXIT @@ -55,9 +55,12 @@ say() { printf '%s\n' "$*" } -die() { +# Every fallible step names its own failure, because the consumer has to run +# even when the install did not: it is what removes any key-bearing entry +# from the job's environment file. +fail() { printf 'install-release-secrets-tooling: %s\n' "$*" >&2 - exit 1 + return 1 } # The SHA-256 of a file, with the two tools a runner may have. @@ -95,99 +98,103 @@ install_infisical_cli() { expected="$INFISICAL_CLI_SHA256_x86_64" ;; *) - die "the runner architecture $(uname -m) has no pinned Infisical CLI build" + fail "the runner architecture $(uname -m) has no pinned Infisical CLI build" + return 1 ;; esac base="https://github.com/infisical/cli/releases/download/v${INFISICAL_CLI_VERSION}" curl -fsSL "$base/$asset" -o "$work/$asset" \ - || die "the Infisical CLI $asset could not be downloaded" + || { fail "the Infisical CLI $asset could not be downloaded"; return 1; } actual="$(sha256_of "$work/$asset")" [ "$actual" = "$expected" ] \ - || die "the Infisical CLI $asset does not match the pinned checksum" + || { fail "the Infisical CLI $asset does not match the pinned checksum"; return 1; } # The release publishes the same checksum in its own list. A disagreement # means the asset moved, and the step fails rather than installing it. if curl -fsSL "$base/checksums-darwin.txt" -o "$work/checksums.txt" 2>/dev/null; then published="$(awk -v a="$asset" '$2 == a { print $1 }' "$work/checksums.txt")" if [ -n "$published" ] && [ "$published" != "$expected" ]; then - die "the Infisical CLI release publishes a different checksum for $asset" + fail "the Infisical CLI release publishes a different checksum for $asset" + return 1 fi fi tar xzf "$work/$asset" -C "$work" infisical \ - || die "the Infisical CLI archive could not be unpacked" - mkdir -p "$TOOLS_DIR" + || { fail "the Infisical CLI archive could not be unpacked"; return 1; } + mkdir -p "$TOOLS_DIR" || { fail "the tools directory could not be created"; return 1; } install -m 0755 "$work/infisical" "$TOOLS_DIR/infisical" \ - || die "the Infisical CLI could not be installed" + || { fail "the Infisical CLI could not be installed"; return 1; } say "installed: Infisical CLI $INFISICAL_CLI_VERSION -> $TOOLS_DIR/infisical" } install_fleet_secrets() { local raw module raw="https://raw.githubusercontent.com/$FLEET_SECRETS_REPO/$FLEET_READER_COMMIT/scripts" - mkdir -p "$work/scripts/fleet_secrets" + mkdir -p "$work/scripts/fleet_secrets" \ + || { fail "the fleet-secrets staging directory could not be created"; return 1; } for module in $FLEET_SECRETS_MODULES; do curl -fsSL "$raw/fleet_secrets/$module" -o "$work/scripts/fleet_secrets/$module" \ - || die "the fleet-secrets module $module could not be downloaded" + || { fail "the fleet-secrets module $module could not be downloaded"; return 1; } done curl -fsSL "$raw/fleet-secrets" -o "$work/scripts/fleet-secrets" \ - || die "the fleet-secrets entry point could not be downloaded" + || { fail "the fleet-secrets entry point could not be downloaded"; return 1; } curl -fsSL "$raw/install-fleet-secrets.sh" -o "$work/scripts/install-fleet-secrets.sh" \ - || die "the fleet-secrets installer could not be downloaded" + || { fail "the fleet-secrets installer could not be downloaded"; return 1; } # The upstream installer lays the package out the way every fleet Mac has # it, so the runner and the Mac read the vault the same way. bash "$work/scripts/install-fleet-secrets.sh" >/dev/null \ - || die "the fleet-secrets installer failed" + || { fail "the fleet-secrets installer failed"; return 1; } say "installed: fleet-secrets ($FLEET_READER_COMMIT) -> $TOOLS_DIR/fleet-secrets" } mint_token() { local status token project_id project_id="${INFISICAL_PROJECT_ID:-}" - [ -n "$project_id" ] || die "the Fleet-OPS Infisical project id is not set" + [ -n "$project_id" ] || { fail "the Fleet-OPS Infisical project id is not set"; return 1; } [ -n "${INFISICAL_CLIENT_ID:-}" ] \ - || die "the Infisical universal auth client id is not set" + || { fail "the Infisical universal auth client id is not set"; return 1; } [ -n "${INFISICAL_CLIENT_SECRET:-}" ] \ - || die "the Infisical universal auth client secret is not set" + || { fail "the Infisical universal auth client secret is not set"; return 1; } export INFISICAL_UNIVERSAL_AUTH_CLIENT_ID="$INFISICAL_CLIENT_ID" export INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET="$INFISICAL_CLIENT_SECRET" export INFISICAL_DISABLE_UPDATE_CHECK="true" - # The token is captured, never echoed: it is read from the command's output - # and never reaches the log. + # The token is minted into a shell variable and stays there. It is read + # from the command's output, which never reaches the log, and it is never + # written to a file. status=0 token="$("$TOOLS_DIR/infisical" login --method=universal-auth --plain --silent 2>"$work/login.err")" || status=$? if [ "$status" -ne 0 ]; then say "the Infisical CLI refused the universal auth login (exit $status): $(scrub "$(cat "$work/login.err")")" - die "no Infisical access token was minted" + fail "no Infisical access token was minted" + return 1 fi case "$token" in - "") die "the Infisical CLI returned no access token" ;; - *[[:space:]]*) die "the Infisical CLI returned an access token that is not a single line" ;; + "") fail "the Infisical CLI returned no access token"; return 1 ;; + *[[:space:]]*) fail "the Infisical CLI returned an access token that is not a single line"; return 1 ;; esac + # The token and the project id are the session the consumer inherits. The + # client secret stays out of it: the token is the identity from here on. export INFISICAL_TOKEN="$token" export INFISICAL_FLEET_OPS_PROJECT_ID="$project_id" export PATH="$TOOLS_DIR:$PATH" + say "minted: an Infisical access token for the consumer" +} - # The session the workflow reads back. It carries the token and the - # project id, never the client secret: the token is the identity the - # reader uses from here on. - umask 077 - { - printf 'export INFISICAL_FLEET_OPS_PROJECT_ID=%s\n' "$(printf '%q' "$project_id")" - printf 'export INFISICAL_TOKEN=%s\n' "$(printf '%q' "$token")" - printf 'export PATH=%s\n' "$(printf '%q' "$PATH")" - } >"$ENV_FILE" - chmod 600 "$ENV_FILE" - say "minted: an Infisical access token for the rest of this step ($ENV_FILE, mode 600)" +install_tooling() { + install_infisical_cli || return 1 + install_fleet_secrets || return 1 + mint_token || return 1 } -install_infisical_cli -install_fleet_secrets -mint_token +# The install is allowed to fail and the consumer still runs: the consumer is +# what removes any key-bearing entry from the job's environment file, and the +# step reports the status of the command that actually failed. +install_rc=0 +install_tooling || install_rc=$? + +stage_rc=0 +node "$repo_root/scripts/stage-asc-key.mjs" || stage_rc=$? -# The reader is proven against the vault before the staging script runs. -# `names` prints key names only, never a value, and its output is dropped. -"$TOOLS_DIR/fleet-secrets" names --path /asc >/dev/null \ - || die "the fleet reader could not read Fleet-OPS prod /asc with the minted token" -say "verified: the fleet reader reads Fleet-OPS prod /asc" +if [ "$install_rc" -ne 0 ]; then exit "$install_rc"; fi +exit "$stage_rc" diff --git a/scripts/ios-ship-workflow.test.mjs b/scripts/ios-ship-workflow.test.mjs index 4b2fe921..9c299c1e 100644 --- a/scripts/ios-ship-workflow.test.mjs +++ b/scripts/ios-ship-workflow.test.mjs @@ -175,18 +175,38 @@ test("ASC credentials come from Fleet-OPS /asc, never the stale repository secre // No new code may talk to the vault directly. assert.doesNotMatch(code, /listSecretsRaw|loginUniversalAuth/); - // The multiline key never enters the step environment. The tooling - // install and the staging script run in ONE step: the install brings the - // fleet's reader onto the runner, the staging script resolves the three - // values environment-first, streams the key over stdin into a mode-600 - // file and removes the ASC_KEY_P8 entry, so only the file's path and the - // two identifiers cross into the later steps. - assert.match(code, /scripts\/stage-asc-key\.mjs/); + // The multiline key never enters the step environment. The tooling and + // the staging script run in ONE step: the tooling brings the fleet's + // reader onto the runner, the staging script resolves the three values + // environment-first, streams the key over stdin into a mode-600 file and + // removes the ASC_KEY_P8 entry, so only the file's path and the two + // identifiers cross into the later steps. + // The step names the tooling, and the tooling names the consumer that + // resolves the values. + assert.match(code, /scripts\/install-release-secrets-tooling\.sh/); // No workflow step reads the vault directly: the reader is the fleet's // own, and the staging script is the only thing that resolves a value. assert.doesNotMatch(code, /node scripts\/infisical-fetch\.mjs/); + // The step is a single call to the tooling, and the tooling runs the + // consumer in the same process tree, so the minted token reaches it + // through its environment and never through a file. assert.match(code, /bash scripts\/install-release-secrets-tooling\.sh/); - assert.match(code, /node scripts\/stage-asc-key\.mjs \|\| stage_rc=\$\?/); + const tooling = readFileSync(join(ROOT, "scripts/install-release-secrets-tooling.sh"), "utf8"); + assert.match(tooling, /node "\$repo_root\/scripts\/stage-asc-key\.mjs"/); + assert.doesNotMatch(tooling, /release-secrets-env\.sh/); + assert.doesNotMatch(tooling, /printf[^\n]*\$(token|\{token\})/); + assert.doesNotMatch(code, /release-secrets-env\.sh/); + assert.match(tooling, /export INFISICAL_TOKEN="\$token"/); + assert.match(tooling, /export INFISICAL_FLEET_OPS_PROJECT_ID="\$project_id"/); + // The consumer runs even when the tooling install fails, and the step + // reports the status of the command that actually failed. + assert.match(tooling, /install_rc=0[\s\S]*install_tooling \|\| install_rc=\$\?/); + assert.match(tooling, /stage_rc=0[\s\S]*stage-asc-key\.mjs"? \|\| stage_rc=\$\?/); + assert.match(tooling, /if \[ "\$install_rc" -ne 0 \]; then exit "\$install_rc"; fi/); + assert.match(tooling, /exit "\$stage_rc"/); + // The tooling is part of the ship, so a change to it runs the ship the + // same way a change to any other ship script does. + assert.match(code, /'scripts\/install-release-secrets-tooling\.sh'/); // The staging script resolves each value from process.env first and only // then through the approved reader, so a credential in the step's own // environment always wins over the vault. diff --git a/scripts/mac-release-workflow.test.mjs b/scripts/mac-release-workflow.test.mjs index c1d7897e..10e26fc3 100644 --- a/scripts/mac-release-workflow.test.mjs +++ b/scripts/mac-release-workflow.test.mjs @@ -63,19 +63,29 @@ test('Sparkle reads the ASC credentials through the fleet reader, never reposito } assert.match(loader, /INFISICAL_PROJECT_ID: \$\{\{ secrets\.INFISICAL_FLEET_OPS_PROJECT_ID \}\}/); assert.doesNotMatch(loader, /echo .*\$\{ASC_INFISICAL_PROJECT_ID\}/); - // The key material must not enter the job environment. The tooling - // install and the staging script run in ONE step: the install brings the - // fleet's reader onto the runner and the staging script writes the private - // key to a mode-600 file. Split across two steps the key would be an - // environment variable of the second one, where every later step, every - // process listing and every environment dump can read it. + // The key material must not enter the job environment. The tooling and the + // staging script run in ONE step: the tooling brings the fleet's reader + // onto the runner and the staging script writes the private key to a + // mode-600 file. Split across two steps the key would be an environment + // variable of the second one, where every later step, every process + // listing and every environment dump can read it. assert.doesNotMatch(loader, /infisical-secrets/); assert.doesNotMatch(loader, /uses: \.\/\.github\/actions/); // No workflow step reads the vault. The reader is the fleet's own, and // the staging script is the only thing that resolves a credential, so the // direct loader must not be called from a workflow at all. assert.doesNotMatch(loader, /node scripts\/infisical-fetch\.mjs/); - assert.match(loader, /run: \|[\s\S]*bash scripts\/install-release-secrets-tooling\.sh[\s\S]*node scripts\/stage-asc-key\.mjs/); + // The step is a single call to the tooling, and the tooling runs the + // consumer in the same process tree, so the minted token reaches it + // through its environment and never through a file. + assert.match(loader, /run: \|[\s\S]*bash scripts\/install-release-secrets-tooling\.sh/); + const tooling = read('scripts/install-release-secrets-tooling.sh'); + assert.match(tooling, /node "\$repo_root\/scripts\/stage-asc-key\.mjs"/); + assert.doesNotMatch(tooling, /release-secrets-env\.sh/); + assert.doesNotMatch(tooling, /printf[^\n]*\$(token|\{token\})/); + assert.doesNotMatch(loader, /release-secrets-env\.sh/); + assert.match(tooling, /export INFISICAL_TOKEN="\$token"/); + assert.match(tooling, /export INFISICAL_FLEET_OPS_PROJECT_ID="\$project_id"/); // The staging script resolves every credential environment-first, so a // value in the job's own environment always wins over the vault. const stage = read('scripts/stage-asc-key.mjs'); @@ -84,13 +94,18 @@ test('Sparkle reads the ASC credentials through the fleet reader, never reposito stage.indexOf('fromProcess.get(name)') < stage.indexOf('getSecret(name'), 'the environment must be looked up before the approved reader', ); - // The staging script runs even when the tooling install fails, so a - // key-bearing entry can never wait in the job's environment file, and the - // step reports the status of the command that actually failed. - assert.match(loader, /bash scripts\/install-release-secrets-tooling\.sh \|\| loader_rc=\$\?/); - assert.match(loader, /node scripts\/stage-asc-key\.mjs \|\| stage_rc=\$\?/); - assert.match(loader, /if \[ "\$loader_rc" -ne 0 \]; then exit "\$loader_rc"; fi/); - assert.match(loader, /exit "\$stage_rc"/); + // The consumer runs even when the tooling install fails, so a key-bearing + // entry can never wait in the job's environment file, and the step reports + // the status of the command that actually failed. + assert.match(tooling, /install_rc=0[\s\S]*install_tooling \|\| install_rc=\$\?/); + assert.match(tooling, /stage_rc=0[\s\S]*stage-asc-key\.mjs"? \|\| stage_rc=\$\?/); + assert.match(tooling, /if \[ "\$install_rc" -ne 0 \]; then exit "\$install_rc"; fi/); + assert.match(tooling, /exit "\$stage_rc"/); + // The tooling is part of the release, so a change to it runs the contract + // test and the release the same way a change to any other release script + // does. + assert.match(code, /'scripts\/install-release-secrets-tooling\.sh'/); + assert.match(iosCode, /'scripts\/install-release-secrets-tooling\.sh'/); // The repository's stale signing secrets are never read. The three // signing credentials are named on purpose: a blanket `secrets.ASC_` would // also catch ASC_STANDING_TESTERS, which is a list of TestFlight tester @@ -117,7 +132,7 @@ test('Sparkle reads the ASC credentials through the fleet reader, never reposito assert.doesNotMatch(file, /infisical secrets get/); assert.doesNotMatch(file, /infisical\/get-cli/); } - assert.match(loader, /node scripts\/stage-asc-key\.mjs/); + assert.match(tooling, /stage-asc-key\.mjs/); assert.match(loader, /Missing \$name in Fleet-OPS prod \/asc; verify the CI identity has read access to Fleet-OPS prod \/asc\./); assert.match(loader, /Missing ASC key material in Fleet-OPS prod \/asc; verify the CI identity has read access to Fleet-OPS prod \/asc\./); assert.match(code, /NOTARY_KEY_PATH: \$\{\{ env.ASC_KEY_PATH \}\}/); diff --git a/scripts/stage-asc-key.mjs b/scripts/stage-asc-key.mjs index 2b94d891..c19eba2a 100644 --- a/scripts/stage-asc-key.mjs +++ b/scripts/stage-asc-key.mjs @@ -223,9 +223,15 @@ export async function run({ * `if: always()`, so a key-bearing entry that stays in the file is a key * that reaches them. The entry is removed, bytes and all; a blank * assignment would leave the earlier bytes in the file for the runner to - * read. */ - const sanitize = () => { - removeEnvironmentEntries(envPath, KEY_ENTRIES); + * read. + * + * A malformed entry for a name this script publishes is removed as well. + * An unterminated block swallows everything the runner reads after it, so + * the identifier a later step needs would never arrive. The fresh value + * is written after the cleanup, which is why only the malformed entries + * go and the values this script publishes stay. */ + const sanitize = (alsoRemove = []) => { + removeEnvironmentEntries(envPath, new Set([...KEY_ENTRIES, ...alsoRemove])); if (env.ASC_KEY_P8 !== undefined) delete env.ASC_KEY_P8; }; @@ -244,27 +250,32 @@ export async function run({ // memory only: once the read is done, the key leaves the file. const { values: fromFile, malformed } = readEnvironmentFile(envPath); const fromProcess = new Map(CREDENTIALS.map((name) => [name, env[name]])); - sanitize(); + // The malformed entries for the names this script publishes are removed + // before it writes its own values, so an unterminated block cannot + // shadow the identifiers the later steps need. + sanitize(CREDENTIALS.filter((name) => malformed.has(name))); /** Environment first, then the approved fallback: `fleet-secrets get`, * captured and never echoed. The result says where the value came - * from, because the verdict on a malformed job environment file only - * belongs to a value that file actually supplied. */ + * from and whether the job's environment file carries an entry for the + * name. The verdict on a malformed file belongs to a name the file + * supplied, and an empty truncated entry counts as the file having + * supplied it, so a corrupted file never passes as a clean one. */ const resolve = async (name) => { const named = fromProcess.get(name); - if (typeof named === 'string' && named.trim() !== '') return { value: named, fromFile: false }; + if (typeof named === 'string' && named.trim() !== '') return { value: named, malformedEntry: false }; const written = fromFile.get(name); - if (typeof written === 'string' && written.trim() !== '') return { value: written, fromFile: true }; + if (typeof written === 'string' && written.trim() !== '') return { value: written, malformedEntry: malformed.has(name) }; const fetched = await getSecret(name, SECRET_FOLDER); - return { value: typeof fetched === 'string' ? fetched : '', fromFile: false }; + return { value: typeof fetched === 'string' ? fetched : '', malformedEntry: malformed.has(name) }; }; const keyIdSrc = await resolve('ASC_KEY_ID'); const issuerIdSrc = await resolve('ASC_ISSUER_ID'); const keySrc = await resolve('ASC_KEY_P8'); - const keyId = checkCredential(log, 'ASC_KEY_ID', keyIdSrc.value, keyIdSrc.fromFile && malformed.has('ASC_KEY_ID')); - const issuerId = checkCredential(log, 'ASC_ISSUER_ID', issuerIdSrc.value, issuerIdSrc.fromFile && malformed.has('ASC_ISSUER_ID')); - const key = checkCredential(log, 'ASC_KEY_P8', keySrc.value, keySrc.fromFile && malformed.has('ASC_KEY_P8')); + const keyId = checkCredential(log, 'ASC_KEY_ID', keyIdSrc.value, keyIdSrc.malformedEntry); + const issuerId = checkCredential(log, 'ASC_ISSUER_ID', issuerIdSrc.value, issuerIdSrc.malformedEntry); + const key = checkCredential(log, 'ASC_KEY_P8', keySrc.value, keySrc.malformedEntry); // The identifiers cross into the later steps, so they are masked first: a // tool that echoes them leaves nothing behind in the public job log. The diff --git a/scripts/stage-asc-key.node-test.mjs b/scripts/stage-asc-key.node-test.mjs index ca693c03..aef3ce70 100644 --- a/scripts/stage-asc-key.node-test.mjs +++ b/scripts/stage-asc-key.node-test.mjs @@ -19,7 +19,6 @@ import { join } from 'node:path'; import test from 'node:test'; import { run } from './stage-asc-key.mjs'; -import { run as loaderRun } from './infisical-fetch.mjs'; // A synthetic fixture, not a credential. It stands in for the private key // so the test can tell a leak from a clean run. @@ -157,18 +156,18 @@ test('the key never survives into a later step', async () => { } }); -test("the loader's key entry is removed from the environment file, not blanked", async () => { +test('a key-bearing entry in the environment file is removed, not blanked', async () => { const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); try { const env = baseEnv(dir); - // The approved loader writes the three values into the job's environment - // file before this script runs. + // A step wrote the three values into the job's environment file before + // this script runs. writeEnvEntry(env.GITHUB_ENV, 'ASC_KEY_ID', 'FILEKEYID'); writeEnvEntry(env.GITHUB_ENV, 'ASC_ISSUER_ID', 'FILEISSUER'); writeEnvEntry(env.GITHUB_ENV, 'ASC_KEY_P8', KEY); const before = readFileSync(env.GITHUB_ENV, 'utf8'); - assert.ok(before.includes(KEY_MARKER), 'the loader wrote the key into the environment file'); + assert.ok(before.includes(KEY_MARKER), 'the key was written into the environment file'); await run({ env, getSecret: stubGetter(), log: () => {} }); @@ -180,59 +179,23 @@ test("the loader's key entry is removed from the environment file, not blanked", } }); -test('the loader-then-staging sequence leaves no key behind when the loader fails', async () => { +test('the seeded environment and the reader fallback leave no key behind', async () => { const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); try { - const env = baseEnv(dir); - // The vault answers with the private key and nothing for the identifier, - // which is the shape of a partial read. The loader writes every value it - // does have into the job's environment file and only then fails on the - // one that is missing, so the key is already in the file when the step - // dies. - const stubFetch = async (url) => { - const u = String(url); - if (u.includes('/api/v1/auth/universal-auth/login')) { - return { ok: true, json: async () => ({ accessToken: 'test-token', expiresIn: 3600 }) }; - } - if (u.includes('/api/v3/secrets/raw')) { - return { - ok: true, - json: async () => ({ - secrets: [{ secretKey: 'ASC_KEY_P8', secretValue: KEY }], - }), - }; - } - throw new Error(`stubFetch: unexpected URL ${u}`); - }; - - await assert.rejects( - loaderRun({ - env: { - ...env, - INFISICAL_NAMES: 'ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8', - INFISICAL_REQUIRED: 'ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8', - INFISICAL_PROJECT_ID: 'project', - INFISICAL_CLIENT_ID: 'client', - INFISICAL_CLIENT_SECRET: 'secret', - }, - fetchImpl: stubFetch, - log: () => {}, - }), - /Missing required Infisical name/, - ); + // The environment carries the identifiers and the approved reader carries + // the key: that is the composition the workflow runs. The reader is + // stubbed at its seam, so nothing here reaches the vault. + const env = baseEnv(dir, { ASC_KEY_ID: 'ENVKEYID', ASC_ISSUER_ID: 'ENVISSUER' }); + const getSecret = stubGetter(); - // The loader wrote the key before it failed: this is the state the - // staging script has to clean up, and the state the workflow leaves - // behind when the fetch fails. - assert.ok( - readFileSync(env.GITHUB_ENV, 'utf8').includes(KEY_MARKER), - 'the loader wrote the key into the environment file before it failed', - ); - - // Now the staging script runs, exactly as the workflow runs it. - await run({ env, getSecret: stubGetter(), log: () => {} }); + await run({ env, getSecret, log: () => {} }); - assertNoKeySurvives(env.GITHUB_ENV); + const imported = assertNoKeySurvives(env.GITHUB_ENV); + assert.equal(imported.ASC_KEY_ID, 'ENVKEYID', 'the identifier the environment carried is the one that crosses forward'); + assert.equal(imported.ASC_ISSUER_ID, 'ENVISSUER'); + assert.ok(imported.ASC_KEY_PATH, 'the path of the mode-600 key file crosses forward'); + // The reader is asked only for the name the environment did not carry. + assert.deepEqual(getSecret.calls, ['ASC_KEY_P8']); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -302,8 +265,32 @@ test('a malformed entry does not reject a credential the environment carries', a !lines.some((l) => l.includes('malformed')), 'the verdict on the file must not reject a credential the environment carries', ); - const written = readFileSync(env.GITHUB_ENV, 'utf8'); - assert.match(written, /ENVKEYID/, 'the credential the environment carried is the one that crosses forward'); + // The truncated block is removed before the fresh value is written, so a + // later step is handed the identifier instead of a file it cannot read + // past. + const imported = importEnvironmentFile(env.GITHUB_ENV); + assert.equal(imported.ASC_KEY_ID, 'ENVKEYID', 'a later step is handed the identifier the environment carried'); + assert.equal(imported.ASC_ISSUER_ID, 'STUBISSUER'); + assert.ok(imported.ASC_KEY_PATH, 'the path of the mode-600 key file crosses forward'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('an empty truncated entry fails closed instead of passing as a clean file', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); + try { + const env = baseEnv(dir); + // The block is truncated and empty: the file carries an entry for the + // name, but no usable value. The reader can supply one, and the file is + // still corrupt, so the step fails closed. + writeFileSync(env.GITHUB_ENV, 'ASC_KEY_ID< lines.push(l) }), + /malformed in the job environment file/, + ); + assertNoKeySurvives(env.GITHUB_ENV); } finally { rmSync(dir, { recursive: true, force: true }); } From 4340751c824b385fe51e9dbec299391c2d6dc335 Mon Sep 17 00:00:00 2001 From: MiniMax Date: Wed, 21 Oct 2026 09:41:22 -0500 Subject: [PATCH 31/39] test(release): prove the consumer runs after an installer failure The review note asked for a focused executable regression for an installer failure followed by cleanup, rather than structural assertions about the order of the two commands. The installer is allowed to fail and the consumer still has to run, because the consumer is what removes a key-bearing entry from the job's environment file. The step then reports the status of the command that actually failed, so a failed install is never reported as a clean run. The new test runs the real installer with a stub curl on PATH, so the pinned download cannot succeed and nothing reaches the network. It asserts four things: the step fails, the failure names what failed, the key-bearing entry is gone from the environment file and a later step is handed no key, and the key bytes reach neither stdout nor stderr. It also asserts that the consumer ran, by checking that the staged key path crossed forward. The test was checked against a broken installer, where the consumer is skipped after a failed install, and it fails there, so it is not vacuous. The installer was restored byte-identical afterwards. --- scripts/stage-asc-key.node-test.mjs | 75 +++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/scripts/stage-asc-key.node-test.mjs b/scripts/stage-asc-key.node-test.mjs index aef3ce70..e53cc21b 100644 --- a/scripts/stage-asc-key.node-test.mjs +++ b/scripts/stage-asc-key.node-test.mjs @@ -17,6 +17,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, wri import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import { run } from './stage-asc-key.mjs'; @@ -111,6 +112,19 @@ function assertNoKeySurvives(envPath) { return imported; } +/** Run a shell script and collect what it said and how it ended. Used for + * the installer, which is a shell script and not a function. */ +function runScript(script, { env }) { + return new Promise((resolve) => { + const child = spawn('bash', [script], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => { stdout += d; }); + child.stderr.on('data', (d) => { stderr += d; }); + child.on('close', (code) => resolve({ code, stdout, stderr })); + }); +} + test('the key is staged to a mode-600 file and only its path is published', async () => { const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); try { @@ -455,3 +469,64 @@ test('an unresolved Infisical reference is rejected before anything is staged', rmSync(dir, { recursive: true, force: true }); } }); + +/** The installer, run for real, with the install made to fail. + * + * The install is allowed to fail and the consumer still has to run, because + * the consumer is what removes a key-bearing entry from the job's environment + * file. The step then reports the status of the command that actually + * failed, so a failed install is never reported as a clean run. + * + * The install is made to fail with a stub `curl` on PATH, so nothing here + * reaches the network and the pinned download cannot succeed. The credential + * the consumer needs is already in the job's environment file, which is the + * case this path exists for: a key was written by an earlier step, the + * tooling could not be installed, and the key must still not survive. + */ +test('an installer failure still runs the consumer and clears the key', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-install-')); + try { + const envFile = join(dir, 'env'); + writeEnvEntry(envFile, 'ASC_KEY_ID', 'FILEKEYID'); + writeEnvEntry(envFile, 'ASC_ISSUER_ID', 'FILEISSUER'); + writeEnvEntry(envFile, 'ASC_KEY_P8', KEY); + + // A stub curl that always fails: the pinned download cannot succeed. + const stubDir = join(dir, 'stub'); + mkdirSync(stubDir, { recursive: true }); + writeFileSync(join(stubDir, 'curl'), '#!/usr/bin/env bash\nexit 22\n', { mode: 0o755 }); + + const home = join(dir, 'home'); + mkdirSync(home, { recursive: true }); + const staging = join(dir, 'staging'); + mkdirSync(staging, { recursive: true }); + + const result = await runScript(fileURLToPath(new URL('install-release-secrets-tooling.sh', import.meta.url)), { + env: { + ...process.env, + HOME: home, + PATH: `${stubDir}:${process.env.PATH}`, + GITHUB_ENV: envFile, + ASC_STAGING_ROOT: staging, + INFISICAL_PROJECT_ID: 'test-project-id', + INFISICAL_CLIENT_ID: 'test-client-id', + INFISICAL_CLIENT_SECRET: 'test-client-secret', + }, + }); + + // The install failed, and the step says so instead of reporting success. + assert.notEqual(result.code, 0, 'a failed install must fail the step'); + assert.match(result.stderr, /could not be downloaded/, 'the failure must name what failed'); + + // The consumer ran anyway: it staged the key, so the path crossed + // forward, and the key-bearing entry is out of the environment file. + const imported = assertNoKeySurvives(envFile); + assert.ok(imported.ASC_KEY_PATH, 'the consumer has to run, so the key was staged'); + + // And the key never reached the log. + assert.ok(!result.stdout.includes(KEY_MARKER), 'the key bytes must not reach stdout'); + assert.ok(!result.stderr.includes(KEY_MARKER), 'the key bytes must not reach stderr'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From 179ca58d1e09116bf0bce99d6eb846a7abf0a24b Mon Sep 17 00:00:00 2001 From: MM Date: Wed, 21 Oct 2026 10:13:31 -0500 Subject: [PATCH 32/39] test(release): mark the synthetic key fixtures as scanner-approved The secret scanner reads a key-shaped block when one BEGIN marker and a later END marker sit close enough together. These two fixtures are synthetic and are never a credential, but a squash merge lands the whole file in one commit, so the scanner sees both markers at once and reports the file. The allow marker says so where the fixture is. --- scripts/stage-asc-key.node-test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/stage-asc-key.node-test.mjs b/scripts/stage-asc-key.node-test.mjs index e53cc21b..f115148d 100644 --- a/scripts/stage-asc-key.node-test.mjs +++ b/scripts/stage-asc-key.node-test.mjs @@ -24,7 +24,7 @@ import { run } from './stage-asc-key.mjs'; // A synthetic fixture, not a credential. It stands in for the private key // so the test can tell a leak from a clean run. const KEY_MARKER = 'SYNTHETIC-MULTILINE-FIXTURE-DO-NOT-USE'; -const KEY = `-----BEGIN PRIVATE KEY-----\n${KEY_MARKER}\n-----END PRIVATE KEY-----`; +const KEY = `-----BEGIN PRIVATE KEY-----\n${KEY_MARKER}\n-----END PRIVATE KEY-----`; // gitleaks:allow const VAULT = { ASC_KEY_ID: 'STUBKEYID', ASC_ISSUER_ID: 'STUBISSUER', @@ -370,7 +370,7 @@ test('the environment is looked up first, before the approved fallback', async ( const env = baseEnv(dir, { ASC_KEY_ID: 'ENVKEYID', ASC_ISSUER_ID: 'ENVISSUER', - ASC_KEY_P8: `-----BEGIN PRIVATE KEY-----\nENVIRONMENT-COPY\n-----END PRIVATE KEY-----`, + ASC_KEY_P8: `-----BEGIN PRIVATE KEY-----\nENVIRONMENT-COPY\n-----END PRIVATE KEY-----`, // gitleaks:allow }); const getter = stubGetter(); await run({ env, getSecret: getter, log: () => {} }); From 06abad337148dd66dd16b53bd40c94b72f718426 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:53:51 -0500 Subject: [PATCH 33/39] Prove the fleet reader before the consumer runs The consumer reads a failed lookup as an empty value, so a missing reader, a broken install and a token the vault rejects all read as "the credential is missing". The operator was sent to fix vault permissions when the fault was the tooling, which is the credential-fetch failure this branch set out to fix. The tooling now proves the reader with the minted token before it stages anything. The proof is read-only: `fleet-secrets names --path /asc` lists names and never a value. Its exit code says which fault it is, because the reader's own codes are the fault: 2 is a refused configuration, 4 is a fetch or auth failure and 5 is an unresolved reference in the vault. A reference is a credential fault rather than a tooling fault, and the consumer names it precisely, so it does not fail this check. The check also runs on its own, with a token the caller already holds: bash scripts/install-release-secrets-tooling.sh check-reader The contract test drives it with a stubbed reader, so every failure mode is covered: a rejected token, a refused configuration, an unresolved reference, a working reader and no reader at all. The test was proven non-vacuous: the success branch was broken, the test failed with "a reader that cannot authenticate must fail the check", and the file was restored byte-identically afterwards. The token-print guard now names every output helper the script has. The old guard matched a literal `printf` on the same line as `$token`, so a regression that printed the token through `say` or `fail` passed the test while leaking the token into the public job log. The widened guard fails on exactly that regression, and the old guard was confirmed to pass the same leak, so the widening is what closes it. docs/AUTO-UPDATE.md now names the tooling script and the fleet reader, says the ASC_KEY_P8 entry is removed rather than blanked, and says that the file's path and both identifiers cross the step boundary. --- docs/AUTO-UPDATE.md | 4 +- scripts/install-release-secrets-tooling.sh | 81 +++++++++++++++++++++- scripts/ios-ship-workflow.test.mjs | 6 +- scripts/mac-release-workflow.test.mjs | 16 ++++- scripts/stage-asc-key.node-test.mjs | 67 +++++++++++++++++- 5 files changed, 165 insertions(+), 9 deletions(-) diff --git a/docs/AUTO-UPDATE.md b/docs/AUTO-UPDATE.md index f36c1897..00b8b110 100644 --- a/docs/AUTO-UPDATE.md +++ b/docs/AUTO-UPDATE.md @@ -54,7 +54,7 @@ The EdDSA key pair is per app, so one app's key cannot sign another app's update - Stored as the repository secret `SPARKLE_ED_PRIVATE_KEY` by piping the file into `gh secret set`. It reaches `generate_appcast` on standard input, never as an argument. - The public key is `Ou2J0syHZawPSY3JLTLVyhbOylmtyr0QnZPbq7acETQ=`, in `script/build_and_run.sh`. -The App Store Connect signing key is a separate key, and CI reads it from Fleet-OPS prod `/asc` rather than from a repository secret. The repository copy of that key was revoked (HTTP 401, CC-163), which is what kept the Mac release from publishing for nine days. In CI the values come from the `infisical-secrets` composite action, which masks every line of every value and never prints one; the project id comes from the `INFISICAL_FLEET_OPS_PROJECT_ID` secret rather than from a literal in a public workflow. The multiline private key arrives in the step environment from the `infisical-secrets` action, is streamed over stdin into a mode-600 file by `scripts/stage-asc-key.mjs`, and the `ASC_KEY_P8` environment entry is blanked in the same step, so only that file's path crosses into the later steps. The same loader is used by `mac-release.yml` and `ios-ship.yml`, and `scripts/mac-release-workflow.test.mjs` pins the two to the same text. +The App Store Connect signing key is a separate key, and CI reads it from Fleet-OPS prod `/asc` rather than from a repository secret. The repository copy of that key was revoked (HTTP 401, CC-163), which is what kept the Mac release from publishing for nine days. In CI the values come from the fleet's own reader, `fleet-secrets`, which `scripts/install-release-secrets-tooling.sh` installs onto the runner together with the pinned Infisical CLI. That script mints an Infisical access token with universal auth, proves the reader can list Fleet-OPS prod `/asc` with that token, and only then runs `scripts/stage-asc-key.mjs`, the one thing in the release that resolves a value. The reader is proved first on purpose: the consumer reads a failed lookup as an empty value, so a broken reader and a rejected token would otherwise both look like a missing credential. The token is minted into a shell variable and never reaches a file, `$GITHUB_ENV` or the log; the project id comes from the `INFISICAL_FLEET_OPS_PROJECT_ID` secret rather than from a literal in a public workflow. The multiline private key is streamed over stdin into a mode-600 file by `scripts/stage-asc-key.mjs`, and the `ASC_KEY_P8` entry is removed from the job's environment file in the same step, so only that file's path and the two identifiers cross into the later steps. The same loader is used by `mac-release.yml` and `ios-ship.yml`, and `scripts/mac-release-workflow.test.mjs` pins the two to the same text. The reader can also be proved on its own, with a token the caller already holds, by running `bash scripts/install-release-secrets-tooling.sh check-reader`. To check that the release credentials still work without publishing anything, run the "Mac Release (Sparkle)" workflow by hand with the `verify-only` input set. It fetches the key, asks Apple's notarization service whether it still accepts it, and stops. @@ -71,7 +71,7 @@ A private repository cannot do this, because anonymous clients cannot download i | Name | Where | Status on Tue, Sep 29, 2026 | |---|---|---| | `MAC_CERT_P12_BASE64`, `MAC_CERT_PASSWORD` | Infisical (the way BotFleet's `release.yml` reads them), needing `INFISICAL_PROJECT_ID` and `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID`/`_SECRET` (or `INFISICAL_CLIENT_ID`/`_SECRET`) repository secrets; or same-named repository secrets as the fallback | Missing. Publishing is gated until they exist. | -| `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_P8` | Fleet-OPS Infisical prod `/asc`, using the same loader as `ios-ship.yml`. The stale repository copies are never read. The PEM arrives in the step environment from the `infisical-secrets` action and is streamed over stdin by `scripts/stage-asc-key.mjs` into a mode-600 temporary file, and the `ASC_KEY_P8` environment entry is blanked in the same step, so only the file's path crosses into the later steps. | Notarization access is verified with read-only `notarytool history` before building. Artifact acceptance is still required separately. | +| `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_P8` | Fleet-OPS Infisical prod `/asc`, read by the fleet's `fleet-secrets` reader that `scripts/install-release-secrets-tooling.sh` installs onto the runner. The same loader serves `ios-ship.yml`. The stale repository copies are never read. The reader is proved against the minted token before anything is staged, so a broken reader is reported as a reader fault rather than as a missing credential. The PEM is streamed over stdin by `scripts/stage-asc-key.mjs` into a mode-600 temporary file, and the `ASC_KEY_P8` environment entry is removed from the job's environment file in the same step, so only the file's path and the two identifiers cross into the later steps. | Notarization access is verified with read-only `notarytool history` before building. Artifact acceptance is still required separately. | | `SPARKLE_ED_PRIVATE_KEY` | Repository secret | Present. | Pull requests skip credential loading, perform ad-hoc structural validation and never publish. On main, a missing Infisical identity, missing fleet ASC material, or failed notarization preflight fails the job before publication. If fleet ASC loading succeeds but certificate or Sparkle signing material is missing, the publication gate reports the missing names and runs the existing ad-hoc build-only validation. None of these non-publishing paths updates the feed. diff --git a/scripts/install-release-secrets-tooling.sh b/scripts/install-release-secrets-tooling.sh index dbd6a2d5..ff733645 100755 --- a/scripts/install-release-secrets-tooling.sh +++ b/scripts/install-release-secrets-tooling.sh @@ -13,15 +13,22 @@ # public AI-Fleet-Coordinator repository and installed with the upstream # installer, so the runner ends up with the layout every fleet Mac has. # -# It then mints an Infisical access token with universal auth and runs -# scripts/stage-asc-key.mjs, the only thing in the release that reads a -# credential. +# It then mints an Infisical access token with universal auth, proves that the +# fleet's reader can use that token, and runs scripts/stage-asc-key.mjs, the +# only thing in the release that reads a credential. # # The token never touches a file. It is minted into a shell variable, it is # exported into the environment of the consumer, and the consumer runs in this # same process tree. It is never echoed, never printed, never written to # $GITHUB_ENV and never written to disk. # +# The reader is proven before the consumer runs, because the consumer cannot +# tell a broken reader from a missing credential: it reads a failed lookup as +# an empty value, so a broken tool and a rejected token both read as "the +# credential is missing", and the operator is sent to fix vault permissions +# when the fault is the tooling. The proof is read-only: it lists the names +# in Fleet-OPS prod /asc and never a value. +# # The names the workflow already uses are mapped here onto the names the # reader and the CLI expect: # @@ -181,12 +188,80 @@ mint_token() { say "minted: an Infisical access token for the consumer" } +# The reader is proven before the consumer runs. The consumer reads a failed +# lookup as an empty value, so a missing reader, a broken install and a token +# the vault rejects would all read as "the credential is missing" and send the +# operator to fix vault permissions when the fault is the tooling. This check +# names the fault instead. +# +# The proof is read-only and lists names, never values: `fleet-secrets names +# --path /asc` is the fleet's own reader, and it carries the minted token +# through INFISICAL_TOKEN. +# +# The reader's exit codes are its own: 2 is a refused configuration, 4 is a +# fetch or auth failure and 5 is an unresolved reference in the vault. A +# reference is a credential fault, not a tooling fault, and the consumer names +# it precisely, so it does not fail this check. +check_reader() { + local reader status detail + reader="${FLEET_SECRETS:-}" + if [ -z "$reader" ]; then + reader="$(command -v fleet-secrets || true)" + fi + if [ -z "$reader" ]; then + fail "the fleet-secrets reader is not on PATH; the reader is at fault, not the vault permissions" + return 1 + fi + status=0 + "$reader" names --path /asc >"$work/names.out" 2>"$work/names.err" || status=$? + if [ "$status" -eq 0 ]; then + say "checked: the fleet-secrets reader can read Fleet-OPS prod /asc" + return 0 + fi + if [ "$status" -eq 5 ]; then + # The fetch reached the vault, so the reader and the token both work and + # the fault is in the vault's content. The consumer reports it. + say "checked: the fleet-secrets reader reached Fleet-OPS prod /asc; a value there is an unresolved Infisical reference" + return 0 + fi + detail="$(scrub "$(cat "$work/names.err" 2>/dev/null || true)")" + detail="${detail:-the reader reported no detail}" + case "$status" in + 2) + fail "the fleet-secrets reader refused to read Fleet-OPS prod /asc (exit 2); the reader or its configuration is at fault, not the vault permissions: ${detail}" + ;; + 4) + fail "the fleet-secrets reader could not read Fleet-OPS prod /asc (exit 4); the minted Infisical access token was rejected or the vault was unreachable: ${detail}" + ;; + *) + fail "the fleet-secrets reader failed to list Fleet-OPS prod /asc (exit ${status}); the reader is at fault: ${detail}" + ;; + esac + return 1 +} + install_tooling() { install_infisical_cli || return 1 install_fleet_secrets || return 1 mint_token || return 1 + check_reader || return 1 } +# The reader can be proved on its own, with a token the caller already holds. +# An operator runs it to test a reader or a token without running a release +# step, and the contract test drives it with a stubbed reader: +# +# INFISICAL_TOKEN=... INFISICAL_FLEET_OPS_PROJECT_ID=... \ +# bash scripts/install-release-secrets-tooling.sh check-reader +# +# Without an argument the script does the whole release job: install the +# tooling, mint the token, prove the reader and run the consumer. +if [ "${1:-}" = "check-reader" ]; then + export PATH="$TOOLS_DIR:$PATH" + check_reader || exit 1 + exit 0 +fi + # The install is allowed to fail and the consumer still runs: the consumer is # what removes any key-bearing entry from the job's environment file, and the # step reports the status of the command that actually failed. diff --git a/scripts/ios-ship-workflow.test.mjs b/scripts/ios-ship-workflow.test.mjs index 9c299c1e..0cbc694e 100644 --- a/scripts/ios-ship-workflow.test.mjs +++ b/scripts/ios-ship-workflow.test.mjs @@ -194,7 +194,11 @@ test("ASC credentials come from Fleet-OPS /asc, never the stale repository secre const tooling = readFileSync(join(ROOT, "scripts/install-release-secrets-tooling.sh"), "utf8"); assert.match(tooling, /node "\$repo_root\/scripts\/stage-asc-key\.mjs"/); assert.doesNotMatch(tooling, /release-secrets-env\.sh/); - assert.doesNotMatch(tooling, /printf[^\n]*\$(token|\{token\})/); + // The minted token must never reach the log. The guard names every output + // helper the script has, because a regression that printed through `say` or + // `fail` would leak the token while a guard that only matched `printf` + // still passed. + assert.doesNotMatch(tooling, /\b(say|echo|printf|fail)\b[^\n]*\$(token|\{token\})/); assert.doesNotMatch(code, /release-secrets-env\.sh/); assert.match(tooling, /export INFISICAL_TOKEN="\$token"/); assert.match(tooling, /export INFISICAL_FLEET_OPS_PROJECT_ID="\$project_id"/); diff --git a/scripts/mac-release-workflow.test.mjs b/scripts/mac-release-workflow.test.mjs index 10e26fc3..5057e9f7 100644 --- a/scripts/mac-release-workflow.test.mjs +++ b/scripts/mac-release-workflow.test.mjs @@ -82,10 +82,24 @@ test('Sparkle reads the ASC credentials through the fleet reader, never reposito const tooling = read('scripts/install-release-secrets-tooling.sh'); assert.match(tooling, /node "\$repo_root\/scripts\/stage-asc-key\.mjs"/); assert.doesNotMatch(tooling, /release-secrets-env\.sh/); - assert.doesNotMatch(tooling, /printf[^\n]*\$(token|\{token\})/); + // The minted token must never reach the log. The guard names every output + // helper the script has, because a regression that printed through `say` or + // `fail` would leak the token while a guard that only matched `printf` + // still passed. + assert.doesNotMatch(tooling, /\b(say|echo|printf|fail)\b[^\n]*\$(token|\{token\})/); assert.doesNotMatch(loader, /release-secrets-env\.sh/); assert.match(tooling, /export INFISICAL_TOKEN="\$token"/); assert.match(tooling, /export INFISICAL_FLEET_OPS_PROJECT_ID="\$project_id"/); + // The reader is proved before the consumer runs. The consumer reads a + // failed lookup as an empty value, so without this check a broken reader + // and a rejected token both read as a missing credential, and the operator + // is sent to fix vault permissions when the fault is the tooling. + assert.match(tooling, /check_reader/); + assert.match(tooling, /names --path \/asc/); + assert.ok( + tooling.indexOf('check_reader || return 1') < tooling.indexOf('node "$repo_root/scripts/stage-asc-key.mjs"'), + 'the reader has to be proved before the consumer runs', + ); // The staging script resolves every credential environment-first, so a // value in the job's own environment always wins over the vault. const stage = read('scripts/stage-asc-key.mjs'); diff --git a/scripts/stage-asc-key.node-test.mjs b/scripts/stage-asc-key.node-test.mjs index f115148d..c2cee34e 100644 --- a/scripts/stage-asc-key.node-test.mjs +++ b/scripts/stage-asc-key.node-test.mjs @@ -114,9 +114,9 @@ function assertNoKeySurvives(envPath) { /** Run a shell script and collect what it said and how it ended. Used for * the installer, which is a shell script and not a function. */ -function runScript(script, { env }) { +function runScript(script, { env, args = [] }) { return new Promise((resolve) => { - const child = spawn('bash', [script], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn('/bin/bash', [script, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (d) => { stdout += d; }); @@ -125,6 +125,17 @@ function runScript(script, { env }) { }); } +/** A stub reader that fails the way the fleet's reader fails. The fleet + * reader's exit codes are its own: 2 is a refused configuration, 4 is a fetch + * or auth failure and 5 is an unresolved reference in the vault. */ +function writeStubReader(dir, exitCode, message) { + const stubDir = join(dir, 'reader'); + mkdirSync(stubDir, { recursive: true }); + const path = join(stubDir, 'fleet-secrets'); + writeFileSync(path, `#!/bin/bash\nprintf '%s\\n' ${JSON.stringify(message)} >&2\nexit ${exitCode}\n`, { mode: 0o755 }); + return path; +} + test('the key is staged to a mode-600 file and only its path is published', async () => { const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); try { @@ -530,3 +541,55 @@ test('an installer failure still runs the consumer and clears the key', async () rmSync(dir, { recursive: true, force: true }); } }); + +test('a broken reader is reported as a reader fault, not as a missing credential', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-reader-')); + try { + const script = fileURLToPath(new URL('install-release-secrets-tooling.sh', import.meta.url)); + const home = join(dir, 'home'); + mkdirSync(home, { recursive: true }); + + // The path holds bash and the coreutils and nothing else, so the reader + // is only ever the stub this test names. + const base = { + ...process.env, + HOME: home, + PATH: '/usr/bin:/bin', + INFISICAL_TOKEN: 'test-token', + INFISICAL_FLEET_OPS_PROJECT_ID: '11111111-2222-3333-4444-555555555555', + }; + + // A reader that cannot authenticate: the token is at fault, and the + // message has to say so instead of blaming the vault permissions. + const rejected = writeStubReader(dir, 4, 'fleet-secrets: fetch-failed: exit-1'); + const rejectedResult = await runScript(script, { env: { ...base, FLEET_SECRETS: rejected }, args: ['check-reader'] }); + assert.notEqual(rejectedResult.code, 0, 'a reader that cannot authenticate must fail the check'); + assert.match(rejectedResult.stderr, /access token was rejected/, 'the message must name the token'); + assert.doesNotMatch(rejectedResult.stderr, /Missing ASC_KEY_ID/, 'a reader fault must not read as a missing credential'); + + // A reader that refuses the request: the tooling is at fault. + const refused = writeStubReader(dir, 2, 'fleet-secrets: refusing Infisical environment'); + const refusedResult = await runScript(script, { env: { ...base, FLEET_SECRETS: refused }, args: ['check-reader'] }); + assert.notEqual(refusedResult.code, 0, 'a refused reader must fail the check'); + assert.match(refusedResult.stderr, /reader or its configuration is at fault/, 'the message must name the reader'); + + // An unresolved reference is a credential fault and the consumer names + // it, so the check does not blame the tooling for it. + const unresolved = writeStubReader(dir, 5, 'fleet-secrets: unresolved-reference: ASC_KEY_P8 came back as an unexpanded Infisical reference'); + const unresolvedResult = await runScript(script, { env: { ...base, FLEET_SECRETS: unresolved }, args: ['check-reader'] }); + assert.equal(unresolvedResult.code, 0, 'an unresolved reference is not a reader fault'); + + // A working reader passes. + const working = writeStubReader(dir, 0, ''); + const workingResult = await runScript(script, { env: { ...base, FLEET_SECRETS: working }, args: ['check-reader'] }); + assert.equal(workingResult.code, 0, 'a working reader must pass the check'); + assert.match(workingResult.stdout, /can read Fleet-OPS prod \/asc/, 'a passing check says so'); + + // No reader at all is a tooling fault, and it is named as one. + const missingResult = await runScript(script, { env: base, args: ['check-reader'] }); + assert.notEqual(missingResult.code, 0, 'a missing reader must fail the check'); + assert.match(missingResult.stderr, /reader is not on PATH/, 'the message must name the reader'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From c2d96a78ff424b6fba0d0cf7424adaa1ea986a44 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:58:24 -0500 Subject: [PATCH 34/39] test(release): pin the install-failure state the workflows actually reach The installer-failure case seeded ASC_KEY_ID and ASC_ISSUER_ID into the job's environment file, so the consumer staged the key and succeeded. No release workflow step writes those names before the installer runs, and after a failed install there is no reader and no token, so the real consumer fails on the first identifier. The case now seeds only the key, stubs the reader so a reader installed on the machine running the suite is never reached, and asserts the production shape: the consumer reports the missing ASC_KEY_ID, the key-bearing entry is still removed, and no key path crosses forward. Co-Authored-By: Claude --- scripts/stage-asc-key.node-test.mjs | 60 +++++++++++++++++++---------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/scripts/stage-asc-key.node-test.mjs b/scripts/stage-asc-key.node-test.mjs index c2cee34e..211beac9 100644 --- a/scripts/stage-asc-key.node-test.mjs +++ b/scripts/stage-asc-key.node-test.mjs @@ -489,50 +489,68 @@ test('an unresolved Infisical reference is rejected before anything is staged', * failed, so a failed install is never reported as a clean run. * * The install is made to fail with a stub `curl` on PATH, so nothing here - * reaches the network and the pinned download cannot succeed. The credential - * the consumer needs is already in the job's environment file, which is the - * case this path exists for: a key was written by an earlier step, the - * tooling could not be installed, and the key must still not survive. + * reaches the network and the pinned download cannot succeed. + * + * The state is the one the release workflows reach after a failed install. + * No step before the installer writes ASC_KEY_ID or ASC_ISSUER_ID into the + * job's environment file, and with the install failed there is no working + * reader and no minted token, so the consumer cannot resolve the identifiers + * and fails on the first one. Only the key is seeded, because a key-bearing + * entry is what the consumer has to remove even when it fails: the key must + * not survive into the steps that follow. */ test('an installer failure still runs the consumer and clears the key', async () => { const dir = mkdtempSync(join(tmpdir(), 'asc-install-')); try { const envFile = join(dir, 'env'); - writeEnvEntry(envFile, 'ASC_KEY_ID', 'FILEKEYID'); - writeEnvEntry(envFile, 'ASC_ISSUER_ID', 'FILEISSUER'); writeEnvEntry(envFile, 'ASC_KEY_P8', KEY); // A stub curl that always fails: the pinned download cannot succeed. + // A stub reader that fails the way a reader with no token fails (exit 4), + // so the consumer's fallback never reaches a reader this machine happens + // to have installed. const stubDir = join(dir, 'stub'); mkdirSync(stubDir, { recursive: true }); writeFileSync(join(stubDir, 'curl'), '#!/usr/bin/env bash\nexit 22\n', { mode: 0o755 }); + const reader = join(stubDir, 'fleet-secrets'); + writeFileSync(reader, '#!/usr/bin/env bash\nexit 4\n', { mode: 0o755 }); const home = join(dir, 'home'); mkdirSync(home, { recursive: true }); const staging = join(dir, 'staging'); mkdirSync(staging, { recursive: true }); - const result = await runScript(fileURLToPath(new URL('install-release-secrets-tooling.sh', import.meta.url)), { - env: { - ...process.env, - HOME: home, - PATH: `${stubDir}:${process.env.PATH}`, - GITHUB_ENV: envFile, - ASC_STAGING_ROOT: staging, - INFISICAL_PROJECT_ID: 'test-project-id', - INFISICAL_CLIENT_ID: 'test-client-id', - INFISICAL_CLIENT_SECRET: 'test-client-secret', - }, - }); + const env = { + ...process.env, + HOME: home, + PATH: `${stubDir}:${process.env.PATH}`, + FLEET_SECRETS: reader, + GITHUB_ENV: envFile, + ASC_STAGING_ROOT: staging, + INFISICAL_PROJECT_ID: 'test-project-id', + INFISICAL_CLIENT_ID: 'test-client-id', + INFISICAL_CLIENT_SECRET: 'test-client-secret', + }; + // Nothing from the shell that runs the suite may stand in for the + // credentials or the token the failed install never produced. + for (const name of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_KEY_P8', 'INFISICAL_TOKEN']) delete env[name]; + + const result = await runScript(fileURLToPath(new URL('install-release-secrets-tooling.sh', import.meta.url)), { env }); // The install failed, and the step says so instead of reporting success. assert.notEqual(result.code, 0, 'a failed install must fail the step'); assert.match(result.stderr, /could not be downloaded/, 'the failure must name what failed'); - // The consumer ran anyway: it staged the key, so the path crossed - // forward, and the key-bearing entry is out of the environment file. + // The consumer ran anyway: it could not resolve the identifiers, so it + // said which one was missing, and it still took the key-bearing entry out + // of the environment file on its way out. + assert.match( + result.stderr, + /Missing ASC_KEY_ID in Fleet-OPS prod \/asc/, + 'the consumer has to run, and it fails on the identifier the workflow never supplied', + ); const imported = assertNoKeySurvives(envFile); - assert.ok(imported.ASC_KEY_PATH, 'the consumer has to run, so the key was staged'); + assert.equal(imported.ASC_KEY_PATH, undefined, 'nothing was staged, so no key path crosses forward'); // And the key never reached the log. assert.ok(!result.stdout.includes(KEY_MARKER), 'the key bytes must not reach stdout'); From ac72809b2626a2264dff80719979616bbab25356 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:24:41 -0500 Subject: [PATCH 35/39] fix(release): make the reader proof name the real fault Five review findings on the reader proof, fixed together. - The consumer kept the reader's exit 5 nowhere: `fleet-secrets get` exits 5 and prints nothing for an unresolved Infisical reference, and the fallback turned that into an empty value, so the step blamed the read permissions. The fallback now returns a distinct marker for exit 5 and the consumer reports the unresolved reference. - The proof listed the names in Fleet-OPS prod /asc and never read the list. It now fails, naming the credential, when ASC_KEY_ID, ASC_ISSUER_ID or ASC_KEY_P8 is not there. - The standalone check-reader mode now names an absent INFISICAL_TOKEN instead of reporting it as a rejected token. - The reader's error text is scrubbed of the access token as well as the client id and secret before it reaches the log. - The order assertion in the Mac release contract test now checks the call site, install before consumer, and that the install proves the reader after minting the token, instead of matching the guard in the function body. Each fix has a test that fails without it. docs/AUTO-UPDATE.md describes the proof as it now is. Co-Authored-By: Claude --- docs/AUTO-UPDATE.md | 2 +- scripts/install-release-secrets-tooling.sh | 36 ++++++++-- scripts/mac-release-workflow.test.mjs | 17 +++-- scripts/stage-asc-key.mjs | 31 +++++++-- scripts/stage-asc-key.node-test.mjs | 81 ++++++++++++++++++++-- 5 files changed, 143 insertions(+), 24 deletions(-) diff --git a/docs/AUTO-UPDATE.md b/docs/AUTO-UPDATE.md index 00b8b110..3accddb2 100644 --- a/docs/AUTO-UPDATE.md +++ b/docs/AUTO-UPDATE.md @@ -54,7 +54,7 @@ The EdDSA key pair is per app, so one app's key cannot sign another app's update - Stored as the repository secret `SPARKLE_ED_PRIVATE_KEY` by piping the file into `gh secret set`. It reaches `generate_appcast` on standard input, never as an argument. - The public key is `Ou2J0syHZawPSY3JLTLVyhbOylmtyr0QnZPbq7acETQ=`, in `script/build_and_run.sh`. -The App Store Connect signing key is a separate key, and CI reads it from Fleet-OPS prod `/asc` rather than from a repository secret. The repository copy of that key was revoked (HTTP 401, CC-163), which is what kept the Mac release from publishing for nine days. In CI the values come from the fleet's own reader, `fleet-secrets`, which `scripts/install-release-secrets-tooling.sh` installs onto the runner together with the pinned Infisical CLI. That script mints an Infisical access token with universal auth, proves the reader can list Fleet-OPS prod `/asc` with that token, and only then runs `scripts/stage-asc-key.mjs`, the one thing in the release that resolves a value. The reader is proved first on purpose: the consumer reads a failed lookup as an empty value, so a broken reader and a rejected token would otherwise both look like a missing credential. The token is minted into a shell variable and never reaches a file, `$GITHUB_ENV` or the log; the project id comes from the `INFISICAL_FLEET_OPS_PROJECT_ID` secret rather than from a literal in a public workflow. The multiline private key is streamed over stdin into a mode-600 file by `scripts/stage-asc-key.mjs`, and the `ASC_KEY_P8` entry is removed from the job's environment file in the same step, so only that file's path and the two identifiers cross into the later steps. The same loader is used by `mac-release.yml` and `ios-ship.yml`, and `scripts/mac-release-workflow.test.mjs` pins the two to the same text. The reader can also be proved on its own, with a token the caller already holds, by running `bash scripts/install-release-secrets-tooling.sh check-reader`. +The App Store Connect signing key is a separate key, and CI reads it from Fleet-OPS prod `/asc` rather than from a repository secret. The repository copy of that key was revoked (HTTP 401, CC-163), which is what kept the Mac release from publishing for nine days. In CI the values come from the fleet's own reader, `fleet-secrets`, which `scripts/install-release-secrets-tooling.sh` installs onto the runner together with the pinned Infisical CLI. That script mints an Infisical access token with universal auth, proves the reader can list Fleet-OPS prod `/asc` with that token and that the list holds all three names, and only then runs `scripts/stage-asc-key.mjs`, the one thing in the release that resolves a value. The reader is proved first on purpose: the consumer reads a failed lookup as an empty value, so a broken reader, a rejected token and a name that is absent from the vault would otherwise all look like the same missing credential. The one failure the consumer tells apart is an unresolved Infisical reference, which the reader reports with its own exit code and the consumer names as a reference. The token is minted into a shell variable and never reaches a file, `$GITHUB_ENV` or the log; the project id comes from the `INFISICAL_FLEET_OPS_PROJECT_ID` secret rather than from a literal in a public workflow. The multiline private key is streamed over stdin into a mode-600 file by `scripts/stage-asc-key.mjs`, and the `ASC_KEY_P8` entry is removed from the job's environment file in the same step, so only that file's path and the two identifiers cross into the later steps. The same loader is used by `mac-release.yml` and `ios-ship.yml`, and `scripts/mac-release-workflow.test.mjs` pins the two to the same text. The reader can also be proved on its own, with a token the caller already holds in `INFISICAL_TOKEN`, by running `bash scripts/install-release-secrets-tooling.sh check-reader`; without that token the check says so instead of reporting a rejected one. To check that the release credentials still work without publishing anything, run the "Mac Release (Sparkle)" workflow by hand with the `verify-only` input set. It fetches the key, asks Apple's notarization service whether it still accepts it, and stops. diff --git a/scripts/install-release-secrets-tooling.sh b/scripts/install-release-secrets-tooling.sh index ff733645..081552d6 100755 --- a/scripts/install-release-secrets-tooling.sh +++ b/scripts/install-release-secrets-tooling.sh @@ -79,9 +79,11 @@ sha256_of() { fi } -# The CLI's own error text, with the client id and the client secret taken -# out of it. A failed login must be diagnosable from the log, and neither -# half of the identity belongs in a log. +# A tool's own error text, with the client id, the client secret and the +# access token taken out of it. A failed login or a failed read must be +# diagnosable from the log, and no part of the identity belongs in a log. +# The token is minted fresh for every job, so a slice of it would identify +# nothing an operator can act on; the diagnosis names the fault instead. scrub() { local text="$1" if [ -n "${INFISICAL_CLIENT_ID:-}" ]; then @@ -90,6 +92,9 @@ scrub() { if [ -n "${INFISICAL_CLIENT_SECRET:-}" ]; then text="${text//"$INFISICAL_CLIENT_SECRET"/}" fi + if [ -n "${INFISICAL_TOKEN:-}" ]; then + text="${text//"$INFISICAL_TOKEN"/}" + fi printf '%s' "$text" } @@ -203,7 +208,7 @@ mint_token() { # reference is a credential fault, not a tooling fault, and the consumer names # it precisely, so it does not fail this check. check_reader() { - local reader status detail + local reader status detail name missing reader="${FLEET_SECRETS:-}" if [ -z "$reader" ]; then reader="$(command -v fleet-secrets || true)" @@ -215,12 +220,26 @@ check_reader() { status=0 "$reader" names --path /asc >"$work/names.out" 2>"$work/names.err" || status=$? if [ "$status" -eq 0 ]; then + # The reader works, so a name the release needs that is not in the list + # is a credential missing from the vault. It is named here, because the + # consumer cannot tell an absent name from an unreadable one and would + # send the operator to fix the read permissions. + missing="" + for name in ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8; do + grep -qx -- "$name" "$work/names.out" || missing="${missing:+$missing, }$name" + done + if [ -n "$missing" ]; then + fail "the fleet-secrets reader can read Fleet-OPS prod /asc, but ${missing} is not there; the credential is missing from the vault, not unreadable" + return 1 + fi say "checked: the fleet-secrets reader can read Fleet-OPS prod /asc" return 0 fi if [ "$status" -eq 5 ]; then # The fetch reached the vault, so the reader and the token both work and - # the fault is in the vault's content. The consumer reports it. + # the fault is in the vault's content. The consumer reports it: it keeps + # the reader's exit 5 and names the unresolved reference rather than a + # missing credential. say "checked: the fleet-secrets reader reached Fleet-OPS prod /asc; a value there is an unresolved Infisical reference" return 0 fi @@ -258,6 +277,13 @@ install_tooling() { # tooling, mint the token, prove the reader and run the consumer. if [ "${1:-}" = "check-reader" ]; then export PATH="$TOOLS_DIR:$PATH" + # Without a token the reader falls back to sources a runner does not have + # and fails the way a rejected token fails, so an absent token is named + # before the reader is asked. + if [ -z "${INFISICAL_TOKEN:-}" ]; then + fail "no Infisical access token in the environment; export INFISICAL_TOKEN and INFISICAL_FLEET_OPS_PROJECT_ID before proving the reader" + exit 1 + fi check_reader || exit 1 exit 0 fi diff --git a/scripts/mac-release-workflow.test.mjs b/scripts/mac-release-workflow.test.mjs index 5057e9f7..51b3b354 100644 --- a/scripts/mac-release-workflow.test.mjs +++ b/scripts/mac-release-workflow.test.mjs @@ -96,10 +96,19 @@ test('Sparkle reads the ASC credentials through the fleet reader, never reposito // is sent to fix vault permissions when the fault is the tooling. assert.match(tooling, /check_reader/); assert.match(tooling, /names --path \/asc/); - assert.ok( - tooling.indexOf('check_reader || return 1') < tooling.indexOf('node "$repo_root/scripts/stage-asc-key.mjs"'), - 'the reader has to be proved before the consumer runs', - ); + // The proof is a step of the install, after the token is minted, and the + // install is called before the consumer. The order is asserted at the + // call site: the guard inside the function body only says the function + // would prove the reader, not that the release calls it first. + const installStart = tooling.indexOf('install_tooling() {'); + assert.notEqual(installStart, -1, 'the install has to be one function'); + const installBody = tooling.slice(installStart, tooling.indexOf('\n}\n', installStart)); + assert.match(installBody, /mint_token \|\| return 1[\s\S]*check_reader \|\| return 1/); + const installCall = tooling.indexOf('install_tooling || install_rc=$?'); + const consumerCall = tooling.indexOf('node "$repo_root/scripts/stage-asc-key.mjs"'); + assert.notEqual(installCall, -1, 'the release has to call the install'); + assert.notEqual(consumerCall, -1, 'the release has to call the consumer'); + assert.ok(installCall < consumerCall, 'the reader has to be proved before the consumer runs'); // The staging script resolves every credential environment-first, so a // value in the job's own environment always wins over the vault. const stage = read('scripts/stage-asc-key.mjs'); diff --git a/scripts/stage-asc-key.mjs b/scripts/stage-asc-key.mjs index c19eba2a..25f08f8c 100644 --- a/scripts/stage-asc-key.mjs +++ b/scripts/stage-asc-key.mjs @@ -162,10 +162,23 @@ export function removeEnvironmentEntries(path, names) { return true; } +/** The reader's own exit code for a value that came back as an unresolved + * Infisical reference. `fleet-secrets get` prints nothing in that case, so + * the code is the only thing that says what went wrong. */ +const READER_EXIT_UNRESOLVED = 5; + +/** What the approved fallback returns when the reader exited 5: the vault + * was reached and the value is an unexpanded reference. It is kept apart + * from an empty value, because an empty value reads as a missing + * credential and sends the operator to fix the read permissions. */ +export const UNRESOLVED_REFERENCE = Symbol('unresolved Infisical reference'); + /** The approved fallback: `fleet-secrets get --path ` reads * Fleet-OPS Infisical prod /asc. The value is captured, never echoed, and - * the failure is an empty string rather than an exception, so a missing - * tool reads as a missing credential. */ + * a failure is an empty string rather than an exception, so a missing tool + * reads as a missing credential. The one exception is the reader's exit 5, + * an unresolved reference, which comes back as UNRESOLVED_REFERENCE so the + * consumer names it as one. */ export function fleetSecretsGet(name, folder) { const localBin = join(homedir(), '.local', 'bin', 'fleet-secrets'); const bin = process.env.FLEET_SECRETS || (existsSync(localBin) ? localBin : 'fleet-secrets'); @@ -174,7 +187,8 @@ export function fleetSecretsGet(name, folder) { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }).replace(/\n$/, ''); - } catch { + } catch (err) { + if (err && err.status === READER_EXIT_UNRESOLVED) return UNRESOLVED_REFERENCE; return ''; } } @@ -200,12 +214,14 @@ function checkCredential(log, name, value, malformed = false) { if (malformed) { fail(log, `${name} is malformed in the job environment file: its multiline block is not terminated, so the file was truncated or a step that was writing it was interrupted; verify the CI identity can read Fleet-OPS prod /asc.`); } + const unresolved = () => fail(log, `${name} is still an unresolved Infisical secret reference in Fleet-OPS prod /asc; check the target exists and the CI identity can read the referenced folder.`); + // The reader reached the vault and refused the value as a reference, so + // this is not a missing credential and must not read as one. + if (value === UNRESOLVED_REFERENCE) unresolved(); if (typeof value !== 'string' || value.trim() === '') { fail(log, `Missing ${name} in Fleet-OPS prod /asc; verify the CI identity has read access to Fleet-OPS prod /asc.`); } - if (isUnresolvedReference(value)) { - fail(log, `${name} is still an unresolved Infisical secret reference in Fleet-OPS prod /asc; check the target exists and the CI identity can read the referenced folder.`); - } + if (isUnresolvedReference(value)) unresolved(); return value; } @@ -267,7 +283,8 @@ export async function run({ const written = fromFile.get(name); if (typeof written === 'string' && written.trim() !== '') return { value: written, malformedEntry: malformed.has(name) }; const fetched = await getSecret(name, SECRET_FOLDER); - return { value: typeof fetched === 'string' ? fetched : '', malformedEntry: malformed.has(name) }; + const value = typeof fetched === 'string' || fetched === UNRESOLVED_REFERENCE ? fetched : ''; + return { value, malformedEntry: malformed.has(name) }; }; const keyIdSrc = await resolve('ASC_KEY_ID'); diff --git a/scripts/stage-asc-key.node-test.mjs b/scripts/stage-asc-key.node-test.mjs index 211beac9..e53d64c3 100644 --- a/scripts/stage-asc-key.node-test.mjs +++ b/scripts/stage-asc-key.node-test.mjs @@ -19,7 +19,7 @@ import { join } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; -import { run } from './stage-asc-key.mjs'; +import { run, UNRESOLVED_REFERENCE } from './stage-asc-key.mjs'; // A synthetic fixture, not a credential. It stands in for the private key // so the test can tell a leak from a clean run. @@ -127,12 +127,14 @@ function runScript(script, { env, args = [] }) { /** A stub reader that fails the way the fleet's reader fails. The fleet * reader's exit codes are its own: 2 is a refused configuration, 4 is a fetch - * or auth failure and 5 is an unresolved reference in the vault. */ -function writeStubReader(dir, exitCode, message) { + * or auth failure and 5 is an unresolved reference in the vault. A working + * reader lists the names it found on stdout, one per line, as `names` does. */ +function writeStubReader(dir, exitCode, message, names = []) { const stubDir = join(dir, 'reader'); mkdirSync(stubDir, { recursive: true }); const path = join(stubDir, 'fleet-secrets'); - writeFileSync(path, `#!/bin/bash\nprintf '%s\\n' ${JSON.stringify(message)} >&2\nexit ${exitCode}\n`, { mode: 0o755 }); + const listing = names.map((name) => `printf '%s\\n' ${JSON.stringify(name)}\n`).join(''); + writeFileSync(path, `#!/bin/bash\n${listing}printf '%s\\n' ${JSON.stringify(message)} >&2\nexit ${exitCode}\n`, { mode: 0o755 }); return path; } @@ -481,6 +483,48 @@ test('an unresolved Infisical reference is rejected before anything is staged', } }); +test('a reader that reports an unresolved reference is not read as a missing credential', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-stage-')); + const saved = process.env.FLEET_SECRETS; + try { + // At the seam: the fallback hands back the reader's verdict, and the + // consumer names the reference. + const lines = []; + await assert.rejects( + run({ + env: baseEnv(dir), + getSecret: stubGetter({ ...VAULT, ASC_KEY_P8: UNRESOLVED_REFERENCE }), + log: (l) => lines.push(l), + }), + (err) => { + assert.match(err.message, /ASC_KEY_P8 is still an unresolved Infisical secret reference/); + return true; + }, + ); + assert.ok(!lines.some((l) => l.includes('Missing ASC_KEY_P8')), 'a reference must not read as a missing credential'); + + // Through the real fallback: `fleet-secrets get` exits 5 and prints + // nothing, and the exit code is what has to survive. + process.env.FLEET_SECRETS = writeStubReader(dir, 5, 'fleet-secrets: unresolved-reference: ASC_KEY_ID'); + const env = baseEnv(dir); + writeEnvEntry(env.GITHUB_ENV, 'ASC_KEY_P8', KEY); + const realLines = []; + await assert.rejects( + run({ env, log: (l) => realLines.push(l) }), + (err) => { + assert.match(err.message, /ASC_KEY_ID is still an unresolved Infisical secret reference/); + return true; + }, + ); + assert.ok(!realLines.some((l) => l.includes('Missing ASC_KEY_ID')), 'a reference must not read as a missing credential'); + assertNoKeySurvives(env.GITHUB_ENV); + } finally { + if (saved === undefined) delete process.env.FLEET_SECRETS; + else process.env.FLEET_SECRETS = saved; + rmSync(dir, { recursive: true, force: true }); + } +}); + /** The installer, run for real, with the install made to fail. * * The install is allowed to fail and the consumer still has to run, because @@ -579,11 +623,16 @@ test('a broken reader is reported as a reader fault, not as a missing credential // A reader that cannot authenticate: the token is at fault, and the // message has to say so instead of blaming the vault permissions. - const rejected = writeStubReader(dir, 4, 'fleet-secrets: fetch-failed: exit-1'); + // The reader's own text is passed through for the diagnosis, so the + // stub repeats the token in it: the token must not reach the log. + const rejected = writeStubReader(dir, 4, 'fleet-secrets: fetch-failed: exit-1 for test-token'); const rejectedResult = await runScript(script, { env: { ...base, FLEET_SECRETS: rejected }, args: ['check-reader'] }); assert.notEqual(rejectedResult.code, 0, 'a reader that cannot authenticate must fail the check'); assert.match(rejectedResult.stderr, /access token was rejected/, 'the message must name the token'); assert.doesNotMatch(rejectedResult.stderr, /Missing ASC_KEY_ID/, 'a reader fault must not read as a missing credential'); + assert.ok(!rejectedResult.stderr.includes('test-token'), 'the access token must not reach the log'); + assert.ok(!rejectedResult.stdout.includes('test-token'), 'the access token must not reach the log'); + assert.match(rejectedResult.stderr, /fetch-failed: exit-1 for /, 'the rest of the detail survives'); // A reader that refuses the request: the tooling is at fault. const refused = writeStubReader(dir, 2, 'fleet-secrets: refusing Infisical environment'); @@ -597,12 +646,30 @@ test('a broken reader is reported as a reader fault, not as a missing credential const unresolvedResult = await runScript(script, { env: { ...base, FLEET_SECRETS: unresolved }, args: ['check-reader'] }); assert.equal(unresolvedResult.code, 0, 'an unresolved reference is not a reader fault'); - // A working reader passes. - const working = writeStubReader(dir, 0, ''); + // A working reader that finds every name the release needs passes. + const working = writeStubReader(dir, 0, '', ['ASC_ISSUER_ID', 'ASC_KEY_ID', 'ASC_KEY_P8', 'OTHER_NAME']); const workingResult = await runScript(script, { env: { ...base, FLEET_SECRETS: working }, args: ['check-reader'] }); assert.equal(workingResult.code, 0, 'a working reader must pass the check'); assert.match(workingResult.stdout, /can read Fleet-OPS prod \/asc/, 'a passing check says so'); + // A working reader that does not find a name the release needs: the + // credential is missing from the vault, and the check names it instead + // of letting the consumer blame the read permissions. + const partial = writeStubReader(dir, 0, '', ['ASC_ISSUER_ID', 'ASC_KEY_ID', 'ASC_KEY_P8_OLD']); + const partialResult = await runScript(script, { env: { ...base, FLEET_SECRETS: partial }, args: ['check-reader'] }); + assert.notEqual(partialResult.code, 0, 'a name the release needs must be in the vault'); + assert.match(partialResult.stderr, /but ASC_KEY_P8 is not there/, 'the message must name the missing credential'); + assert.doesNotMatch(partialResult.stderr, /ASC_KEY_ID|ASC_ISSUER_ID/, 'only the missing name is reported'); + + // No token in the environment: the reader would fail the way a rejected + // token fails, so the absent token is named before the reader is asked. + const noToken = { ...base, FLEET_SECRETS: working }; + delete noToken.INFISICAL_TOKEN; + const noTokenResult = await runScript(script, { env: noToken, args: ['check-reader'] }); + assert.notEqual(noTokenResult.code, 0, 'a check without a token must fail'); + assert.match(noTokenResult.stderr, /no Infisical access token in the environment/, 'the message must name the absent token'); + assert.doesNotMatch(noTokenResult.stderr, /was rejected/, 'an absent token is not a rejected one'); + // No reader at all is a tooling fault, and it is named as one. const missingResult = await runScript(script, { env: base, args: ['check-reader'] }); assert.notEqual(missingResult.code, 0, 'a missing reader must fail the check'); From 535cd7484f40a82cfacf918d8133bb0ec5311161 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:51:26 -0500 Subject: [PATCH 36/39] fix(release): keep a merge from cancelling another merge's compile run Every merge to main queued a Swift CI run in one shared concurrency group, and GitHub cancels a run that is still pending when a new run joins its group. So each later merge cancelled the compile run that the release gate was waiting on, and the release never saw a green check on the commit it publishes. The release for 6819caf only went out because nothing merged for 45 minutes. Swift CI now takes one concurrency group per commit for a push to main, so a merge never cancels another merge's compile run. A pull request keeps its one group, so a new push still cancels the run it replaces. A cancelled compile run is also no longer treated as a verdict about the code. It never finished, so the gate waits for a replacement instead of failing on a run that never ran. The release gate can dispatch that replacement itself, up to three times, when it sees the cancellation, so a release does not spend its whole window waiting for a run that will never arrive. The dispatch is opt-in and only the release gate turns it on. Tests cover the new classification, the opt-in, and the dispatch itself. The dispatch test fails when the dispatch is removed. --- .github/workflows/mac-release.yml | 6 ++ .github/workflows/swift-ci.yml | 8 +- scripts/await-swift-ci-checks.sh | 30 ++++++- scripts/compile_gate_checks_test.py | 129 ++++++++++++++++++++++++++++ scripts/swift_ci_check_status.py | 10 +++ 5 files changed, 181 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index cde2ea46..9e013db0 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -85,6 +85,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 90 permissions: + # actions: write lets this gate dispatch a replacement compile run + # when the one it is waiting on is cancelled before it finishes. A + # cancelled run carries no verdict about the code, and the release + # window is too short to wait for a run that will never arrive. + actions: write checks: read contents: read steps: @@ -98,6 +103,7 @@ jobs: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} SHA: ${{ github.event.pull_request.head.sha || github.sha }} + REDISPATCH_ON_CANCEL: '1' run: bash scripts/await-swift-ci-checks.sh release: diff --git a/.github/workflows/swift-ci.yml b/.github/workflows/swift-ci.yml index c2a2ed6c..7f033e14 100644 --- a/.github/workflows/swift-ci.yml +++ b/.github/workflows/swift-ci.yml @@ -16,7 +16,13 @@ on: workflow_dispatch: concurrency: - group: swift-ci-${{ github.ref }} + # A pull request keeps one group, so a new push cancels the run it + # replaces. A push to main gets one group per commit instead: GitHub + # cancels a run that is still PENDING when a new run joins its group, so a + # single shared group made every later merge cancel the compile run that + # the release gate was waiting on. One group per commit means a merge + # never cancels another merge's compile run. + group: swift-ci-${{ github.event_name == 'pull_request' && github.ref || format('{0}-{1}', github.ref, github.sha) }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: diff --git a/scripts/await-swift-ci-checks.sh b/scripts/await-swift-ci-checks.sh index be879835..ec129430 100755 --- a/scripts/await-swift-ci-checks.sh +++ b/scripts/await-swift-ci-checks.sh @@ -15,6 +15,12 @@ # POLL_SECONDS delay between polls (default: 30) # ONCE if 1, decide from one fetch and do not poll # CHECKS_JSON one-shot fixture path; skips the network +# REDISPATCH_ON_CANCEL if 1, a cancelled compile run is replaced by a +# fresh dispatch of the compile workflow on the same +# commit, instead of the gate waiting for a run that +# will never arrive (default: 0) +# REDISPATCH_WORKFLOW the workflow file to dispatch (default: swift-ci.yml) +# MAX_REDISPATCH how many replacements to dispatch (default: 3) # # Exit 0 when every named check's newest github-actions run succeeded. # Exit 1 on a failed, skipped, or cancelled check, or on timeout. @@ -28,6 +34,10 @@ SHA="${SHA:-}" DEADLINE_SECONDS="${DEADLINE_SECONDS:-5100}" POLL_SECONDS="${POLL_SECONDS:-30}" ONCE="${ONCE:-0}" +REDISPATCH_ON_CANCEL="${REDISPATCH_ON_CANCEL:-0}" +REDISPATCH_WORKFLOW="${REDISPATCH_WORKFLOW:-swift-ci.yml}" +MAX_REDISPATCH="${MAX_REDISPATCH:-3}" +redispatches=0 if [[ $# -gt 0 ]]; then CHECK_NAMES=("$@") else @@ -65,6 +75,8 @@ if [[ -n "${CHECKS_JSON:-}" ]]; then fi deadline=$(( $(date +%s) + DEADLINE_SECONDS )) +details="$(mktemp)" +trap 'rm -f "$details"' EXIT while true; do files=() for name in "${CHECK_NAMES[@]}"; do @@ -74,7 +86,7 @@ while true; do gh api "repos/${REPO}/commits/${SHA}/check-runs?check_name=${encoded}&per_page=100" >"$payload" done set +e - verdict="$(decide "${files[@]}")" + verdict="$(decide "${files[@]}" 2>"$details")" code=$? set -e rm -f "${files[@]}" @@ -98,6 +110,22 @@ while true; do echo "::error::Timed out after ${DEADLINE_SECONDS}s waiting for Swift CI on ${SHA}: ${verdict}" exit 1 fi + # A cancelled run never finished, so it is no verdict at all about the + # code. Waiting for a run that will never arrive only wastes the window + # the release has, so dispatch a replacement on the same commit instead. + if [[ "$code" -eq 2 && "$REDISPATCH_ON_CANCEL" == "1" ]] \ + && [[ "$redispatches" -lt "$MAX_REDISPATCH" ]] \ + && grep -q "=cancelled" "$details"; then + redispatches=$(( redispatches + 1 )) + echo "::notice title=Swift CI cancelled::the compile run on ${SHA} was cancelled before it finished. Dispatching a replacement (${redispatches} of ${MAX_REDISPATCH})." + if gh api --method POST \ + "repos/${REPO}/actions/workflows/${REDISPATCH_WORKFLOW}/dispatches" \ + -f ref="$SHA" >/dev/null 2>&1; then + echo "dispatched ${REDISPATCH_WORKFLOW} on ${SHA}" + else + echo "::warning title=Swift CI cancelled::the replacement compile run could not be dispatched; the gate keeps waiting for a run to appear." + fi + fi echo "Waiting for Swift CI on ${SHA}: ${verdict}" sleep "$POLL_SECONDS" done diff --git a/scripts/compile_gate_checks_test.py b/scripts/compile_gate_checks_test.py index 0301ad2e..bdbf43e8 100755 --- a/scripts/compile_gate_checks_test.py +++ b/scripts/compile_gate_checks_test.py @@ -74,6 +74,27 @@ def test_newer_success_replaces_cancelled(self): verdict, _details = status.decide([payload], self.names) self.assertEqual(verdict, "success") + def test_cancelled_run_is_pending_not_failure(self): + # A cancelled run never finished, so it is no verdict about the + # code. The gate waits for a replacement instead of failing on a + # run that never ran. + payload = {"check_runs": [ + run_check("package", "cancelled", 3), + run_check("companion", "success", 4), + ]} + verdict, details = status.decide([payload], self.names) + self.assertEqual(verdict, "pending") + self.assertEqual(details, ["package=cancelled"]) + + def test_failure_still_wins_over_a_cancelled_sibling(self): + payload = {"check_runs": [ + run_check("package", "cancelled", 3), + run_check("companion", "failure", 4), + ]} + verdict, details = status.decide([payload], self.names) + self.assertEqual(verdict, "failure") + self.assertIn("companion=failure", details) + def test_newer_failure_replaces_success(self): payload = {"check_runs": [ run_check("package", "success", 1), @@ -214,6 +235,114 @@ def test_fixture_success_and_pending(self): ) self.assertEqual(pending.returncode, 2, pending.stderr) + def test_cancelled_fixture_is_pending_not_failure(self): + script = ROOT / "await-swift-ci-checks.sh" + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "checks.json" + path.write_text(json.dumps({"check_runs": [ + run_check("Mac package build and test", "cancelled", 1), + ]}), encoding="utf-8") + pending = subprocess.run( + ["bash", str(script)], + check=False, + capture_output=True, + text=True, + env={"CHECKS_JSON": str(path), "PATH": "/usr/bin:/bin"}, + ) + self.assertEqual(pending.returncode, 2, pending.stderr) + + +class RedispatchTests(unittest.TestCase): + """The gate replaces a cancelled compile run instead of waiting forever.""" + + def _stub_gh(self, root: Path, good: Path, current: Path, log: Path) -> Path: + stub_dir = root / "bin" + stub_dir.mkdir() + gh = stub_dir / "gh" + gh.write_text( + "#!/usr/bin/env bash\n" + "set -u\n" + 'if [[ "$*" == *"/dispatches"* ]]; then\n' + f' echo "$*" >> "{log}"\n' + f' cp "{good}" "{current}"\n' + " exit 0\n" + "fi\n" + 'if [[ "$*" == *"check-runs"* ]]; then\n' + f' cat "{current}"\n' + " exit 0\n" + "fi\n" + "exit 0\n", + encoding="utf-8") + gh.chmod(0o755) + return stub_dir + + def test_cancelled_run_is_replaced_and_the_gate_waits_for_the_replacement(self): + script = ROOT / "await-swift-ci-checks.sh" + sha = "0" * 40 + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + log = root / "dispatch.log" + current = root / "current.json" + good = root / "good.json" + current.write_text(json.dumps({"check_runs": [ + run_check("Mac package build and test", "cancelled", 1), + ]}), encoding="utf-8") + good.write_text(json.dumps({"check_runs": [ + run_check("Mac package build and test", "success", 2), + run_check("iOS companion compile", "success", 3), + ]}), encoding="utf-8") + stub_dir = self._stub_gh(root, good, current, log) + completed = subprocess.run( + ["bash", str(script)], + check=False, + capture_output=True, + text=True, + env={ + "PATH": f"{stub_dir}:/usr/bin:/bin", + "REPO": "Simple-With-Us/CodeCaps", + "SHA": sha, + "GH_TOKEN": "stub", + "REDISPATCH_ON_CANCEL": "1", + "DEADLINE_SECONDS": "60", + "POLL_SECONDS": "1", + }, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertTrue(log.exists(), "the gate did not dispatch a replacement run") + dispatched = log.read_text(encoding="utf-8") + self.assertIn(f"ref={sha}", dispatched) + self.assertIn("actions/workflows/swift-ci.yml/dispatches", dispatched) + + def test_without_the_opt_in_the_gate_does_not_dispatch(self): + script = ROOT / "await-swift-ci-checks.sh" + sha = "0" * 40 + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + log = root / "dispatch.log" + current = root / "current.json" + good = root / "good.json" + current.write_text(json.dumps({"check_runs": [ + run_check("Mac package build and test", "cancelled", 1), + ]}), encoding="utf-8") + good.write_text(json.dumps({"check_runs": []}), encoding="utf-8") + stub_dir = self._stub_gh(root, good, current, log) + completed = subprocess.run( + ["bash", str(script)], + check=False, + capture_output=True, + text=True, + env={ + "PATH": f"{stub_dir}:/usr/bin:/bin", + "REPO": "Simple-With-Us/CodeCaps", + "SHA": sha, + "GH_TOKEN": "stub", + "DEADLINE_SECONDS": "2", + "POLL_SECONDS": "1", + }, + ) + self.assertEqual(completed.returncode, 1, completed.stderr) + self.assertFalse(log.exists(), "the gate dispatched a replacement without the opt-in") + if __name__ == "__main__": unittest.main() diff --git a/scripts/swift_ci_check_status.py b/scripts/swift_ci_check_status.py index af81a3e1..7f677b83 100755 --- a/scripts/swift_ci_check_status.py +++ b/scripts/swift_ci_check_status.py @@ -7,6 +7,10 @@ Exit status matches that word: 0, 1, or 2. The newest github-actions run for each requested name wins, so a rerun replaces a cancelled attempt. + +A CANCELLED run is reported as pending, not as a failure: a cancelled run +never finished, so it carries no verdict about the code. The gate waits for +a replacement instead of failing on a run that never ran. """ from __future__ import annotations @@ -53,6 +57,9 @@ def decide(payloads: Iterable[Mapping], names: Sequence[str]) -> tuple[str, list skipped companion after a failed package. A missing or still-running check stays pending so the caller can poll. Failure wins over pending so a red package is not hidden behind a companion that has not started. + + A cancelled run stays pending rather than failing: it never finished, + so it says nothing about the code, and a replacement may still arrive. """ if not names: raise ValueError("at least one check name is required") @@ -68,6 +75,9 @@ def decide(payloads: Iterable[Mapping], names: Sequence[str]) -> tuple[str, list status = run.get("status") or "" if conclusion == "success": continue + if conclusion == "cancelled": + pending.append(f"{name}=cancelled") + continue if conclusion: failed.append(f"{name}={conclusion}") continue From 8ef6a7e891f57999ffaaead54f1d271d0ab69d26 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:21:27 -0500 Subject: [PATCH 37/39] release: name the credential fault, and mask the credential in the log Two findings from the last review round are fixed here. The vault-name gate required ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 to be in Fleet-OPS prod /asc, without the two sources the staging script resolves first: the process environment and the job's environment file. A job that carries one of the three names in its own environment was failed with "the credential is missing from the vault", which is false for that configuration, and the release was blocked even though the consumer would have resolved the value. A name the environment already carries is skipped now, and only the names that no environment source provides are required to be in the vault. A rejected access token is reported as a masked first-eight and last-four preview instead of a flat redaction, so the diagnostic names the value that was refused without carrying it. The preview is not reversible for a credential longer than the preview itself, and a value too short to have a hidden middle is still shown as redacted. The masking substitution is written in the form that works under the bash 3.2 the runner uses: a quoted replacement keeps its quotes there, so the braces form is used instead. Tests: the reader suite gains a case where the environment carries a name the vault does not, and the rejected-token case now asserts the preview. Both fail against the pre-fix script and pass against the fixed one. --- scripts/install-release-secrets-tooling.sh | 35 ++++++++++++++++++---- scripts/stage-asc-key.node-test.mjs | 13 +++++++- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/scripts/install-release-secrets-tooling.sh b/scripts/install-release-secrets-tooling.sh index 081552d6..5c80de75 100755 --- a/scripts/install-release-secrets-tooling.sh +++ b/scripts/install-release-secrets-tooling.sh @@ -79,21 +79,40 @@ sha256_of() { fi } +# A masked preview of a credential: the first eight characters and the last +# four, with an ellipsis between them. It is not reversible, because the +# ellipsis stands for the part that is not shown, and it names the value that +# was rejected so an operator can tell which one it was without being handed +# the value (rule 43). A value too short to have a hidden middle is shown as +# redacted instead. +mask() { + local value="$1" + if [ "${#value}" -lt 5 ]; then + printf '%s' "" + return + fi + printf '%s…%s' "${value:0:8}" "${value: -4}" +} + # A tool's own error text, with the client id, the client secret and the # access token taken out of it. A failed login or a failed read must be # diagnosable from the log, and no part of the identity belongs in a log. # The token is minted fresh for every job, so a slice of it would identify -# nothing an operator can act on; the diagnosis names the fault instead. +# nothing an operator can act on; the diagnosis names the fault instead. The +# masked preview is what tells the operator which value was refused. scrub() { - local text="$1" + local text="$1" masked if [ -n "${INFISICAL_CLIENT_ID:-}" ]; then - text="${text//"$INFISICAL_CLIENT_ID"/}" + masked="$(mask "$INFISICAL_CLIENT_ID")" + text="${text//"$INFISICAL_CLIENT_ID"/${masked}}" fi if [ -n "${INFISICAL_CLIENT_SECRET:-}" ]; then - text="${text//"$INFISICAL_CLIENT_SECRET"/}" + masked="$(mask "$INFISICAL_CLIENT_SECRET")" + text="${text//"$INFISICAL_CLIENT_SECRET"/${masked}}" fi if [ -n "${INFISICAL_TOKEN:-}" ]; then - text="${text//"$INFISICAL_TOKEN"/}" + masked="$(mask "$INFISICAL_TOKEN")" + text="${text//"$INFISICAL_TOKEN"/${masked}}" fi printf '%s' "$text" } @@ -226,6 +245,12 @@ check_reader() { # send the operator to fix the read permissions. missing="" for name in ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8; do + # A name the job's own environment already carries is resolved from + # there first (stage-asc-key.mjs reads the environment before the + # vault), so its absence from the vault is not a fault. + if [ -n "${!name:-}" ]; then + continue + fi grep -qx -- "$name" "$work/names.out" || missing="${missing:+$missing, }$name" done if [ -n "$missing" ]; then diff --git a/scripts/stage-asc-key.node-test.mjs b/scripts/stage-asc-key.node-test.mjs index e53d64c3..986aef7b 100644 --- a/scripts/stage-asc-key.node-test.mjs +++ b/scripts/stage-asc-key.node-test.mjs @@ -632,7 +632,7 @@ test('a broken reader is reported as a reader fault, not as a missing credential assert.doesNotMatch(rejectedResult.stderr, /Missing ASC_KEY_ID/, 'a reader fault must not read as a missing credential'); assert.ok(!rejectedResult.stderr.includes('test-token'), 'the access token must not reach the log'); assert.ok(!rejectedResult.stdout.includes('test-token'), 'the access token must not reach the log'); - assert.match(rejectedResult.stderr, /fetch-failed: exit-1 for /, 'the rest of the detail survives'); + assert.match(rejectedResult.stderr, /fetch-failed: exit-1 for test-tok…oken/, 'the failure includes a masked first-8/last-4 token preview'); // A reader that refuses the request: the tooling is at fault. const refused = writeStubReader(dir, 2, 'fleet-secrets: refusing Infisical environment'); @@ -661,6 +661,17 @@ test('a broken reader is reported as a reader fault, not as a missing credential assert.match(partialResult.stderr, /but ASC_KEY_P8 is not there/, 'the message must name the missing credential'); assert.doesNotMatch(partialResult.stderr, /ASC_KEY_ID|ASC_ISSUER_ID/, 'only the missing name is reported'); + // A name the job's own environment carries is resolved from there first + // (stage-asc-key.mjs reads the environment before the vault), so its + // absence from the vault is not a fault and the check must not fail. + const envProvided = writeStubReader(dir, 0, '', ['ASC_ISSUER_ID', 'ASC_KEY_ID']); + const envProvidedResult = await runScript(script, { + env: { ...base, FLEET_SECRETS: envProvided, ASC_KEY_P8: 'test-key-pem' }, + args: ['check-reader'] + }); + assert.equal(envProvidedResult.code, 0, 'a name the environment carries must not be required in the vault'); + assert.doesNotMatch(envProvidedResult.stderr, /ASC_KEY_P8/, 'the name is not reported as missing'); + // No token in the environment: the reader would fail the way a rejected // token fails, so the absent token is named before the reader is asked. const noToken = { ...base, FLEET_SECRETS: working }; From 7dc639b670775c428b559c9eadaeee5435f08c63 Mon Sep 17 00:00:00 2001 From: MiniMax Date: Wed, 21 Oct 2026 14:25:57 -0500 Subject: [PATCH 38/39] release: redact a credential that has no hidden middle The masked preview was built from the first eight characters and the last four of a value, and those two slices cover twelve characters between them. A credential of twelve characters or fewer was therefore reconstructed in full by the two of them together, and the ellipsis between them was only decoration. The preview now applies to a value of thirteen characters or more, and a shorter one is redacted outright. The message that carries the preview names the credential, so an operator still knows which value was refused: the universal auth login failure now says that the client id or the client secret was refused, and the reader failure already says that the minted access token was rejected. The test that asserted the preview for a ten character token asserted the leak it was meant to prevent, because that preview was the whole value. It now asserts the redaction, and a new test walks the boundary: ten and twelve character values are redacted, thirteen and thirty character values carry a preview, and no slice of any of them reaches the log. Refs CC-177 --- scripts/install-release-secrets-tooling.sh | 14 ++++-- scripts/stage-asc-key.node-test.mjs | 56 +++++++++++++++++++++- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/scripts/install-release-secrets-tooling.sh b/scripts/install-release-secrets-tooling.sh index 5c80de75..82a962e5 100755 --- a/scripts/install-release-secrets-tooling.sh +++ b/scripts/install-release-secrets-tooling.sh @@ -83,11 +83,17 @@ sha256_of() { # four, with an ellipsis between them. It is not reversible, because the # ellipsis stands for the part that is not shown, and it names the value that # was rejected so an operator can tell which one it was without being handed -# the value (rule 43). A value too short to have a hidden middle is shown as -# redacted instead. +# the value (rule 43). +# +# The preview is only a preview when the middle of the value is really hidden. +# The two slices cover twelve characters between them, so a value of twelve +# characters or fewer would be reconstructed in full by the two of them +# together; those are redacted outright instead. The message that carries the +# preview names the credential, so an operator still knows which value was +# refused without any part of the value being shown. mask() { local value="$1" - if [ "${#value}" -lt 5 ]; then + if [ "${#value}" -le 12 ]; then printf '%s' "" return fi @@ -195,7 +201,7 @@ mint_token() { status=0 token="$("$TOOLS_DIR/infisical" login --method=universal-auth --plain --silent 2>"$work/login.err")" || status=$? if [ "$status" -ne 0 ]; then - say "the Infisical CLI refused the universal auth login (exit $status): $(scrub "$(cat "$work/login.err")")" + say "the Infisical CLI refused the universal auth login (exit $status); the client id or the client secret was refused: $(scrub "$(cat "$work/login.err")")" fail "no Infisical access token was minted" return 1 fi diff --git a/scripts/stage-asc-key.node-test.mjs b/scripts/stage-asc-key.node-test.mjs index 986aef7b..eceedbc0 100644 --- a/scripts/stage-asc-key.node-test.mjs +++ b/scripts/stage-asc-key.node-test.mjs @@ -632,7 +632,9 @@ test('a broken reader is reported as a reader fault, not as a missing credential assert.doesNotMatch(rejectedResult.stderr, /Missing ASC_KEY_ID/, 'a reader fault must not read as a missing credential'); assert.ok(!rejectedResult.stderr.includes('test-token'), 'the access token must not reach the log'); assert.ok(!rejectedResult.stdout.includes('test-token'), 'the access token must not reach the log'); - assert.match(rejectedResult.stderr, /fetch-failed: exit-1 for test-tok…oken/, 'the failure includes a masked first-8/last-4 token preview'); + assert.ok(!rejectedResult.stderr.includes('test-tok'), 'no slice of a short access token may reach the log'); + assert.ok(!rejectedResult.stdout.includes('test-tok'), 'no slice of a short access token may reach the log'); + assert.match(rejectedResult.stderr, /fetch-failed: exit-1 for /, 'a token too short to have a hidden middle is redacted outright'); // A reader that refuses the request: the tooling is at fault. const refused = writeStubReader(dir, 2, 'fleet-secrets: refusing Infisical environment'); @@ -689,3 +691,55 @@ test('a broken reader is reported as a reader fault, not as a missing credential rmSync(dir, { recursive: true, force: true }); } }); + +/** A literal string for a regular expression. The preview carries an ellipsis + * and angle brackets, neither of which is a metacharacter, but the helper keeps + * a future preview from changing the meaning of the pattern. */ +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +test('a credential is previewed only when the middle of it is really hidden', async () => { + const script = fileURLToPath(new URL('install-release-secrets-tooling.sh', import.meta.url)); + + // The two slices the preview is built from cover twelve characters between + // them, so a value of twelve characters or fewer is reconstructed in full + // by the two of them together. Thirteen is the first length with a middle + // left over, and it is the boundary the helper has to hold. The hidden + // middle of each case is a string that does not occur in the prose around + // it, so a leak of it is a leak and not a coincidence. + const cases = [ + { token: 'abcdefghij', preview: '', hidden: 'abcdefghij' }, + { token: 'abcdefghijkl', preview: '', hidden: 'abcdefghijkl' }, + { token: 'abcdefghZijkl', preview: 'abcdefgh…ijkl', hidden: 'Z' }, + { token: 'abcdefghijklmnopqrstuvwxyz0123', preview: 'abcdefgh…0123', hidden: 'ijklmnopqrstuvwxyz' } + ]; + + for (const { token, preview, hidden } of cases) { + const dir = mkdtempSync(join(tmpdir(), 'asc-preview-')); + try { + const home = join(dir, 'home'); + mkdirSync(home, { recursive: true }); + const base = { + ...process.env, + HOME: home, + PATH: '/usr/bin:/bin', + INFISICAL_TOKEN: token, + INFISICAL_FLEET_OPS_PROJECT_ID: '11111111-2222-3333-4444-555555555555' + }; + + // The reader repeats the token in its own error text, which is the text + // the failure carries, so the token is in the log unless it is masked. + const rejected = writeStubReader(dir, 4, `fleet-secrets: fetch-failed: exit-1 for ${token}`); + const result = await runScript(script, { env: { ...base, FLEET_SECRETS: rejected }, args: ['check-reader'] }); + + assert.notEqual(result.code, 0, 'a reader that cannot authenticate must fail the check'); + assert.ok(!result.stderr.includes(token), `the access token must not reach the log (${token.length} characters)`); + assert.ok(!result.stdout.includes(token), `the access token must not reach stdout (${token.length} characters)`); + assert.match(result.stderr, new RegExp(`fetch-failed: exit-1 for ${escapeRegExp(preview)}`), `the failure carries the preview of a ${token.length} character token`); + assert.ok(!result.stderr.includes(hidden), `the hidden middle must stay hidden (${token.length} characters)`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +}); From 45911de42d1945e2b3c77143221529179f4f93cd Mon Sep 17 00:00:00 2001 From: MiniMax Date: Wed, 21 Oct 2026 14:37:52 -0500 Subject: [PATCH 39/39] release: keep a credential out of the log when the shell would expand it Three faults in the credential path, each with a test that fails without its fix. The replacement in a bash substitution is a pattern, and in bash 5.2 and later an ampersand in it expands to the text that matched, which here was the credential itself: a value whose preview carried an ampersand was written back into the log in full. The option is now turned off where the shell has it, and the preview refuses a value that could put an ampersand in it. The client id and the client secret stand for months and the runner masks them by value, so a preview of either defeated that masking and put twelve characters of a standing credential in a public log. Both are redacted whole now, and only the access token, which is minted for one job, may carry a preview. The proof read a name the environment carries as resolved, so a name the environment held as whitespace was skipped and the vault was not asked for it, while the consumer trims a value and refuses one that holds nothing but whitespace. The proof now ignores a value with nothing but whitespace in it, so the vault still has to carry the name. Refs CC-177 --- scripts/install-release-secrets-tooling.sh | 48 ++++++-- scripts/stage-asc-key.node-test.mjs | 125 ++++++++++++++++++++- 2 files changed, 159 insertions(+), 14 deletions(-) diff --git a/scripts/install-release-secrets-tooling.sh b/scripts/install-release-secrets-tooling.sh index 82a962e5..7f866c68 100755 --- a/scripts/install-release-secrets-tooling.sh +++ b/scripts/install-release-secrets-tooling.sh @@ -40,6 +40,15 @@ set +o xtrace set -euo pipefail +# The replacement in a bash substitution is a pattern, and in bash 5.2 and +# later an ampersand in it expands to the text that matched, which here would +# be the credential itself. The option is turned off where the shell has it, +# and the preview refuses a value that could put one in the replacement, so +# neither shell can expand one back into the secret. +if shopt -q patsub_replacement 2>/dev/null; then + shopt -u patsub_replacement +fi + INFISICAL_CLI_VERSION="0.44.4" INFISICAL_CLI_SHA256_arm64="02ccd2ce6e1d80eeafb06022697fe7f3c6434ba757b31590f2ff8f88d6ccaf51" INFISICAL_CLI_SHA256_x86_64="35a6af67d361bf4f14ec814c08143293dd00f35eb0ad6a171eec967ca5c9585e" @@ -91,8 +100,17 @@ sha256_of() { # together; those are redacted outright instead. The message that carries the # preview names the credential, so an operator still knows which value was # refused without any part of the value being shown. +# +# The preview also has to be an ordinary string. The replacement in a bash +# substitution is a pattern, and in bash 5.2 and later an ampersand in it +# expands to the text that matched, which here would be the credential +# itself. A value that could put one in the preview is refused, so the +# preview can only ever be text. mask() { local value="$1" + case "$value" in + *[\&]*) printf '%s' ""; return ;; + esac if [ "${#value}" -le 12 ]; then printf '%s' "" return @@ -104,17 +122,22 @@ mask() { # access token taken out of it. A failed login or a failed read must be # diagnosable from the log, and no part of the identity belongs in a log. # The token is minted fresh for every job, so a slice of it would identify -# nothing an operator can act on; the diagnosis names the fault instead. The -# masked preview is what tells the operator which value was refused. +# nothing an operator can act on; the diagnosis names the fault instead. +# +# The client id and the client secret are long lived and the runner masks +# them by value, so they are redacted whole: a preview of either would +# defeat that masking and put twelve characters of a standing credential in +# a public log. The access token is minted for one job, so it may carry a +# preview, and that preview is what tells the operator which value was +# refused. scrub() { - local text="$1" masked + local text="$1" masked redacted + redacted='' if [ -n "${INFISICAL_CLIENT_ID:-}" ]; then - masked="$(mask "$INFISICAL_CLIENT_ID")" - text="${text//"$INFISICAL_CLIENT_ID"/${masked}}" + text="${text//"$INFISICAL_CLIENT_ID"/${redacted}}" fi if [ -n "${INFISICAL_CLIENT_SECRET:-}" ]; then - masked="$(mask "$INFISICAL_CLIENT_SECRET")" - text="${text//"$INFISICAL_CLIENT_SECRET"/${masked}}" + text="${text//"$INFISICAL_CLIENT_SECRET"/${redacted}}" fi if [ -n "${INFISICAL_TOKEN:-}" ]; then masked="$(mask "$INFISICAL_TOKEN")" @@ -253,10 +276,13 @@ check_reader() { for name in ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8; do # A name the job's own environment already carries is resolved from # there first (stage-asc-key.mjs reads the environment before the - # vault), so its absence from the vault is not a fault. - if [ -n "${!name:-}" ]; then - continue - fi + # vault), so its absence from the vault is not a fault. The consumer + # trims a value before it uses it and refuses one that holds nothing + # but whitespace, so a name the environment carries in that shape is + # not resolved from there either and has to come from the vault. + case "${!name:-}" in + *[![:space:]]*) continue ;; + esac grep -qx -- "$name" "$work/names.out" || missing="${missing:+$missing, }$name" done if [ -n "$missing" ]; then diff --git a/scripts/stage-asc-key.node-test.mjs b/scripts/stage-asc-key.node-test.mjs index eceedbc0..a098c431 100644 --- a/scripts/stage-asc-key.node-test.mjs +++ b/scripts/stage-asc-key.node-test.mjs @@ -12,7 +12,7 @@ // // Run: node --test scripts/stage-asc-key.node-test.mjs import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -114,9 +114,9 @@ function assertNoKeySurvives(envPath) { /** Run a shell script and collect what it said and how it ended. Used for * the installer, which is a shell script and not a function. */ -function runScript(script, { env, args = [] }) { +function runScript(script, { env, args = [], shell = '/bin/bash' }) { return new Promise((resolve) => { - const child = spawn('/bin/bash', [script, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn(shell, [script, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (d) => { stdout += d; }); @@ -743,3 +743,122 @@ test('a credential is previewed only when the middle of it is really hidden', as } } }); + +/** The shells the script has to be safe in. The replacement in a bash + * substitution expands an ampersand to the text that matched in bash 5.2 and + * later, so that failure only appears in the newer shell; the test runs in + * every bash it can find so both generations are covered. */ +function shells() { + const found = ['/bin/bash']; + for (const candidate of ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/opt/homebrew/bin/bash5']) { + if (!existsSync(candidate)) continue; + let version = ''; + try { + version = spawnSync(candidate, ['--version']).stdout.toString(); + } catch { + continue; + } + const match = version.match(/version (\d+)\.(\d+)/); + if (!match) continue; + const major = Number(match[1]); + const minor = Number(match[2]); + if (major > 5 || (major === 5 && minor >= 2)) found.push(candidate); + } + return found; +} + +test('a credential that carries an ampersand is never expanded back into the log', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-amp-')); + try { + const script = fileURLToPath(new URL('install-release-secrets-tooling.sh', import.meta.url)); + const home = join(dir, 'home'); + mkdirSync(home, { recursive: true }); + + // The replacement in a bash substitution is a pattern, and in bash 5.2 + // and later an ampersand in it expands to the text that matched, which + // here would be the whole credential. The ampersand sits in the first + // eight characters, so it is in the part of the value a preview shows. + const token = 'abc&efghijklmnopqrst'; + const base = { + ...process.env, + HOME: home, + PATH: '/usr/bin:/bin', + INFISICAL_TOKEN: token, + INFISICAL_FLEET_OPS_PROJECT_ID: '11111111-2222-3333-4444-555555555555' + }; + const rejected = writeStubReader(dir, 4, `fleet-secrets: fetch-failed: exit-1 for ${token}`); + + for (const shell of shells()) { + const result = await runScript(script, { env: { ...base, FLEET_SECRETS: rejected }, args: ['check-reader'], shell }); + assert.notEqual(result.code, 0, 'a reader that cannot authenticate must fail the check'); + assert.ok(!result.stderr.includes(token), `the access token must not reach the log (${shell})`); + assert.ok(!result.stdout.includes(token), `the access token must not reach stdout (${shell})`); + assert.ok(!result.stderr.includes('&'), `no ampersand may reach the log (${shell})`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('the long lived client credentials are redacted whole', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-client-')); + try { + const script = fileURLToPath(new URL('install-release-secrets-tooling.sh', import.meta.url)); + const home = join(dir, 'home'); + mkdirSync(home, { recursive: true }); + + // The client id and the client secret stand for months and the runner + // masks them by value, so a preview of either would defeat that masking + // and put twelve characters of a standing credential in a public log. + const clientId = 'client-id-abcdefghijklmnop'; + const clientSecret = 'client-secret-abcdefghijklmnop'; + const base = { + ...process.env, + HOME: home, + PATH: '/usr/bin:/bin', + INFISICAL_CLIENT_ID: clientId, + INFISICAL_CLIENT_SECRET: clientSecret, + INFISICAL_TOKEN: 'abcdefghijklmnopqrstuvwxyz0123', + INFISICAL_FLEET_OPS_PROJECT_ID: '11111111-2222-3333-4444-555555555555' + }; + const rejected = writeStubReader(dir, 4, `fleet-secrets: fetch-failed: exit-1 for ${clientId} and ${clientSecret}`); + const result = await runScript(script, { env: { ...base, FLEET_SECRETS: rejected }, args: ['check-reader'] }); + + assert.notEqual(result.code, 0, 'a reader that cannot authenticate must fail the check'); + assert.ok(!result.stderr.includes(clientId), 'the client id must not reach the log'); + assert.ok(!result.stderr.includes(clientSecret), 'the client secret must not reach the log'); + assert.ok(!result.stderr.includes('client-id-abc'), 'no slice of the client id may reach the log'); + assert.ok(!result.stderr.includes('client-sec'), 'no slice of the client secret may reach the log'); + assert.match(result.stderr, /for /, 'the credentials are redacted whole'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a name the environment carries as whitespace is still required in the vault', async () => { + const dir = mkdtempSync(join(tmpdir(), 'asc-blank-')); + try { + const script = fileURLToPath(new URL('install-release-secrets-tooling.sh', import.meta.url)); + const home = join(dir, 'home'); + mkdirSync(home, { recursive: true }); + + const base = { + ...process.env, + HOME: home, + PATH: '/usr/bin:/bin', + INFISICAL_TOKEN: 'abcdefghijklmnopqrstuvwxyz0123', + INFISICAL_FLEET_OPS_PROJECT_ID: '11111111-2222-3333-4444-555555555555' + }; + + // The consumer trims a value and refuses one that holds nothing but + // whitespace, so a name the environment carries in that shape is not + // resolved from there and the vault still has to carry it. + const reader = writeStubReader(dir, 0, '', ['ASC_ISSUER_ID', 'ASC_KEY_P8']); + const result = await runScript(script, { env: { ...base, FLEET_SECRETS: reader, ASC_KEY_ID: ' ' }, args: ['check-reader'] }); + + assert.notEqual(result.code, 0, 'a name the environment holds only whitespace is not resolved from there'); + assert.match(result.stderr, /but ASC_KEY_ID is not there/, 'the message must name the credential the vault has to carry'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});