From 9f4172178ca386fa97ab8d523cfa95b3f3862cb4 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sun, 9 Aug 2026 18:59:25 +0000 Subject: [PATCH 1/6] test(release): validate credential-free brand pilot Amp-Thread-ID: https://ampcode.com/threads/T-019fe7ac-7d82-754e-a203-5d0214817d24 --- .github/scripts/brand-matrix.test.mjs | 81 +++++++++++ .github/workflows/release-brand-matrix.yml | 150 ++++++++++++++++++++- 2 files changed, 230 insertions(+), 1 deletion(-) diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index 9b74dc37..d54de09c 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -226,3 +226,84 @@ describe('parseBrandBuildMatrix', () => { ); }); }); + +describe('release brand matrix workflow', () => { + it('keeps local runtime validation independent of provider and signing inputs', async () => { + const workflow = await readFile( + new URL('../workflows/release-brand-matrix.yml', import.meta.url), + 'utf8', + ); + const validation = workflow.slice( + workflow.indexOf(' credential-free-validation:'), + workflow.indexOf(' release-environment-preflight:'), + ); + + expect(validation).toContain('needs: prepare'); + expect(validation).toContain('xvfb-run -a pnpm -F @linkcode/desktop e2e:config-canary'); + expect(validation).toContain('pnpm -F @linkcode/mobile smoke:export'); + expect(validation).toContain('expo prebuild --clean --no-install --platform android'); + expect(validation).toContain('expo prebuild --clean --no-install --platform ios'); + expect(validation).toContain('"local-static-origin"'); + expect(validation).toContain('providerDeploymentId:null'); + expect(validation).not.toContain('environment: release'); + expect(validation).not.toContain('secrets.'); + expect(validation).not.toContain('release-environment-preflight'); + }); + + it('fails closed unless the live-pilot environment is protected', async () => { + const workflow = await readFile( + new URL('../workflows/release-brand-matrix.yml', import.meta.url), + 'utf8', + ); + const preflight = workflow.slice( + workflow.indexOf(' release-environment-preflight:'), + workflow.indexOf(' render-inputs:'), + ); + + expect(preflight).toContain('environment: release'); + expect(preflight).toContain('protection_rules'); + expect(preflight).toContain('required_reviewers'); + expect(preflight).toContain('deployment_branch_policy'); + expect(preflight).toContain('gh api "repos/$GITHUB_REPOSITORY/environments/release"'); + expect(preflight).toContain('inputs.sign'); + expect(preflight).not.toContain('inputs.build'); + const renderInputs = workflow.slice( + workflow.indexOf(' render-inputs:'), + workflow.indexOf(' signing-inputs:'), + ); + expect(renderInputs).toContain('needs: prepare'); + expect(renderInputs).not.toContain('release-environment-preflight'); + const signingInputs = workflow.slice( + workflow.indexOf(' signing-inputs:'), + workflow.indexOf(' render:'), + ); + expect(signingInputs).toContain('needs: [prepare, release-environment-preflight]'); + }); + + it('binds credential-free desktop recovery evidence to immutable release inputs', async () => { + const workflow = await readFile( + new URL('../workflows/release-brand-matrix.yml', import.meta.url), + 'utf8', + ); + const desktopValidation = workflow.slice( + workflow.indexOf(' desktop-validation:'), + workflow.indexOf(' mobile-validation:'), + ); + + expect(desktopValidation).toContain('inputs.build && !inputs.sign'); + expect(desktopValidation).toContain('xvfb-run -a pnpm -F @linkcode/desktop e2e:config-canary'); + expect(desktopValidation).toContain( + '54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39', + ); + expect(desktopValidation).toContain('"normal":["1","2","3","4"]'); + expect(desktopValidation).toContain('"emergency":["1","2","3"]'); + expect(desktopValidation).toContain('"kind":"local-static-origin"'); + expect(desktopValidation).toContain('"providerDeploymentId":null'); + expect(desktopValidation).toContain('--expected-delivery-sha256'); + expect(desktopValidation).toContain( + '--release-manifest release-inputs/release-manifest.desktop.json', + ); + expect(desktopValidation).toContain('--out release-provenance.desktop.json'); + expect(desktopValidation).not.toContain('environment: release'); + }); +}); diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index 18bb0c5e..208f4d6a 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -39,6 +39,7 @@ concurrency: permissions: contents: read + deployments: read jobs: prepare: @@ -126,6 +127,84 @@ jobs: --sign "${{ inputs.sign }}" \ --upload "${{ inputs.upload }}" + credential-free-validation: + name: Credential-free runtime validation + needs: prepare + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + run_install: false + cache: true + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: .nvmrc + package-manager-cache: false + - run: pnpm install --frozen-lockfile + - name: Exercise Electron recovery with Xvfb + run: | + set -euo pipefail + pnpm -F @linkcode/desktop exec playwright-core install-deps chromium + printf '%s %s\n' \ + '54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39' \ + 'apps/desktop/e2e/fixtures/pilot-e2e-v1.json' | sha256sum --check --strict + xvfb-run -a pnpm -F @linkcode/desktop e2e:config-canary + - name: Exercise production Hermes and native generation + env: + CI: "1" + EXPO_NO_TELEMETRY: "1" + run: | + set -euo pipefail + pnpm -F @linkcode/mobile smoke:export + pnpm --dir apps/mobile exec expo prebuild --clean --no-install --platform android + test -f apps/mobile/android/app/build.gradle + rm -rf apps/mobile/android + pnpm --dir apps/mobile exec expo prebuild --clean --no-install --platform ios + test -f apps/mobile/ios/Podfile + rm -rf apps/mobile/ios + - name: Record local-only evidence + env: + CLIENT_REF: ${{ inputs.ref }} + DELIVERY_SHA256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} + run: | + mkdir -p credential-free-evidence + jq -cn \ + --arg clientGitSha "$CLIENT_REF" \ + --arg deliveryDescriptorSha256 "$DELIVERY_SHA256" \ + '{clientGitSha:$clientGitSha,deliveryDescriptorSha256:$deliveryDescriptorSha256,deploymentIdentity:{kind:"local-static-origin",providerDeploymentId:null},evidenceVersion:1,pilotFixtureSha256:"54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39",runtimes:["electron+xvfb","production-hermes+android-prebuild","production-hermes+ios-prebuild"]}' \ + > credential-free-evidence/runtime-validation.json + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: credential-free-runtime-validation + path: credential-free-evidence + if-no-files-found: error + retention-days: 7 + + release-environment-preflight: + name: Protected live-pilot preflight + if: ${{ inputs.sign }} + needs: prepare + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + environment: release + steps: + - name: Require protected nonproduction release environment + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + environment="$RUNNER_TEMP/release-environment.json" + gh api "repos/$GITHUB_REPOSITORY/environments/release" > "$environment" + if ! jq -e ' + ([.protection_rules[]?.type] | index("required_reviewers") != null) and + (.deployment_branch_policy != null) + ' "$environment" >/dev/null; then + echo "::error::release must require reviewers and a deployment branch policy before a live pilot" + exit 1 + fi + render-inputs: name: Validate immutable render inputs if: ${{ inputs.build }} @@ -146,7 +225,7 @@ jobs: signing-inputs: name: Validate signing inputs if: ${{ inputs.sign }} - needs: prepare + needs: [prepare, release-environment-preflight] runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} environment: release env: @@ -317,6 +396,75 @@ jobs: rendered_artifact: brand-render-${{ matrix.brandId }} update_url: ${{ matrix.distribution.desktop.updateUrl || '' }} + desktop-validation: + name: Desktop validation ${{ matrix.brandId }} + if: ${{ inputs.build && !inputs.sign && !cancelled() && needs.render.result == 'success' }} + needs: [prepare, render] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + run_install: false + cache: true + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: .nvmrc + package-manager-cache: false + - run: pnpm install --frozen-lockfile + - name: Install Electron system dependencies + run: pnpm -F @linkcode/desktop exec playwright-core install-deps chromium + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: brand-render-${{ matrix.brandId }} + path: . + - name: Verify real Electron configuration recovery + run: | + set -euo pipefail + printf '%s %s\n' \ + '54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39' \ + 'apps/desktop/e2e/fixtures/pilot-e2e-v1.json' | sha256sum --check --strict + rendered="$RUNNER_TEMP/rendered-desktop-${{ matrix.brandId }}" + mv apps/desktop/generated "$rendered" + status=0 + xvfb-run -a pnpm -F @linkcode/desktop e2e:config-canary || status=$? + if [ -e apps/desktop/generated ]; then + echo "::error::the fixture build created release-rendered output" + exit 1 + fi + mv "$rendered" apps/desktop/generated + exit "$status" + - name: Record isolated validation evidence + run: | + set -euo pipefail + root="release-validation/${{ matrix.brandId }}" + mkdir -p "$root" + printf '%s\n' '{"activationVersions":{"emergency":["1","2","3"],"normal":["1","2","3","4"]},"consumer":"desktop","deploymentIdentity":{"kind":"local-static-origin","providerDeploymentId":null},"drills":["emergency-kill-switch","emergency-release","offline-startup","pointer-and-snapshot-tamper","replay-rejection","rollback","roll-forward"],"evidenceVersion":1,"pilotFixtureSha256":"54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39","runtime":"electron+xvfb"}' \ + > "$root/validation.desktop.json" + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root "$root" \ + --artifact validation.desktop.json \ + --bundle apps/desktop/generated/config-build-bundle.json \ + --brand-identity apps/desktop/generated/brand-identity.json \ + --brand-manifest release-inputs/brands.manifest.yaml \ + --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ + --release-manifest release-inputs/release-manifest.desktop.json \ + --compliance release-inputs/compliance.desktop.json \ + --out release-provenance.desktop.json + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: brand-validation-desktop-${{ matrix.brandId }} + path: release-validation/${{ matrix.brandId }} + if-no-files-found: error + retention-days: 7 + mobile-validation: name: Mobile validation ${{ matrix.brandId }} if: ${{ inputs.build && !inputs.sign && !cancelled() && needs.render.result == 'success' }} From a7f15dfc8a31c836f1165b00cead57759879a0e5 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sun, 9 Aug 2026 19:39:14 +0000 Subject: [PATCH 2/6] fix(release): harden pilot matrix gates Amp-Thread-ID: https://ampcode.com/threads/T-019fe7ac-7d82-754e-a203-5d0214817d24 --- .../brand-matrices/code-561-pilot.json | 203 ++++++++++++++++++ .github/scripts/brand-matrix.test.mjs | 77 ++++++- .github/workflows/build-desktop.yml | 9 +- .github/workflows/build-mobile.yml | 11 +- .github/workflows/release-brand-matrix.yml | 72 ++++--- 5 files changed, 335 insertions(+), 37 deletions(-) create mode 100644 .github/release/brand-matrices/code-561-pilot.json diff --git a/.github/release/brand-matrices/code-561-pilot.json b/.github/release/brand-matrices/code-561-pilot.json new file mode 100644 index 00000000..ddc0f41a --- /dev/null +++ b/.github/release/brand-matrices/code-561-pilot.json @@ -0,0 +1,203 @@ +{ + "brandBuildMatrixVersion": 1, + "brands": [ + { + "brandId": "acme", + "channel": "canary", + "compliance": { + "android": { + "checklist": { + "configurableFeaturesDisclosed": true, + "dataPracticesReviewed": true, + "noExecutableCode": true, + "permissionsReviewed": true, + "storeMetadataReviewed": true + }, + "disclosedFeatures": [ + "feature.aiAssist", + "feature.newEditor", + "modules.messaging.enabled", + "modules.terminal.enabled", + "modules.workspace.enabled" + ] + }, + "desktop": { + "checklist": { + "configurableFeaturesDisclosed": true, + "dataPracticesReviewed": true, + "noExecutableCode": true, + "permissionsReviewed": true, + "storeMetadataReviewed": true + }, + "disclosedFeatures": [ + "feature.aiAssist", + "feature.newEditor", + "modules.messaging.enabled", + "modules.terminal.enabled", + "modules.workspace.enabled" + ] + }, + "ios": { + "checklist": { + "configurableFeaturesDisclosed": true, + "dataPracticesReviewed": true, + "noExecutableCode": true, + "permissionsReviewed": true, + "storeMetadataReviewed": true + }, + "disclosedFeatures": [ + "feature.aiAssist", + "feature.newEditor", + "modules.messaging.enabled", + "modules.terminal.enabled", + "modules.workspace.enabled" + ] + } + }, + "distribution": { + "desktop": null, + "mobile": null + }, + "releaseManifests": { + "android": { + "brandId": "acme", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "0675b1b33e81d4898f75233fdf9bda7243348e286ebfd3b06f807d82fff8818f", + "platform": "android", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "telemetryEndpoint": "https://acme.example.invalid/telemetry" + }, + "desktop": { + "brandId": "acme", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "936250a3ef922cede3a200b5dc401cc7697ee1db90dc3efd0f873358524f01e3", + "platform": "desktop", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "telemetryEndpoint": "https://acme.example.invalid/telemetry" + }, + "ios": { + "brandId": "acme", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "a689a8d95f74d9cb00b5d9850af3ecfd50edb23d2496c71805c9ffe4659d56ae", + "platform": "ios", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "telemetryEndpoint": "https://acme.example.invalid/telemetry" + } + } + }, + { + "brandId": "zenith", + "channel": "canary", + "compliance": { + "android": { + "checklist": { + "configurableFeaturesDisclosed": true, + "dataPracticesReviewed": true, + "noExecutableCode": true, + "permissionsReviewed": true, + "storeMetadataReviewed": true + }, + "disclosedFeatures": [ + "feature.aiAssist", + "feature.newEditor", + "modules.messaging.enabled", + "modules.terminal.enabled", + "modules.workspace.enabled" + ] + }, + "desktop": { + "checklist": { + "configurableFeaturesDisclosed": true, + "dataPracticesReviewed": true, + "noExecutableCode": true, + "permissionsReviewed": true, + "storeMetadataReviewed": true + }, + "disclosedFeatures": [ + "feature.aiAssist", + "feature.newEditor", + "modules.messaging.enabled", + "modules.terminal.enabled", + "modules.workspace.enabled" + ] + }, + "ios": { + "checklist": { + "configurableFeaturesDisclosed": true, + "dataPracticesReviewed": true, + "noExecutableCode": true, + "permissionsReviewed": true, + "storeMetadataReviewed": true + }, + "disclosedFeatures": [ + "feature.aiAssist", + "feature.newEditor", + "modules.messaging.enabled", + "modules.terminal.enabled", + "modules.workspace.enabled" + ] + } + }, + "distribution": { + "desktop": null, + "mobile": null + }, + "releaseManifests": { + "android": { + "brandId": "zenith", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "a0ef5196645ae3b857343784f7a5ab5d6f5184b15c7cb646d8e86c93ff5384b0", + "platform": "android", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "telemetryEndpoint": "https://zenith.example.invalid/telemetry" + }, + "desktop": { + "brandId": "zenith", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "99a93cec0ca5381faa15a5def6727736f220b5d7d111e1fce04afda1d321aef2", + "platform": "desktop", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "telemetryEndpoint": "https://zenith.example.invalid/telemetry" + }, + "ios": { + "brandId": "zenith", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "e1b93b64973e0192ed2e1d8ba9a4cca27ae2bb5521ef6011392c2d86b510b95b", + "platform": "ios", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "telemetryEndpoint": "https://zenith.example.invalid/telemetry" + } + } + } + ] +} diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index d54de09c..6fbc4603 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -19,6 +19,7 @@ const RE_DIVERGENT_SOURCE = /all platforms must share sourceGitSha/; const RE_SHARED_DESTINATION = /R2 prefixes in one bucket must not overlap/; const RE_SHARED_CREDENTIALS = /credentialSecretPrefix: must be unique/; const RE_SHARED_APP_STORE_APP = /ios\.ascAppId: must be unique/; +const ACTIONS_EXPRESSION = String.fromCodePoint(36); function sha(character) { return character.repeat(64); @@ -81,6 +82,31 @@ function matrix(...brands) { } describe('parseBrandBuildMatrix', () => { + it('pins the CODE-561 credential-free pilot to two brands and all platforms', async () => { + const pilot = JSON.parse( + await readFile( + new URL('../release/brand-matrices/code-561-pilot.json', import.meta.url), + 'utf8', + ), + ); + const plan = buildMatrixPlan(pilot); + + expect(plan.targets.include.map(({ brandId, platform }) => `${brandId}/${platform}`)).toEqual([ + 'acme/desktop', + 'acme/ios', + 'acme/android', + 'zenith/desktop', + 'zenith/ios', + 'zenith/android', + ]); + expect( + new Set(pilot.brands.map((entry) => entry.releaseManifests.desktop.publisherGitSha)), + ).toEqual(new Set(['e4a0624abbc8ed1cac4948fa90239176a83cb96e'])); + expect( + pilot.brands.every((entry) => Object.values(entry.distribution).every((x) => x === null)), + ).toBe(true); + }); + it('builds the complete brand by platform plan', () => { const input = matrix(brand('acme'), brand('zenith')); const plan = buildMatrixPlan(input); @@ -239,10 +265,19 @@ describe('release brand matrix workflow', () => { ); expect(validation).toContain('needs: prepare'); + expect(validation).toContain( + `matrix: ${ACTIONS_EXPRESSION}{{ fromJSON(needs.prepare.outputs.targets) }}`, + ); expect(validation).toContain('xvfb-run -a pnpm -F @linkcode/desktop e2e:config-canary'); expect(validation).toContain('pnpm -F @linkcode/mobile smoke:export'); - expect(validation).toContain('expo prebuild --clean --no-install --platform android'); - expect(validation).toContain('expo prebuild --clean --no-install --platform ios'); + expect(validation).toContain( + `expo prebuild --clean --no-install --platform '${ACTIONS_EXPRESSION}{{ matrix.platform }}'`, + ); + expect(validation).toContain("matrix.platform == 'desktop'"); + expect(validation).toContain("matrix.platform != 'desktop'"); + expect(validation).toContain( + `credential-free-${ACTIONS_EXPRESSION}{{ matrix.brandId }}-${ACTIONS_EXPRESSION}{{ matrix.platform }}`, + ); expect(validation).toContain('"local-static-origin"'); expect(validation).toContain('providerDeploymentId:null'); expect(validation).not.toContain('environment: release'); @@ -260,24 +295,50 @@ describe('release brand matrix workflow', () => { workflow.indexOf(' render-inputs:'), ); - expect(preflight).toContain('environment: release'); + expect(preflight).not.toContain('environment:'); expect(preflight).toContain('protection_rules'); expect(preflight).toContain('required_reviewers'); expect(preflight).toContain('deployment_branch_policy'); - expect(preflight).toContain('gh api "repos/$GITHUB_REPOSITORY/environments/release"'); - expect(preflight).toContain('inputs.sign'); - expect(preflight).not.toContain('inputs.build'); + expect(preflight).toContain( + 'gh api "repos/$GITHUB_REPOSITORY/environments/pilot-nonproduction"', + ); + expect(preflight).toContain('secrets.PILOT_ENVIRONMENT_ADMIN_TOKEN'); + expect(preflight).toContain('inputs.build'); const renderInputs = workflow.slice( workflow.indexOf(' render-inputs:'), workflow.indexOf(' signing-inputs:'), ); - expect(renderInputs).toContain('needs: prepare'); - expect(renderInputs).not.toContain('release-environment-preflight'); + expect(renderInputs).toContain('needs: [prepare, release-environment-preflight]'); + expect(renderInputs).toContain('environment: pilot-nonproduction'); const signingInputs = workflow.slice( workflow.indexOf(' signing-inputs:'), workflow.indexOf(' render:'), ); expect(signingInputs).toContain('needs: [prepare, release-environment-preflight]'); + expect(signingInputs).toContain('environment: pilot-nonproduction'); + expect(workflow).not.toContain('environment: release'); + expect(workflow.split('release_environment: pilot-nonproduction')).toHaveLength(3); + }); + + it('passes the isolated pilot environment through reusable signing workflows', async () => { + const [desktop, mobile] = await Promise.all([ + readFile(new URL('../workflows/build-desktop.yml', import.meta.url), 'utf8'), + readFile(new URL('../workflows/build-mobile.yml', import.meta.url), 'utf8'), + ]); + + expect(desktop).toContain('release_environment:'); + expect(desktop).toContain( + `environment: ${ACTIONS_EXPRESSION}{{ inputs.release_environment || 'release' }}`, + ); + expect(desktop).toContain( + `environment: ${ACTIONS_EXPRESSION}{{ inputs.sign && (inputs.release_environment || 'release') || '' }}`, + ); + expect(mobile).toContain('release_environment:'); + expect( + mobile.split( + `environment: ${ACTIONS_EXPRESSION}{{ inputs.release_environment || 'release' }}`, + ), + ).toHaveLength(4); }); it('binds credential-free desktop recovery evidence to immutable release inputs', async () => { diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 1ecbe8fb..cdce70f7 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -40,6 +40,11 @@ on: type: string required: false default: "" + release_environment: + description: GitHub environment that owns signing and publisher credentials + type: string + required: false + default: release # CI builds on PRs — unsigned. # pull_request: # paths: @@ -87,7 +92,7 @@ jobs: name: Render immutable config if: ${{ inputs.sign && inputs.rendered_artifact == '' }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: ${{ inputs.release_environment || 'release' }} steps: - name: Checkout uses: actions/checkout@v7 @@ -132,7 +137,7 @@ jobs: if: ${{ !cancelled() && (needs.render-config.result == 'success' || needs.render-config.result == 'skipped') }} runs-on: ${{ matrix.os }} # Signing is gated by the `release` environment (secrets + tag policy); '' = no environment. - environment: ${{ inputs.sign && 'release' || '' }} + environment: ${{ inputs.sign && (inputs.release_environment || 'release') || '' }} strategy: fail-fast: false # Runners resolve through repo/org `vars` (ArcBox pins Blacksmith labels there); forks diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index dbf97624..654d9302 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -31,6 +31,11 @@ on: type: boolean required: false default: false + release_environment: + description: GitHub environment that owns build, signing, and store credentials + type: string + required: false + default: release workflow_dispatch: inputs: submit: @@ -79,7 +84,7 @@ jobs: if: ${{ inputs.rendered_artifact == '' }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} timeout-minutes: 20 - environment: release + environment: ${{ inputs.release_environment || 'release' }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -117,7 +122,7 @@ jobs: if: ${{ !cancelled() && needs.preflight.result == 'success' && (needs.render-config.result == 'success' || needs.render-config.result == 'skipped') }} runs-on: ${{ matrix.os }} timeout-minutes: 120 - environment: release + environment: ${{ inputs.release_environment || 'release' }} strategy: fail-fast: false matrix: @@ -245,7 +250,7 @@ jobs: needs: build runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} timeout-minutes: 30 - environment: release + environment: ${{ inputs.release_environment || 'release' }} strategy: fail-fast: false matrix: diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index 208f4d6a..99724028 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -128,9 +128,12 @@ jobs: --upload "${{ inputs.upload }}" credential-free-validation: - name: Credential-free runtime validation + name: Credential-free ${{ matrix.brandId }}/${{ matrix.platform }} needs: prepare runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.targets) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -145,6 +148,7 @@ jobs: package-manager-cache: false - run: pnpm install --frozen-lockfile - name: Exercise Electron recovery with Xvfb + if: ${{ matrix.platform == 'desktop' }} run: | set -euo pipefail pnpm -F @linkcode/desktop exec playwright-core install-deps chromium @@ -153,64 +157,82 @@ jobs: 'apps/desktop/e2e/fixtures/pilot-e2e-v1.json' | sha256sum --check --strict xvfb-run -a pnpm -F @linkcode/desktop e2e:config-canary - name: Exercise production Hermes and native generation + if: ${{ matrix.platform != 'desktop' }} env: CI: "1" EXPO_NO_TELEMETRY: "1" run: | set -euo pipefail pnpm -F @linkcode/mobile smoke:export - pnpm --dir apps/mobile exec expo prebuild --clean --no-install --platform android - test -f apps/mobile/android/app/build.gradle - rm -rf apps/mobile/android - pnpm --dir apps/mobile exec expo prebuild --clean --no-install --platform ios - test -f apps/mobile/ios/Podfile - rm -rf apps/mobile/ios + pnpm --dir apps/mobile exec expo prebuild --clean --no-install --platform '${{ matrix.platform }}' + if [ '${{ matrix.platform }}' = android ]; then + test -f apps/mobile/android/app/build.gradle + rm -rf apps/mobile/android + else + test -f apps/mobile/ios/Podfile + rm -rf apps/mobile/ios + fi - name: Record local-only evidence env: + BRAND_ID: ${{ matrix.brandId }} CLIENT_REF: ${{ inputs.ref }} DELIVERY_SHA256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} + PLATFORM: ${{ matrix.platform }} run: | mkdir -p credential-free-evidence + if [ "$PLATFORM" = desktop ]; then + runtime=electron+xvfb + else + runtime=production-hermes+prebuild + fi jq -cn \ + --arg brandId "$BRAND_ID" \ --arg clientGitSha "$CLIENT_REF" \ --arg deliveryDescriptorSha256 "$DELIVERY_SHA256" \ - '{clientGitSha:$clientGitSha,deliveryDescriptorSha256:$deliveryDescriptorSha256,deploymentIdentity:{kind:"local-static-origin",providerDeploymentId:null},evidenceVersion:1,pilotFixtureSha256:"54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39",runtimes:["electron+xvfb","production-hermes+android-prebuild","production-hermes+ios-prebuild"]}' \ + --arg platform "$PLATFORM" \ + --arg runtime "$runtime" \ + '{brandId:$brandId,clientGitSha:$clientGitSha,deliveryDescriptorSha256:$deliveryDescriptorSha256,deploymentIdentity:{kind:"local-static-origin",providerDeploymentId:null},evidenceVersion:1,pilotFixtureSha256:"54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39",platform:$platform,runtime:$runtime}' \ > credential-free-evidence/runtime-validation.json - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: credential-free-runtime-validation + name: credential-free-${{ matrix.brandId }}-${{ matrix.platform }} path: credential-free-evidence if-no-files-found: error retention-days: 7 release-environment-preflight: name: Protected live-pilot preflight - if: ${{ inputs.sign }} + if: ${{ inputs.build }} needs: prepare runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release steps: - name: Require protected nonproduction release environment env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.PILOT_ENVIRONMENT_ADMIN_TOKEN }} run: | set -euo pipefail - environment="$RUNNER_TEMP/release-environment.json" - gh api "repos/$GITHUB_REPOSITORY/environments/release" > "$environment" + if [ -z "$GH_TOKEN" ]; then + echo "::error::PILOT_ENVIRONMENT_ADMIN_TOKEN is required to inspect environment protection" + exit 1 + fi + environment="$RUNNER_TEMP/pilot-environment.json" + gh api "repos/$GITHUB_REPOSITORY/environments/pilot-nonproduction" > "$environment" if ! jq -e ' - ([.protection_rules[]?.type] | index("required_reviewers") != null) and - (.deployment_branch_policy != null) + .name == "pilot-nonproduction" and + ([.protection_rules[]? | select(.type == "required_reviewers") | .reviewers | length] | any(. > 0)) and + (.deployment_branch_policy != null) and + (.deployment_branch_policy.protected_branches == true or .deployment_branch_policy.custom_branch_policies == true) ' "$environment" >/dev/null; then - echo "::error::release must require reviewers and a deployment branch policy before a live pilot" + echo "::error::pilot-nonproduction must require reviewers and a deployment branch policy before a live pilot" exit 1 fi render-inputs: name: Validate immutable render inputs if: ${{ inputs.build }} - needs: prepare + needs: [prepare, release-environment-preflight] runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: pilot-nonproduction env: CONFIG_PUBLISHER_REPO: ${{ vars.CONFIG_PUBLISHER_REPO }} CONFIG_PUBLISHER_TOKEN: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} @@ -227,7 +249,7 @@ jobs: if: ${{ inputs.sign }} needs: [prepare, release-environment-preflight] runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: pilot-nonproduction env: APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} @@ -264,7 +286,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: pilot-nonproduction steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -393,6 +415,7 @@ jobs: sign: ${{ inputs.sign }} brand_id: ${{ matrix.brandId }} delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} + release_environment: pilot-nonproduction rendered_artifact: brand-render-${{ matrix.brandId }} update_url: ${{ matrix.distribution.desktop.updateUrl || '' }} @@ -531,6 +554,7 @@ jobs: ref: ${{ inputs.ref }} brand_id: ${{ matrix.brandId }} delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} + release_environment: pilot-nonproduction rendered_artifact: brand-render-${{ matrix.brandId }} submit: false @@ -542,7 +566,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: pilot-nonproduction env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} R2_ACCESS_KEY_ID: ${{ secrets[format('{0}_R2_ACCESS_KEY_ID', matrix.distribution.desktop.credentialSecretPrefix)] }} @@ -622,7 +646,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: pilot-nonproduction steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -698,7 +722,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: pilot-nonproduction env: AWS_ACCESS_KEY_ID: ${{ secrets[format('{0}_R2_ACCESS_KEY_ID', matrix.distribution.desktop.credentialSecretPrefix)] }} AWS_SECRET_ACCESS_KEY: ${{ secrets[format('{0}_R2_SECRET_ACCESS_KEY', matrix.distribution.desktop.credentialSecretPrefix)] }} From e37d4a1edaaa87715f41f6834b25b13f326d8f56 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sun, 9 Aug 2026 21:04:30 +0000 Subject: [PATCH 3/6] fix(release): use protected release environment Amp-Thread-ID: https://ampcode.com/threads/T-019fe7ac-7d82-754e-a203-5d0214817d24 --- .github/scripts/brand-matrix.test.mjs | 18 ++++++------ .github/workflows/release-brand-matrix.yml | 32 +++++++++++----------- docs/ENVIRONMENT.md | 1 + 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index 6fbc4603..e5a554e2 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -285,7 +285,7 @@ describe('release brand matrix workflow', () => { expect(validation).not.toContain('release-environment-preflight'); }); - it('fails closed unless the live-pilot environment is protected', async () => { + it('fails closed unless the release environment is protected', async () => { const workflow = await readFile( new URL('../workflows/release-brand-matrix.yml', import.meta.url), 'utf8', @@ -299,28 +299,26 @@ describe('release brand matrix workflow', () => { expect(preflight).toContain('protection_rules'); expect(preflight).toContain('required_reviewers'); expect(preflight).toContain('deployment_branch_policy'); - expect(preflight).toContain( - 'gh api "repos/$GITHUB_REPOSITORY/environments/pilot-nonproduction"', - ); - expect(preflight).toContain('secrets.PILOT_ENVIRONMENT_ADMIN_TOKEN'); + expect(preflight).toContain('gh api "repos/$GITHUB_REPOSITORY/environments/release"'); + expect(preflight).toContain('secrets.RELEASE_ENVIRONMENT_ADMIN_TOKEN'); expect(preflight).toContain('inputs.build'); const renderInputs = workflow.slice( workflow.indexOf(' render-inputs:'), workflow.indexOf(' signing-inputs:'), ); expect(renderInputs).toContain('needs: [prepare, release-environment-preflight]'); - expect(renderInputs).toContain('environment: pilot-nonproduction'); + expect(renderInputs).toContain('environment: release'); const signingInputs = workflow.slice( workflow.indexOf(' signing-inputs:'), workflow.indexOf(' render:'), ); expect(signingInputs).toContain('needs: [prepare, release-environment-preflight]'); - expect(signingInputs).toContain('environment: pilot-nonproduction'); - expect(workflow).not.toContain('environment: release'); - expect(workflow.split('release_environment: pilot-nonproduction')).toHaveLength(3); + expect(signingInputs).toContain('environment: release'); + expect(workflow.split(' environment: release')).toHaveLength(7); + expect(workflow.split('release_environment: release')).toHaveLength(3); }); - it('passes the isolated pilot environment through reusable signing workflows', async () => { + it('passes the release environment through reusable signing workflows', async () => { const [desktop, mobile] = await Promise.all([ readFile(new URL('../workflows/build-desktop.yml', import.meta.url), 'utf8'), readFile(new URL('../workflows/build-mobile.yml', import.meta.url), 'utf8'), diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index 99724028..82c9f56a 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -201,29 +201,29 @@ jobs: retention-days: 7 release-environment-preflight: - name: Protected live-pilot preflight + name: Protected release environment preflight if: ${{ inputs.build }} needs: prepare runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} steps: - - name: Require protected nonproduction release environment + - name: Require protected release environment env: - GH_TOKEN: ${{ secrets.PILOT_ENVIRONMENT_ADMIN_TOKEN }} + GH_TOKEN: ${{ secrets.RELEASE_ENVIRONMENT_ADMIN_TOKEN }} run: | set -euo pipefail if [ -z "$GH_TOKEN" ]; then - echo "::error::PILOT_ENVIRONMENT_ADMIN_TOKEN is required to inspect environment protection" + echo "::error::RELEASE_ENVIRONMENT_ADMIN_TOKEN is required to inspect environment protection" exit 1 fi - environment="$RUNNER_TEMP/pilot-environment.json" - gh api "repos/$GITHUB_REPOSITORY/environments/pilot-nonproduction" > "$environment" + environment="$RUNNER_TEMP/release-environment.json" + gh api "repos/$GITHUB_REPOSITORY/environments/release" > "$environment" if ! jq -e ' - .name == "pilot-nonproduction" and + .name == "release" and ([.protection_rules[]? | select(.type == "required_reviewers") | .reviewers | length] | any(. > 0)) and (.deployment_branch_policy != null) and (.deployment_branch_policy.protected_branches == true or .deployment_branch_policy.custom_branch_policies == true) ' "$environment" >/dev/null; then - echo "::error::pilot-nonproduction must require reviewers and a deployment branch policy before a live pilot" + echo "::error::release must require reviewers and a deployment branch policy before release work" exit 1 fi @@ -232,7 +232,7 @@ jobs: if: ${{ inputs.build }} needs: [prepare, release-environment-preflight] runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: pilot-nonproduction + environment: release env: CONFIG_PUBLISHER_REPO: ${{ vars.CONFIG_PUBLISHER_REPO }} CONFIG_PUBLISHER_TOKEN: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} @@ -249,7 +249,7 @@ jobs: if: ${{ inputs.sign }} needs: [prepare, release-environment-preflight] runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: pilot-nonproduction + environment: release env: APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} @@ -286,7 +286,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: pilot-nonproduction + environment: release steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -415,7 +415,7 @@ jobs: sign: ${{ inputs.sign }} brand_id: ${{ matrix.brandId }} delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} - release_environment: pilot-nonproduction + release_environment: release rendered_artifact: brand-render-${{ matrix.brandId }} update_url: ${{ matrix.distribution.desktop.updateUrl || '' }} @@ -554,7 +554,7 @@ jobs: ref: ${{ inputs.ref }} brand_id: ${{ matrix.brandId }} delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} - release_environment: pilot-nonproduction + release_environment: release rendered_artifact: brand-render-${{ matrix.brandId }} submit: false @@ -566,7 +566,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: pilot-nonproduction + environment: release env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} R2_ACCESS_KEY_ID: ${{ secrets[format('{0}_R2_ACCESS_KEY_ID', matrix.distribution.desktop.credentialSecretPrefix)] }} @@ -646,7 +646,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: pilot-nonproduction + environment: release steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -722,7 +722,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: pilot-nonproduction + environment: release env: AWS_ACCESS_KEY_ID: ${{ secrets[format('{0}_R2_ACCESS_KEY_ID', matrix.distribution.desktop.credentialSecretPrefix)] }} AWS_SECRET_ACCESS_KEY: ${{ secrets[format('{0}_R2_SECRET_ACCESS_KEY', matrix.distribution.desktop.credentialSecretPrefix)] }} diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 18b34708..a8e8c287 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -129,6 +129,7 @@ Set as GitHub repository/environment secrets, never locally. Signing and notariz | `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` | `build-desktop.yml` | `azure/login` **inputs** for OIDC federation. No `AZURE_*` credential env exists during packaging on purpose, so `DefaultAzureCredential` falls through to the Azure CLI entry. | | `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | `release-desktop.yml` | Cloudflare R2 credentials for publishing the electron-updater feed. `AWS_REQUEST_CHECKSUM_CALCULATION`/`AWS_RESPONSE_CHECKSUM_VALIDATION` are pinned to `WHEN_REQUIRED` because R2 doesn't implement the checksums recent aws-cli sends. | | `CONFIG_PUBLISHER_TOKEN` | release workflows | Fine-grained token with Contents read-only access to `CONFIG_PUBLISHER_REPO`; used only to fetch exact commits pinned by release manifests. | +| `RELEASE_ENVIRONMENT_ADMIN_TOKEN` | `release-brand-matrix.yml` | Repository/org-scoped token authorized to inspect the `release` environment configuration. The preflight runs before entering that environment and fails unless it has required reviewers and a non-null deployment branch policy, so this token cannot be stored only inside `release`. | | `_R2_ACCOUNT_ID`, `_R2_ACCESS_KEY_ID`, `_R2_SECRET_ACCESS_KEY` | `release-brand-matrix.yml` | Per-brand R2 account and S3 credentials. `` is the validated `credentialSecretPrefix` in that brand's matrix row. Scope each key pair to only that row's bucket/prefix with object read/write/list; never share one prefix between brands. | | `BOT_APP_ID`, `BOT_APP_PRIVATE_KEY` | `release-please.yml`, `finalize-releases.yml`, `release-desktop.yml` | Repository/org-scoped GitHub App credentials. The App needs Contents, Issues, and Pull requests read/write on this repo so release-please can maintain PRs, draft Releases, and tags; the release environment also uses it for the Homebrew cask bump and the WinGet bump (install the App on `arcboxlabs/homebrew-tap` and on the `arcboxlabs/winget-pkgs` fork with contents + pull-requests write). Missing credentials fail release automation before any tag is created; only the package-manager bumps remain an optional self-skip. | From cffc9119d724bc374c630f371fc4bfb36fa47617 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 10 Aug 2026 12:07:53 +0000 Subject: [PATCH 4/6] feat(release): render independent config source Amp-Thread-ID: https://ampcode.com/threads/T-019feb48-7ae2-72cf-85f9-4a9ce1de64eb --- .../actions/render-release-config/action.yml | 86 ++++++++++++++----- .../brand-matrices/code-561-pilot.json | 24 +++--- .github/scripts/brand-matrix.test.mjs | 66 +++++++++++++- .github/scripts/release-inputs.cjs | 2 - .github/scripts/release-inputs.test.mjs | 4 +- .github/workflows/build-desktop.yml | 43 ++++++++-- .github/workflows/build-mobile.yml | 35 +++++++- .github/workflows/release-brand-matrix.yml | 53 +++++++++--- docs/ENVIRONMENT.md | 6 +- docs/RELEASE.md | 43 +++++++--- 10 files changed, 288 insertions(+), 74 deletions(-) diff --git a/.github/actions/render-release-config/action.yml b/.github/actions/render-release-config/action.yml index 171ef99e..672a15fc 100644 --- a/.github/actions/render-release-config/action.yml +++ b/.github/actions/render-release-config/action.yml @@ -9,14 +9,16 @@ inputs: app: description: Which app to render for (desktop or mobile) required: true - publisher-repo: - description: owner/name of the config publisher repository (vars.CONFIG_PUBLISHER_REPO) - required: false - default: "" publisher-token: - description: Token that can read the config publisher repository (secrets.CONFIG_PUBLISHER_TOKEN) + description: Short-lived Contents read token restricted to arcboxlabs/linkcodehq + required: true + source-token: + description: Short-lived Contents read token restricted to arcboxlabs/linkcode-config + required: true + source-root: + description: Reviewed source root (repository root for production or examples/acme-zenith) required: false - default: "" + default: "." revision: description: Config revision metadata JSON content (vars.CONFIG_RELEASE_REVISION) required: false @@ -49,8 +51,9 @@ runs: shell: bash env: APP: ${{ inputs.app }} - PUBLISHER_REPO: ${{ inputs.publisher-repo }} PUBLISHER_TOKEN: ${{ inputs.publisher-token }} + SOURCE_TOKEN: ${{ inputs.source-token }} + SOURCE_ROOT: ${{ inputs.source-root }} REVISION_JSON: ${{ inputs.revision }} KEYRINGS_JSON: ${{ inputs.keyrings }} MANIFEST_DESKTOP: ${{ inputs.release-manifest }} @@ -60,9 +63,12 @@ runs: run: | set -euo pipefail + if [ -z "$PUBLISHER_TOKEN" ] || [ -z "$SOURCE_TOKEN" ]; then + echo "::error::render-release-config requires separate short-lived Contents read tokens for arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + exit 1 + fi + missing=() - [ -n "$PUBLISHER_REPO" ] || missing+=(CONFIG_PUBLISHER_REPO) - [ -n "$PUBLISHER_TOKEN" ] || missing+=(CONFIG_PUBLISHER_TOKEN) [ -n "$REVISION_JSON" ] || missing+=(CONFIG_RELEASE_REVISION) [ -n "$KEYRINGS_JSON" ] || missing+=(CONFIG_RELEASE_KEYRINGS) case "$APP" in @@ -120,27 +126,67 @@ runs: brand="$(pin "$primary" .brandId)" channel="$(pin "$primary" .channel)" telemetry="$(pin "$primary" .telemetryEndpoint)" + if [[ ! "$publisher_sha" =~ ^[0-9a-f]{40}$ ]] || [[ ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::publisherGitSha and sourceGitSha must be exact lowercase 40-hex commits" + exit 1 + fi - # Fetch exactly the two pinned commits — never a branch head — and keep the token out of - # persisted git config by passing it per command. - auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$PUBLISHER_TOKEN" | base64 -w0)" + # Repository identities and source root are code-owned; release data controls only SHAs. + publisher_repo=arcboxlabs/linkcodehq + source_repo=arcboxlabs/linkcode-config + case "$SOURCE_ROOT" in + .|examples/acme-zenith) ;; + *) echo "::error::source-root must be the production repository root or the reviewed nonproduction example root"; exit 1 ;; + esac publisher="$work/publisher" - git init -q "$publisher" - git -C "$publisher" remote add origin "https://github.com/${PUBLISHER_REPO}.git" - if ! git -C "$publisher" -c "http.https://github.com/.extraheader=$auth" \ - fetch -q --depth 1 origin "$publisher_sha" "$source_sha"; then - echo "::error::Could not fetch pinned commits ${publisher_sha} / ${source_sha} from the config publisher repository. Release builds require read access to the private publisher repository and both pinned commits to exist." + source="$work/source" + + checkout_pinned() { + local dir="$1" repo="$2" sha="$3" token="$4" label="$5" + local auth + auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$token" | base64 -w0)" + git init -q "$dir" + git -C "$dir" remote add origin "https://github.com/${repo}.git" + if ! git -C "$dir" -c "http.https://github.com/.extraheader=$auth" \ + -c http.followRedirects=false \ + fetch -q --depth 1 origin "$sha"; then + echo "::error::Could not fetch $label commit $sha from $repo. Confirm the org App is installed on that private repository with Contents: read and the commit exists." + exit 1 + fi + git -C "$dir" checkout -q --detach FETCH_HEAD + if [ "$(git -C "$dir" rev-parse HEAD)" != "$sha" ] || \ + [ "$(git -C "$dir" remote get-url origin)" != "https://github.com/${repo}.git" ]; then + echo "::error::$label checkout identity did not match fixed repository $repo at $sha" + exit 1 + fi + } + + checkout_pinned "$publisher" "$publisher_repo" "$publisher_sha" "$PUBLISHER_TOKEN" publisher + checkout_pinned "$source" "$source_repo" "$source_sha" "$SOURCE_TOKEN" "config source" + + structural="$source/$SOURCE_ROOT" + if [ ! -f "$structural/brands.manifest.yaml" ] || \ + [ ! -f "$structural/schema/config.schema.json" ]; then + echo "::error::Pinned config source must contain source root $SOURCE_ROOT with its manifest and schema mirror; production root is intentionally unavailable until reviewed production data exists" + exit 1 + fi + if find "$structural" -type l -print -quit | grep -q .; then + echo "::error::Pinned config source root must not contain symbolic links" + exit 1 + fi + if ! cmp -s \ + "$publisher/packages/config-structural/schema/config.schema.json" \ + "$structural/schema/config.schema.json"; then + echo "::error::Config source schema mirror differs byte-for-byte from the canonical schema at publisher commit $publisher_sha" exit 1 fi - git -C "$publisher" checkout -q "$publisher_sha" - git -C "$publisher" worktree add -q --detach "$work/source" "$source_sha" pnpm --dir "$publisher" install --frozen-lockfile common_args=( --publisher "$publisher" --publisher-git-sha "$publisher_sha" - --structural "$work/source/packages/config-structural" + --structural "$structural" --source-git-sha "$source_sha" --revision "$work/revision.json" --keyrings "$work/keyrings.json" diff --git a/.github/release/brand-matrices/code-561-pilot.json b/.github/release/brand-matrices/code-561-pilot.json index ddc0f41a..bd158596 100644 --- a/.github/release/brand-matrices/code-561-pilot.json +++ b/.github/release/brand-matrices/code-561-pilot.json @@ -66,10 +66,10 @@ "expectedSnapshotSha256": "0675b1b33e81d4898f75233fdf9bda7243348e286ebfd3b06f807d82fff8818f", "platform": "android", "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", - "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", "releaseManifestFormatVersion": 1, "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", - "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", "telemetryEndpoint": "https://acme.example.invalid/telemetry" }, "desktop": { @@ -79,10 +79,10 @@ "expectedSnapshotSha256": "936250a3ef922cede3a200b5dc401cc7697ee1db90dc3efd0f873358524f01e3", "platform": "desktop", "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", - "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", "releaseManifestFormatVersion": 1, "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", - "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", "telemetryEndpoint": "https://acme.example.invalid/telemetry" }, "ios": { @@ -92,10 +92,10 @@ "expectedSnapshotSha256": "a689a8d95f74d9cb00b5d9850af3ecfd50edb23d2496c71805c9ffe4659d56ae", "platform": "ios", "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", - "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", "releaseManifestFormatVersion": 1, "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", - "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", "telemetryEndpoint": "https://acme.example.invalid/telemetry" } } @@ -165,10 +165,10 @@ "expectedSnapshotSha256": "a0ef5196645ae3b857343784f7a5ab5d6f5184b15c7cb646d8e86c93ff5384b0", "platform": "android", "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", - "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", "releaseManifestFormatVersion": 1, "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", - "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", "telemetryEndpoint": "https://zenith.example.invalid/telemetry" }, "desktop": { @@ -178,10 +178,10 @@ "expectedSnapshotSha256": "99a93cec0ca5381faa15a5def6727736f220b5d7d111e1fce04afda1d321aef2", "platform": "desktop", "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", - "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", "releaseManifestFormatVersion": 1, "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", - "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", "telemetryEndpoint": "https://zenith.example.invalid/telemetry" }, "ios": { @@ -191,10 +191,10 @@ "expectedSnapshotSha256": "e1b93b64973e0192ed2e1d8ba9a4cca27ae2bb5521ef6011392c2d86b510b95b", "platform": "ios", "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", - "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", "releaseManifestFormatVersion": 1, "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", - "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", "telemetryEndpoint": "https://zenith.example.invalid/telemetry" } } diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index e5a554e2..63c91584 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -101,7 +101,17 @@ describe('parseBrandBuildMatrix', () => { ]); expect( new Set(pilot.brands.map((entry) => entry.releaseManifests.desktop.publisherGitSha)), - ).toEqual(new Set(['e4a0624abbc8ed1cac4948fa90239176a83cb96e'])); + ).toEqual(new Set(['986d9f21403df53bc932f511eb1b5f0bb634d48d'])); + expect( + new Set(pilot.brands.map((entry) => entry.releaseManifests.desktop.sourceGitSha)), + ).toEqual(new Set(['a1ed4d666721c3aed0d563aaea42fce8b5f945b5'])); + expect( + pilot.brands.every( + (entry) => + entry.releaseManifests.desktop.publisherGitSha !== + entry.releaseManifests.desktop.sourceGitSha, + ), + ).toBe(true); expect( pilot.brands.every((entry) => Object.values(entry.distribution).every((x) => x === null)), ).toBe(true); @@ -300,7 +310,9 @@ describe('release brand matrix workflow', () => { expect(preflight).toContain('required_reviewers'); expect(preflight).toContain('deployment_branch_policy'); expect(preflight).toContain('gh api "repos/$GITHUB_REPOSITORY/environments/release"'); - expect(preflight).toContain('secrets.RELEASE_ENVIRONMENT_ADMIN_TOKEN'); + expect(preflight).toContain(`GH_TOKEN: ${ACTIONS_EXPRESSION}{{ github.token }}`); + expect(preflight).not.toContain('RELEASE_ENVIRONMENT_ADMIN_TOKEN'); + expect(workflow).toContain('actions: read'); expect(preflight).toContain('inputs.build'); const renderInputs = workflow.slice( workflow.indexOf(' render-inputs:'), @@ -339,6 +351,56 @@ describe('release brand matrix workflow', () => { ).toHaveLength(4); }); + it('mints scoped read tokens before any selected client checkout', async () => { + const [action, desktop, mobile, workflow] = await Promise.all([ + readFile(new URL('../actions/render-release-config/action.yml', import.meta.url), 'utf8'), + readFile(new URL('../workflows/build-desktop.yml', import.meta.url), 'utf8'), + readFile(new URL('../workflows/build-mobile.yml', import.meta.url), 'utf8'), + readFile(new URL('../workflows/release-brand-matrix.yml', import.meta.url), 'utf8'), + ]); + const appTokenAction = + 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1'; + + expect(action).toContain('publisher-token:'); + expect(action).toContain('source-token:'); + expect(action).not.toContain(appTokenAction); + expect(action).not.toContain('github-app-private-key'); + expect(action).not.toContain('BOT_APP_PRIVATE_KEY'); + expect(action).toContain('publisher_repo=arcboxlabs/linkcodehq'); + expect(action).toContain('source_repo=arcboxlabs/linkcode-config'); + expect(action).toContain('default: "."'); + expect(action).toContain('.|examples/acme-zenith)'); + expect(action).toContain('cmp -s'); + expect(action).toContain('http.followRedirects=false'); + expect(action).toContain('must be exact lowercase 40-hex commits'); + expect(action).toContain('must not contain symbolic links'); + expect(action).not.toContain('CONFIG_PUBLISHER_REPO'); + expect(action).not.toContain('CONFIG_PUBLISHER_TOKEN'); + + const renderJobs = [ + desktop.slice(desktop.indexOf(' render-config:'), desktop.indexOf(' build:')), + mobile.slice(mobile.indexOf(' render-config:'), mobile.indexOf(' build:')), + workflow.slice(workflow.indexOf(' render:'), workflow.indexOf(' desktop:')), + ]; + for (const renderJob of renderJobs) { + expect(renderJob.split(appTokenAction)).toHaveLength(3); + expect(renderJob.split('owner: arcboxlabs')).toHaveLength(3); + expect(renderJob).toContain('repositories: linkcodehq'); + expect(renderJob).toContain('repositories: linkcode-config'); + expect(renderJob.split('permission-contents: read')).toHaveLength(3); + expect(renderJob).toContain( + `publisher-token: ${ACTIONS_EXPRESSION}{{ steps.publisher-token.outputs.token }}`, + ); + expect(renderJob).toContain( + `source-token: ${ACTIONS_EXPRESSION}{{ steps.source-token.outputs.token }}`, + ); + expect(renderJob.indexOf(appTokenAction)).toBeLessThan( + renderJob.indexOf('actions/checkout@'), + ); + } + expect(workflow.split('source-root: examples/acme-zenith')).toHaveLength(3); + }); + it('binds credential-free desktop recovery evidence to immutable release inputs', async () => { const workflow = await readFile( new URL('../workflows/release-brand-matrix.yml', import.meta.url), diff --git a/.github/scripts/release-inputs.cjs b/.github/scripts/release-inputs.cjs index 2a780d3d..c138b643 100644 --- a/.github/scripts/release-inputs.cjs +++ b/.github/scripts/release-inputs.cjs @@ -6,8 +6,6 @@ const PLATFORMS = new Set(['desktop', 'mobile']); const RE_R2_ACCOUNT_ID = /^[0-9a-f]{32}$/; const INPUTS = { render: [ - ['var', 'CONFIG_PUBLISHER_REPO'], - ['secret', 'CONFIG_PUBLISHER_TOKEN'], ['var', 'CONFIG_RELEASE_KEYRINGS'], ['var', 'CONFIG_RELEASE_REVISION'], ], diff --git a/.github/scripts/release-inputs.test.mjs b/.github/scripts/release-inputs.test.mjs index cf476a8f..1d8953c0 100644 --- a/.github/scripts/release-inputs.test.mjs +++ b/.github/scripts/release-inputs.test.mjs @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import inputsModule from './release-inputs.cjs'; const { validateReleaseInputs } = inputsModule; -const RE_RENDER_MISSING = /var CONFIG_PUBLISHER_REPO.*secret CONFIG_PUBLISHER_TOKEN/; +const RE_RENDER_MISSING = /var CONFIG_RELEASE_KEYRINGS.*var CONFIG_RELEASE_REVISION/; const RE_MOBILE_SIGNING = /secret EXPO_TOKEN.*secret POSTHOG_PROJECT_TOKEN.*var POSTHOG_HOST.*secret SENTRY_AUTH_TOKEN.*secret SENTRY_DSN_MOBILE/; const RE_DESKTOP_UPLOAD = /R2_ACCESS_KEY_ID.*R2_ACCOUNT_ID.*R2_SECRET_ACCESS_KEY/; @@ -10,7 +10,7 @@ const RE_INVALID_KEY = /must encode an App Store Connect \.p8 key/; const RE_INVALID_ACCOUNT = /must be a lowercase 32-hex Cloudflare account ID/; describe('validateReleaseInputs', () => { - it('reports absent render vars and secrets by exact GitHub name', () => { + it('reports absent render vars by exact GitHub name', () => { expect(() => validateReleaseInputs({ env: {}, phase: 'render', platform: 'desktop' })).toThrow( RE_RENDER_MISSING, ); diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index cdce70f7..4e3d7bc9 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -94,17 +94,48 @@ jobs: runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} environment: ${{ inputs.release_environment || 'release' }} steps: + - name: Require organization App credentials + env: + BOT_APP_ID: ${{ secrets.BOT_APP_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} + run: | + if [ -z "$BOT_APP_ID" ] || [ -z "$BOT_APP_PRIVATE_KEY" ]; then + echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + exit 1 + fi + + - name: Mint publisher read token + id: publisher-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcodehq + permission-contents: read + + - name: Mint config source read token + id: source-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcode-config + permission-contents: read + - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ inputs.ref || github.ref }} + persist-credentials: false - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: run_install: false cache: true - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version-file: .nvmrc package-manager-cache: false @@ -116,14 +147,14 @@ jobs: uses: ./.github/actions/render-release-config with: app: desktop - publisher-repo: ${{ vars.CONFIG_PUBLISHER_REPO }} - publisher-token: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + publisher-token: ${{ steps.publisher-token.outputs.token }} + source-token: ${{ steps.source-token.outputs.token }} revision: ${{ vars.CONFIG_RELEASE_REVISION }} keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} release-manifest: ${{ vars.CONFIG_RELEASE_MANIFEST_DESKTOP }} - name: Upload rendered bundle - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-config-bundle path: apps/desktop/generated/config-build-bundle.json diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 654d9302..9a5bc098 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -86,10 +86,41 @@ jobs: timeout-minutes: 20 environment: ${{ inputs.release_environment || 'release' }} steps: + - name: Require organization App credentials + env: + BOT_APP_ID: ${{ secrets.BOT_APP_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} + run: | + if [ -z "$BOT_APP_ID" ] || [ -z "$BOT_APP_PRIVATE_KEY" ]; then + echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + exit 1 + fi + + - name: Mint publisher read token + id: publisher-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcodehq + permission-contents: read + + - name: Mint config source read token + id: source-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcode-config + permission-contents: read + - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ inputs.ref || github.ref }} + persist-credentials: false - name: Setup EAS uses: ./.github/actions/setup-eas @@ -101,8 +132,8 @@ jobs: uses: ./.github/actions/render-release-config with: app: mobile - publisher-repo: ${{ vars.CONFIG_PUBLISHER_REPO }} - publisher-token: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + publisher-token: ${{ steps.publisher-token.outputs.token }} + source-token: ${{ steps.source-token.outputs.token }} revision: ${{ vars.CONFIG_RELEASE_REVISION }} keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} release-manifest-ios: ${{ vars.CONFIG_RELEASE_MANIFEST_IOS }} diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index 82c9f56a..2cfe9cc5 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -38,8 +38,8 @@ concurrency: cancel-in-progress: false permissions: + actions: read contents: read - deployments: read jobs: prepare: @@ -208,13 +208,9 @@ jobs: steps: - name: Require protected release environment env: - GH_TOKEN: ${{ secrets.RELEASE_ENVIRONMENT_ADMIN_TOKEN }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - if [ -z "$GH_TOKEN" ]; then - echo "::error::RELEASE_ENVIRONMENT_ADMIN_TOKEN is required to inspect environment protection" - exit 1 - fi environment="$RUNNER_TEMP/release-environment.json" gh api "repos/$GITHUB_REPOSITORY/environments/release" > "$environment" if ! jq -e ' @@ -234,8 +230,6 @@ jobs: runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} environment: release env: - CONFIG_PUBLISHER_REPO: ${{ vars.CONFIG_PUBLISHER_REPO }} - CONFIG_PUBLISHER_TOKEN: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} CONFIG_RELEASE_KEYRINGS: ${{ vars.CONFIG_RELEASE_KEYRINGS }} CONFIG_RELEASE_REVISION: ${{ vars.CONFIG_RELEASE_REVISION }} steps: @@ -288,9 +282,40 @@ jobs: runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} environment: release steps: + - name: Require organization App credentials + env: + BOT_APP_ID: ${{ secrets.BOT_APP_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} + run: | + if [ -z "$BOT_APP_ID" ] || [ -z "$BOT_APP_PRIVATE_KEY" ]; then + echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + exit 1 + fi + + - name: Mint publisher read token + id: publisher-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcodehq + permission-contents: read + + - name: Mint config source read token + id: source-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcode-config + permission-contents: read + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ inputs.ref }} + persist-credentials: false - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: @@ -309,8 +334,9 @@ jobs: with: app: desktop brand-artifacts: true - publisher-repo: ${{ vars.CONFIG_PUBLISHER_REPO }} - publisher-token: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + publisher-token: ${{ steps.publisher-token.outputs.token }} + source-token: ${{ steps.source-token.outputs.token }} + source-root: examples/acme-zenith revision: ${{ vars.CONFIG_RELEASE_REVISION }} keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} release-manifest: ${{ toJSON(matrix.releaseManifests.desktop) }} @@ -319,8 +345,9 @@ jobs: uses: ./.github/actions/render-release-config with: app: mobile - publisher-repo: ${{ vars.CONFIG_PUBLISHER_REPO }} - publisher-token: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + publisher-token: ${{ steps.publisher-token.outputs.token }} + source-token: ${{ steps.source-token.outputs.token }} + source-root: examples/acme-zenith revision: ${{ vars.CONFIG_RELEASE_REVISION }} keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} release-manifest-ios: ${{ toJSON(matrix.releaseManifests.ios) }} @@ -341,7 +368,7 @@ jobs: set -euo pipefail mkdir release-inputs git cat-file blob "$CLIENT_REF:$MATRIX_FILE" > release-inputs/brand-build-matrix.json - cp "$RUNNER_TEMP/config-render-desktop/source/packages/config-structural/brands.manifest.yaml" release-inputs/ + cp "$RUNNER_TEMP/config-render-desktop/source/examples/acme-zenith/brands.manifest.yaml" release-inputs/ printf '%s' "$MANIFEST_DESKTOP" > release-inputs/release-manifest.desktop.json printf '%s' "$MANIFEST_IOS" > release-inputs/release-manifest.ios.json printf '%s' "$MANIFEST_ANDROID" > release-inputs/release-manifest.android.json diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index a8e8c287..fa01d067 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -110,7 +110,7 @@ client configuration or new build. | `CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER` | `apps/desktop/scripts/stage-sidecar.mts` | `aarch64-linux-gnu-gcc` for the linux-arm64 sidecar cross-build. | | `NODE_OPTIONS` | `.github/workflows/ci.yml` | `--max-old-space-size=4096` for every CI job. | | `POSTHOG_HOST` | desktop/mobile build workflows | Organization Actions variable mapped to the platform-specific PostHog host for production bundles. | -| `CONFIG_PUBLISHER_REPO`, `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS` | release workflows | Protected `release` environment vars. Repository name plus exact revision/public-keyring JSON bytes; release manifests digest-bind the JSON inputs. | +| `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS` | release workflows | Protected `release` environment vars containing exact revision/public-keyring JSON bytes; release manifests digest-bind the JSON inputs. Publisher and source repository identities are fixed in workflow code. | ## Release-only secrets @@ -128,10 +128,8 @@ Set as GitHub repository/environment secrets, never locally. Signing and notariz | `AZURE_PUBLISHER_NAME`, `AZURE_SIGN_ENDPOINT`, `AZURE_CODE_SIGNING_ACCOUNT`, `AZURE_CERTIFICATE_PROFILE` | `build-desktop.yml` | Windows Trusted Signing identifiers (not credentials, but kept as secrets so the public repo doesn't advertise the signing infrastructure). `AZURE_PUBLISHER_NAME` must match the certificate subject CN exactly. | | `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` | `build-desktop.yml` | `azure/login` **inputs** for OIDC federation. No `AZURE_*` credential env exists during packaging on purpose, so `DefaultAzureCredential` falls through to the Azure CLI entry. | | `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | `release-desktop.yml` | Cloudflare R2 credentials for publishing the electron-updater feed. `AWS_REQUEST_CHECKSUM_CALCULATION`/`AWS_RESPONSE_CHECKSUM_VALIDATION` are pinned to `WHEN_REQUIRED` because R2 doesn't implement the checksums recent aws-cli sends. | -| `CONFIG_PUBLISHER_TOKEN` | release workflows | Fine-grained token with Contents read-only access to `CONFIG_PUBLISHER_REPO`; used only to fetch exact commits pinned by release manifests. | -| `RELEASE_ENVIRONMENT_ADMIN_TOKEN` | `release-brand-matrix.yml` | Repository/org-scoped token authorized to inspect the `release` environment configuration. The preflight runs before entering that environment and fails unless it has required reviewers and a non-null deployment branch policy, so this token cannot be stored only inside `release`. | | `_R2_ACCOUNT_ID`, `_R2_ACCESS_KEY_ID`, `_R2_SECRET_ACCESS_KEY` | `release-brand-matrix.yml` | Per-brand R2 account and S3 credentials. `` is the validated `credentialSecretPrefix` in that brand's matrix row. Scope each key pair to only that row's bucket/prefix with object read/write/list; never share one prefix between brands. | -| `BOT_APP_ID`, `BOT_APP_PRIVATE_KEY` | `release-please.yml`, `finalize-releases.yml`, `release-desktop.yml` | Repository/org-scoped GitHub App credentials. The App needs Contents, Issues, and Pull requests read/write on this repo so release-please can maintain PRs, draft Releases, and tags; the release environment also uses it for the Homebrew cask bump and the WinGet bump (install the App on `arcboxlabs/homebrew-tap` and on the `arcboxlabs/winget-pkgs` fork with contents + pull-requests write). Missing credentials fail release automation before any tag is created; only the package-manager bumps remain an optional self-skip. | +| `BOT_APP_ID`, `BOT_APP_PRIVATE_KEY` | release and config-render workflows | Organization GitHub App credentials. The App needs Contents, Issues, and Pull requests read/write on this repo so release-please can maintain PRs, draft Releases, and tags; install it on private `arcboxlabs/linkcodehq` and `arcboxlabs/linkcode-config` so config rendering can mint separate short-lived tokens restricted to Contents read on each repository. Package-manager bumps additionally require installations on `arcboxlabs/homebrew-tap` and `arcboxlabs/winget-pkgs` with contents + pull-requests write. Missing credentials fail release automation before any tag is created; only package-manager bumps remain an optional self-skip. | Mobile certificates, provisioning profiles, the Android keystore, the App Store Connect API key, and the Google Play service-account key are EAS-managed credentials, not GitHub variables. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 68d241d2..c7362dbc 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -89,12 +89,21 @@ Desktop signing and R2 secrets live in the repo's GitHub **`release` Environment ## Immutable config bundle (build-time render) -Signed desktop builds and every mobile store build embed an immutable config bundle (bootstrap endpoints, public keyrings, bundled defaults) rendered at build time by the config publisher — the client never re-implements rendering. The `render-config` job in `build-desktop.yml` (signed builds only) and `build-mobile.yml` (always) calls `.github/actions/render-release-config`, which checks out the publisher and structural source at the **exact commits pinned by the release-render manifest**, renders through `pnpm -F @linkcode/ config:render`, and verifies the manifest's digest bindings (revision bytes, public keyring bytes, target identity, telemetry endpoint, expected snapshot SHA-256). Nothing falls back to a mutable ref, a global install, or stale generated output. +Signed desktop builds and every mobile store build embed an immutable config bundle (bootstrap endpoints, public keyrings, bundled defaults) rendered at build time by the config publisher — the client never re-implements rendering. The `render-config` job in `build-desktop.yml` (signed builds only) and `build-mobile.yml` (always) calls `.github/actions/render-release-config`, which checks out publisher code from fixed `arcboxlabs/linkcodehq` at `publisherGitSha` and structural data from fixed `arcboxlabs/linkcode-config` at the independent `sourceGitSha`. It renders through `pnpm -F @linkcode/ config:render` and verifies the manifest's digest bindings (revision bytes, public keyring bytes, target identity, telemetry endpoint, expected snapshot SHA-256). Nothing falls back to a mutable ref, a configurable repository, a global install, or stale generated output. + +Each checkout uses its own short-lived installation token minted from the organization secrets +`BOT_APP_ID` and `BOT_APP_PRIVATE_KEY`. Trusted workflow steps mint these tokens before checking out +the selected client ref; client-controlled actions receive only repository-scoped read tokens, +never the App private key. Each token requests only Contents read and is explicitly limited to +`linkcodehq` or `linkcode-config`. The App must be installed on both private repositories. Missing +secrets or installation access fail before rendering; no long-lived config-read token is used. + +Production rendering reads the root of `linkcode-config` and fails closed while production data is +absent. Workflow code may select only that root or the reviewed `examples/acme-zenith` root used by +the nonproduction pilot; configuration data cannot supply a repository or path. Inputs live in the GitHub **`release` environment** and a missing value fails the build with an actionable error: -- `CONFIG_PUBLISHER_REPO` (var) — `owner/name` of the private config publisher repository. -- `CONFIG_PUBLISHER_TOKEN` (secret) — read token for that repository. - `CONFIG_RELEASE_REVISION` / `CONFIG_RELEASE_KEYRINGS` (vars) — exact revision-metadata and public-keyrings JSON bytes; the manifest pins their SHA-256s, so drifted content fails closed. Public keys only — private keys never enter this repo or its CI. - `CONFIG_RELEASE_MANIFEST_DESKTOP` / `CONFIG_RELEASE_MANIFEST_IOS` / `CONFIG_RELEASE_MANIFEST_ANDROID` (vars) — release-render manifest v1 JSON per target (produced by the publisher's release flow), pinning `publisherGitSha`, `sourceGitSha`, brand/platform/channel, telemetry endpoint, input digests, and the expected published snapshot digest. @@ -114,6 +123,13 @@ the selected matrix and needs no credential. `build: true, sign: false` renders set per brand, creates unsigned Desktop packages, and validates production-Hermes exports plus iOS/Android prebuilds. Nothing is signed or submitted in that path. +The committed `code-561-pilot.json` is deterministic nonproduction evidence only. It pins +publisher `986d9f21403df53bc932f511eb1b5f0bb634d48d`, source +`a1ed4d666721c3aed0d563aaea42fce8b5f945b5`, and the source root +`examples/acme-zenith`. The render action byte-compares that root's generated schema mirror with +the canonical schema in the pinned publisher checkout before parsing. Acme and Zenith, their +`.invalid` endpoints, and this example root are not production brand data. + The JSON root contains `brandBuildMatrixVersion: 1` and a non-empty `brands` array. Every brand has exactly `brandId`, `channel`, `releaseManifests`, `compliance`, and `distribution`: @@ -151,14 +167,17 @@ upload inputs before any store submission or R2 upload can begin. ### Required Actions configuration and least privilege -Secrets and render vars below are read only from the protected `release` environment. The scripts -report every missing name and never default a signing or upload input: - -- Vars: `CONFIG_PUBLISHER_REPO`, `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS`, and - `POSTHOG_HOST`. Revision/keyring values are exact JSON bytes already digest-pinned by each release - manifest. -- Config source: secret `CONFIG_PUBLISHER_TOKEN`, a fine-grained token with **Contents: read** only - on `CONFIG_PUBLISHER_REPO`; no write or organization scope. +Signing secrets and render vars below are read from the protected `release` environment; the bot +credentials are organization secrets. Trusted workflow steps report missing bot credentials before +checking out selected client code, and the input scripts report missing render, signing, or upload +values without receiving those bot credentials: + +- Vars: `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS`, and `POSTHOG_HOST`. + Revision/keyring values are exact JSON bytes already digest-pinned by each release manifest. +- Config checkouts: organization secrets `BOT_APP_ID` and `BOT_APP_PRIVATE_KEY` mint separate, + short-lived installation tokens with **Contents: read** only on `arcboxlabs/linkcodehq` and + `arcboxlabs/linkcode-config`. The workflow fixes both repository identities and requests no write + or organization permission. - macOS Desktop: `MACOS_CSC_LINK`, `MACOS_CSC_KEY_PASSWORD`, `APPLE_API_KEY_BASE64`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`, and `APPLE_TEAM_ID`. The App Store Connect API key needs only Developer ID notarization access; it must not have app-management or finance roles. @@ -184,6 +203,8 @@ report every missing name and never default a signing or upload input: Do not store private signing material, access tokens, or service-account JSON in the committed matrix, repository files, artifacts, or Actions vars. Protect the `release` environment with required reviewers and exact deployment ref rules before enabling `sign` or `upload`. +The environment preflight reads protection metadata with the built-in `GITHUB_TOKEN` and explicit +`actions: read`; this metadata-only token cannot approve or bypass an environment review. ## Packaging inputs (staging & version pins) From 29b8f73911ba2f265ae8d47e8c94b511165d0671 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 10 Aug 2026 12:08:01 +0000 Subject: [PATCH 5/6] ci(config): validate cross-repository contract Amp-Thread-ID: https://ampcode.com/threads/T-019feb48-7ae2-72cf-85f9-4a9ce1de64eb --- .github/workflows/ci.yml | 84 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c856fdd8..348e6dea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -280,6 +280,86 @@ jobs: - name: Test run: cargo test --locked + config-integration: + name: Cross-repository config contract + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + timeout-minutes: 10 + steps: + - name: Require organization App credentials + env: + BOT_APP_ID: ${{ secrets.BOT_APP_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} + run: | + if [ -z "$BOT_APP_ID" ] || [ -z "$BOT_APP_PRIVATE_KEY" ]; then + echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so CI can read arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + exit 1 + fi + + - name: Mint publisher read token + id: publisher-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcodehq + permission-contents: read + + - name: Mint config source read token + id: source-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcode-config + permission-contents: read + + - name: Check out pinned publisher + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: arcboxlabs/linkcodehq + ref: 986d9f21403df53bc932f511eb1b5f0bb634d48d + token: ${{ steps.publisher-token.outputs.token }} + path: .config-validation/linkcodehq + persist-credentials: false + + - name: Check out pinned config source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: arcboxlabs/linkcode-config + ref: a1ed4d666721c3aed0d563aaea42fce8b5f945b5 + token: ${{ steps.source-token.outputs.token }} + path: .config-validation/linkcode-config + persist-credentials: false + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + version: 11.9.0 + run_install: false + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24" + package-manager-cache: false + + - name: Validate pinned source with the pinned parser + run: | + set -euo pipefail + hq="$GITHUB_WORKSPACE/.config-validation/linkcodehq" + config="$GITHUB_WORKSPACE/.config-validation/linkcode-config" + test "$(git -C "$hq" rev-parse HEAD)" = 986d9f21403df53bc932f511eb1b5f0bb634d48d + test "$(git -C "$config" rev-parse HEAD)" = a1ed4d666721c3aed0d563aaea42fce8b5f945b5 + test "$(node --version | cut -d. -f1)" = v24 + test "$(pnpm --version)" = 11.9.0 + pnpm --dir "$hq" --filter @linkcodehq/config-structural... \ + install --frozen-lockfile --ignore-scripts + pnpm --dir "$hq" --filter @linkcodehq/config-structural exec tsx \ + "$config/scripts/validate.mts" \ + --hq-root "$hq" \ + --source-root examples/acme-zenith + all-green: name: All Green runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} @@ -290,6 +370,7 @@ jobs: - webview - mobile - rust + - config-integration if: always() steps: @@ -298,3 +379,6 @@ jobs: if [ '${{ needs.typescript.result }}' != 'success' ] || [ '${{ needs.desktop.result }}' != 'success' ] || [ '${{ needs.webview.result }}' != 'success' ] || [ '${{ needs.mobile.result }}' != 'success' ] || [ '${{ needs.rust.result }}' != 'success' ]; then exit 1 fi + if [ '${{ needs.config-integration.result }}' != 'success' ] && [ '${{ needs.config-integration.result }}' != 'skipped' ]; then + exit 1 + fi From 9e1bc73842080bdf0d1a4a4dc158c81bc0775db9 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 14 Aug 2026 01:05:25 +0000 Subject: [PATCH 6/6] fix(release): select both config repositories Amp-Thread-ID: https://ampcode.com/threads/T-019feb48-7ae2-72cf-85f9-4a9ce1de64eb --- .../actions/render-release-config/action.yml | 81 +++++++++++++++++-- .github/scripts/brand-matrix.test.mjs | 60 ++++++++++++-- .github/scripts/release-inputs.cjs | 19 +++++ .github/scripts/release-inputs.test.mjs | 79 +++++++++++++++++- .github/workflows/build-desktop.yml | 44 ++++++++-- .github/workflows/build-mobile.yml | 38 ++++++++- .github/workflows/release-brand-matrix.yml | 42 +++++++++- docs/ENVIRONMENT.md | 4 +- docs/RELEASE.md | 33 +++++--- 9 files changed, 362 insertions(+), 38 deletions(-) diff --git a/.github/actions/render-release-config/action.yml b/.github/actions/render-release-config/action.yml index 672a15fc..2edab2dc 100644 --- a/.github/actions/render-release-config/action.yml +++ b/.github/actions/render-release-config/action.yml @@ -9,11 +9,17 @@ inputs: app: description: Which app to render for (desktop or mobile) required: true + publisher-repository: + description: Validated owner/name from vars.CONFIG_PUBLISHER_REPO + required: true publisher-token: - description: Short-lived Contents read token restricted to arcboxlabs/linkcodehq + description: Short-lived Contents read token restricted to publisher-repository + required: true + source-repository: + description: Validated owner/name from vars.CONFIG_SOURCE_REPO required: true source-token: - description: Short-lived Contents read token restricted to arcboxlabs/linkcode-config + description: Short-lived Contents read token restricted to source-repository required: true source-root: description: Reviewed source root (repository root for production or examples/acme-zenith) @@ -51,7 +57,9 @@ runs: shell: bash env: APP: ${{ inputs.app }} + PUBLISHER_REPO: ${{ inputs.publisher-repository }} PUBLISHER_TOKEN: ${{ inputs.publisher-token }} + SOURCE_REPO: ${{ inputs.source-repository }} SOURCE_TOKEN: ${{ inputs.source-token }} SOURCE_ROOT: ${{ inputs.source-root }} REVISION_JSON: ${{ inputs.revision }} @@ -63,8 +71,20 @@ runs: run: | set -euo pipefail + if [[ ! "$PUBLISHER_REPO" =~ ^arcboxlabs/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$ ]]; then + echo "::error::CONFIG_PUBLISHER_REPO must use canonical arcboxlabs/repository syntax" + exit 1 + fi + if [[ ! "$SOURCE_REPO" =~ ^arcboxlabs/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$ ]]; then + echo "::error::CONFIG_SOURCE_REPO must use canonical arcboxlabs/repository syntax" + exit 1 + fi + if [ "$PUBLISHER_REPO" = "$SOURCE_REPO" ]; then + echo "::error::CONFIG_PUBLISHER_REPO and CONFIG_SOURCE_REPO must identify different repositories" + exit 1 + fi if [ -z "$PUBLISHER_TOKEN" ] || [ -z "$SOURCE_TOKEN" ]; then - echo "::error::render-release-config requires separate short-lived Contents read tokens for arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + echo "::error::render-release-config requires separate short-lived Contents read tokens for $PUBLISHER_REPO and $SOURCE_REPO" exit 1 fi @@ -131,9 +151,10 @@ runs: exit 1 fi - # Repository identities and source root are code-owned; release data controls only SHAs. - publisher_repo=arcboxlabs/linkcodehq - source_repo=arcboxlabs/linkcode-config + # Repository identities are release-environment owned; the source root is workflow-owned. + # Manifests control exact commits and bind the rendered release inputs. + publisher_repo="$PUBLISHER_REPO" + source_repo="$SOURCE_REPO" case "$SOURCE_ROOT" in .|examples/acme-zenith) ;; *) echo "::error::source-root must be the production repository root or the reviewed nonproduction example root"; exit 1 ;; @@ -143,10 +164,42 @@ runs: checkout_pinned() { local dir="$1" repo="$2" sha="$3" token="$4" label="$5" - local auth + local auth comparison extra reviewed reviewed_name reviewed_sha + local reviewed_ref=refs/heads/master auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$token" | base64 -w0)" git init -q "$dir" git -C "$dir" remote add origin "https://github.com/${repo}.git" + if ! reviewed="$(git -C "$dir" \ + -c "http.https://github.com/.extraheader=$auth" \ + -c http.followRedirects=false \ + ls-remote --exit-code origin "$reviewed_ref")"; then + echo "::error::Could not resolve reviewed master for $label repository $repo" + exit 1 + fi + read -r reviewed_sha reviewed_name extra <<< "$reviewed" + if [[ ! "$reviewed_sha" =~ ^[0-9a-f]{40}$ ]] || \ + [ "$reviewed_name" != "$reviewed_ref" ] || [ -n "$extra" ]; then + echo "::error::$label reviewed ref identity did not match $reviewed_ref in $repo" + exit 1 + fi + comparison="$dir-reviewed-ancestry.json" + if ! curl --fail --silent --show-error --max-redirs 0 \ + --proto '=https' --tlsv1.2 \ + -H 'Accept: application/vnd.github+json' \ + -H "Authorization: Bearer $token" \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + -o "$comparison" \ + "https://api.github.com/repos/${repo}/compare/${sha}...${reviewed_sha}"; then + echo "::error::Could not verify that $label commit $sha is reachable from reviewed master in $repo" + exit 1 + fi + if ! jq -e --arg sha "$sha" ' + (.status == "ahead" or .status == "identical") and + .base_commit.sha == $sha and .merge_base_commit.sha == $sha + ' "$comparison" > /dev/null; then + echo "::error::$label commit $sha is not reachable from reviewed master in $repo" + exit 1 + fi if ! git -C "$dir" -c "http.https://github.com/.extraheader=$auth" \ -c http.followRedirects=false \ fetch -q --depth 1 origin "$sha"; then @@ -156,15 +209,27 @@ runs: git -C "$dir" checkout -q --detach FETCH_HEAD if [ "$(git -C "$dir" rev-parse HEAD)" != "$sha" ] || \ [ "$(git -C "$dir" remote get-url origin)" != "https://github.com/${repo}.git" ]; then - echo "::error::$label checkout identity did not match fixed repository $repo at $sha" + echo "::error::$label checkout identity did not match expected repository $repo at $sha" exit 1 fi } checkout_pinned "$publisher" "$publisher_repo" "$publisher_sha" "$PUBLISHER_TOKEN" publisher checkout_pinned "$source" "$source_repo" "$source_sha" "$SOURCE_TOKEN" "config source" + unset PUBLISHER_TOKEN SOURCE_TOKEN structural="$source/$SOURCE_ROOT" + if [ ! -f "$publisher/package.json" ] || \ + [ ! -f "$publisher/pnpm-lock.yaml" ] || \ + [ ! -f "$publisher/packages/config-publisher/package.json" ] || \ + [ ! -f "$publisher/packages/config-structural/schema/config.schema.json" ] || \ + [ -L "$publisher/package.json" ] || \ + [ -L "$publisher/pnpm-lock.yaml" ] || \ + [ -L "$publisher/packages/config-publisher/package.json" ] || \ + [ -L "$publisher/packages/config-structural/schema/config.schema.json" ]; then + echo "::error::Pinned publisher checkout does not satisfy the publisher/parser/schema contract" + exit 1 + fi if [ ! -f "$structural/brands.manifest.yaml" ] || \ [ ! -f "$structural/schema/config.schema.json" ]; then echo "::error::Pinned config source must contain source root $SOURCE_ROOT with its manifest and schema mirror; production root is intentionally unavailable until reviewed production data exists" diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index 63c91584..c6724fcf 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -366,16 +366,30 @@ describe('release brand matrix workflow', () => { expect(action).not.toContain(appTokenAction); expect(action).not.toContain('github-app-private-key'); expect(action).not.toContain('BOT_APP_PRIVATE_KEY'); - expect(action).toContain('publisher_repo=arcboxlabs/linkcodehq'); - expect(action).toContain('source_repo=arcboxlabs/linkcode-config'); + expect(action).toContain('publisher-repository:'); + expect(action).toContain('publisher_repo="$PUBLISHER_REPO"'); + expect(action).toContain('source-repository:'); + expect(action).toContain('source_repo="$SOURCE_REPO"'); expect(action).toContain('default: "."'); expect(action).toContain('.|examples/acme-zenith)'); expect(action).toContain('cmp -s'); expect(action).toContain('http.followRedirects=false'); + expect(action).toContain('--max-redirs 0'); + expect(action).toContain('refs/heads/master'); + expect(action).toContain('not reachable from reviewed master'); expect(action).toContain('must be exact lowercase 40-hex commits'); expect(action).toContain('must not contain symbolic links'); - expect(action).not.toContain('CONFIG_PUBLISHER_REPO'); + expect(action).toContain('CONFIG_PUBLISHER_REPO'); + expect(action).toContain('CONFIG_SOURCE_REPO'); expect(action).not.toContain('CONFIG_PUBLISHER_TOKEN'); + expect(action).not.toContain('CONFIG_SOURCE_TOKEN'); + expect(action).toContain('checkout identity did not match expected repository'); + expect(action).toContain('publisher/parser/schema contract'); + expect(action).toContain('Pinned config source must contain source root'); + expect(action).toContain('unset PUBLISHER_TOKEN SOURCE_TOKEN'); + expect(action.indexOf('unset PUBLISHER_TOKEN SOURCE_TOKEN')).toBeLessThan( + action.indexOf('pnpm --dir "$publisher" install --frozen-lockfile'), + ); const renderJobs = [ desktop.slice(desktop.indexOf(' render-config:'), desktop.indexOf(' build:')), @@ -385,9 +399,29 @@ describe('release brand matrix workflow', () => { for (const renderJob of renderJobs) { expect(renderJob.split(appTokenAction)).toHaveLength(3); expect(renderJob.split('owner: arcboxlabs')).toHaveLength(3); - expect(renderJob).toContain('repositories: linkcodehq'); - expect(renderJob).toContain('repositories: linkcode-config'); + expect(renderJob).toContain( + `CONFIG_PUBLISHER_REPO: ${ACTIONS_EXPRESSION}{{ vars.CONFIG_PUBLISHER_REPO }}`, + ); + expect(renderJob).toContain( + `CONFIG_SOURCE_REPO: ${ACTIONS_EXPRESSION}{{ vars.CONFIG_SOURCE_REPO }}`, + ); + expect(renderJob).toContain('$name is required'); + expect(renderJob).toContain('must use canonical owner/repository syntax'); + expect(renderJob).toContain('$name owner must be arcboxlabs'); + expect(renderJob).toContain('must identify different repositories'); + expect(renderJob).toContain( + `repositories: ${ACTIONS_EXPRESSION}{{ steps.repositories.outputs.publisher-name }}`, + ); + expect(renderJob).toContain( + `repositories: ${ACTIONS_EXPRESSION}{{ steps.repositories.outputs.source-name }}`, + ); expect(renderJob.split('permission-contents: read')).toHaveLength(3); + expect(renderJob).toContain( + `publisher-repository: ${ACTIONS_EXPRESSION}{{ steps.repositories.outputs.publisher-full }}`, + ); + expect(renderJob).toContain( + `source-repository: ${ACTIONS_EXPRESSION}{{ steps.repositories.outputs.source-full }}`, + ); expect(renderJob).toContain( `publisher-token: ${ACTIONS_EXPRESSION}{{ steps.publisher-token.outputs.token }}`, ); @@ -397,10 +431,26 @@ describe('release brand matrix workflow', () => { expect(renderJob.indexOf(appTokenAction)).toBeLessThan( renderJob.indexOf('actions/checkout@'), ); + expect(renderJob).not.toContain('repositories: linkcodehq'); + expect(renderJob).not.toContain('repositories: linkcode-config'); } expect(workflow.split('source-root: examples/acme-zenith')).toHaveLength(3); }); + it('rejects publisher/source role swaps through independent checkout contracts', async () => { + const action = await readFile( + new URL('../actions/render-release-config/action.yml', import.meta.url), + 'utf8', + ); + + expect(action).toContain('$publisher/packages/config-publisher/package.json'); + expect(action).toContain('$publisher/packages/config-structural/schema/config.schema.json'); + expect(action).toContain('$structural/brands.manifest.yaml'); + expect(action).toContain('$structural/schema/config.schema.json'); + expect(action).toContain('publisher/parser/schema contract'); + expect(action).toContain('Pinned config source must contain source root'); + }); + it('binds credential-free desktop recovery evidence to immutable release inputs', async () => { const workflow = await readFile( new URL('../workflows/release-brand-matrix.yml', import.meta.url), diff --git a/.github/scripts/release-inputs.cjs b/.github/scripts/release-inputs.cjs index c138b643..83bcff11 100644 --- a/.github/scripts/release-inputs.cjs +++ b/.github/scripts/release-inputs.cjs @@ -4,8 +4,11 @@ const process = require('node:process'); const PHASES = new Set(['render', 'sign', 'upload']); const PLATFORMS = new Set(['desktop', 'mobile']); const RE_R2_ACCOUNT_ID = /^[0-9a-f]{32}$/; +const RE_PUBLISHER_REPOSITORY = /^([a-z\d][a-z\d-]{0,38})\/[a-z\d][\w.-]{0,99}$/i; const INPUTS = { render: [ + ['var', 'CONFIG_PUBLISHER_REPO'], + ['var', 'CONFIG_SOURCE_REPO'], ['var', 'CONFIG_RELEASE_KEYRINGS'], ['var', 'CONFIG_RELEASE_REVISION'], ], @@ -56,6 +59,22 @@ function validateReleaseInputs({ env, phase, platform }) { `${phase}/${platform}: missing GitHub release environment inputs: ${formatted}`, ); } + if (phase === 'render') { + for (const name of ['CONFIG_PUBLISHER_REPO', 'CONFIG_SOURCE_REPO']) { + const repository = RE_PUBLISHER_REPOSITORY.exec(env[name]); + if (!repository) { + throw new TypeError(`render: var ${name} must use canonical owner/repository syntax`); + } + if (repository[1] !== 'arcboxlabs') { + throw new TypeError(`render: var ${name} owner must be arcboxlabs`); + } + } + if (env.CONFIG_PUBLISHER_REPO === env.CONFIG_SOURCE_REPO) { + throw new TypeError( + 'render: CONFIG_PUBLISHER_REPO and CONFIG_SOURCE_REPO must identify different repositories', + ); + } + } if (phase === 'sign' && platform === 'desktop') { let key; try { diff --git a/.github/scripts/release-inputs.test.mjs b/.github/scripts/release-inputs.test.mjs index 1d8953c0..7be35506 100644 --- a/.github/scripts/release-inputs.test.mjs +++ b/.github/scripts/release-inputs.test.mjs @@ -2,12 +2,19 @@ import { describe, expect, it } from 'vitest'; import inputsModule from './release-inputs.cjs'; const { validateReleaseInputs } = inputsModule; -const RE_RENDER_MISSING = /var CONFIG_RELEASE_KEYRINGS.*var CONFIG_RELEASE_REVISION/; +const RE_RENDER_MISSING = + /var CONFIG_PUBLISHER_REPO.*var CONFIG_SOURCE_REPO.*var CONFIG_RELEASE_KEYRINGS.*var CONFIG_RELEASE_REVISION/; const RE_MOBILE_SIGNING = /secret EXPO_TOKEN.*secret POSTHOG_PROJECT_TOKEN.*var POSTHOG_HOST.*secret SENTRY_AUTH_TOKEN.*secret SENTRY_DSN_MOBILE/; const RE_DESKTOP_UPLOAD = /R2_ACCESS_KEY_ID.*R2_ACCOUNT_ID.*R2_SECRET_ACCESS_KEY/; const RE_INVALID_KEY = /must encode an App Store Connect \.p8 key/; const RE_INVALID_ACCOUNT = /must be a lowercase 32-hex Cloudflare account ID/; +const RE_CANONICAL_REPOSITORY = /must use canonical owner\/repository syntax/; +const RE_ARCBOXLABS_OWNER = /owner must be arcboxlabs/; +const RE_SOURCE_CANONICAL_REPOSITORY = + /CONFIG_SOURCE_REPO must use canonical owner\/repository syntax/; +const RE_SOURCE_ARCBOXLABS_OWNER = /CONFIG_SOURCE_REPO owner must be arcboxlabs/; +const RE_DIFFERENT_REPOSITORIES = /must identify different repositories/; describe('validateReleaseInputs', () => { it('reports absent render vars by exact GitHub name', () => { @@ -16,6 +23,76 @@ describe('validateReleaseInputs', () => { ); }); + it('rejects malformed and cross-organization config repositories', () => { + const renderEnv = { + CONFIG_PUBLISHER_REPO: 'arcboxlabs/config-publisher', + CONFIG_SOURCE_REPO: 'arcboxlabs/config-source', + CONFIG_RELEASE_KEYRINGS: '{}', + CONFIG_RELEASE_REVISION: '{}', + }; + expect(() => + validateReleaseInputs({ + env: { + ...renderEnv, + CONFIG_PUBLISHER_REPO: 'https://github.com/arcboxlabs/publisher', + }, + phase: 'render', + platform: 'desktop', + }), + ).toThrow(RE_CANONICAL_REPOSITORY); + expect(() => + validateReleaseInputs({ + env: { ...renderEnv, CONFIG_PUBLISHER_REPO: 'another-org/config-publisher' }, + phase: 'render', + platform: 'desktop', + }), + ).toThrow(RE_ARCBOXLABS_OWNER); + expect(() => + validateReleaseInputs({ + env: { ...renderEnv, CONFIG_SOURCE_REPO: 'arcboxlabs/source/extra' }, + phase: 'render', + platform: 'desktop', + }), + ).toThrow(RE_SOURCE_CANONICAL_REPOSITORY); + expect(() => + validateReleaseInputs({ + env: { ...renderEnv, CONFIG_SOURCE_REPO: 'another-org/config-source' }, + phase: 'render', + platform: 'desktop', + }), + ).toThrow(RE_SOURCE_ARCBOXLABS_OWNER); + }); + + it('accepts non-hardcoded publisher and source repositories in the ArcBox Labs organization', () => { + expect(() => + validateReleaseInputs({ + env: { + CONFIG_PUBLISHER_REPO: 'arcboxlabs/config-publisher', + CONFIG_SOURCE_REPO: 'arcboxlabs/config-source', + CONFIG_RELEASE_KEYRINGS: '{}', + CONFIG_RELEASE_REVISION: '{}', + }, + phase: 'render', + platform: 'desktop', + }), + ).not.toThrow(); + }); + + it('rejects equal publisher and source repository roles', () => { + expect(() => + validateReleaseInputs({ + env: { + CONFIG_PUBLISHER_REPO: 'arcboxlabs/config-repository', + CONFIG_SOURCE_REPO: 'arcboxlabs/config-repository', + CONFIG_RELEASE_KEYRINGS: '{}', + CONFIG_RELEASE_REVISION: '{}', + }, + phase: 'render', + platform: 'desktop', + }), + ).toThrow(RE_DIFFERENT_REPOSITORIES); + }); + it('requires signing and upload inputs only for the requested platform', () => { expect(() => validateReleaseInputs({ env: {}, phase: 'sign', platform: 'mobile' })).toThrow( RE_MOBILE_SIGNING, diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 4e3d7bc9..89ba8814 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -85,22 +85,52 @@ env: LINKCODE_REQUIRE_CONFIG_BUNDLE: ${{ (inputs.sign || inputs.rendered_artifact != '') && '1' || '' }} jobs: - # Renders the immutable config bundle from the pinned config publisher checkout (release - # environment holds the pins and the publisher read token). Unsigned builds skip this and - # build without a bundle; signed builds hard-require its output. + # Renders the immutable config bundle from the pinned config publisher checkout. The release + # environment selects the publisher repository; the workflow mints its scoped read token. + # Unsigned builds skip this and build without a bundle; signed builds hard-require its output. render-config: name: Render immutable config if: ${{ inputs.sign && inputs.rendered_artifact == '' }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} environment: ${{ inputs.release_environment || 'release' }} steps: + - name: Validate config repositories + id: repositories + env: + CONFIG_PUBLISHER_REPO: ${{ vars.CONFIG_PUBLISHER_REPO }} + CONFIG_SOURCE_REPO: ${{ vars.CONFIG_SOURCE_REPO }} + run: | + set -euo pipefail + validate_repository() { + local name="$1" value="$2" prefix="$3" + if [ -z "$value" ]; then + echo "::error::$name is required in the protected release environment" + exit 1 + fi + if [[ ! "$value" =~ ^([A-Za-z0-9][A-Za-z0-9-]{0,38})/([A-Za-z0-9][A-Za-z0-9._-]{0,99})$ ]]; then + echo "::error::$name must use canonical owner/repository syntax" + exit 1 + fi + if [ "${BASH_REMATCH[1]}" != arcboxlabs ]; then + echo "::error::$name owner must be arcboxlabs" + exit 1 + fi + printf '%s-full=%s\n%s-name=%s\n' "$prefix" "$value" "$prefix" "${BASH_REMATCH[2]}" >> "$GITHUB_OUTPUT" + } + validate_repository CONFIG_PUBLISHER_REPO "$CONFIG_PUBLISHER_REPO" publisher + validate_repository CONFIG_SOURCE_REPO "$CONFIG_SOURCE_REPO" source + if [ "$CONFIG_PUBLISHER_REPO" = "$CONFIG_SOURCE_REPO" ]; then + echo "::error::CONFIG_PUBLISHER_REPO and CONFIG_SOURCE_REPO must identify different repositories" + exit 1 + fi + - name: Require organization App credentials env: BOT_APP_ID: ${{ secrets.BOT_APP_ID }} BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} run: | if [ -z "$BOT_APP_ID" ] || [ -z "$BOT_APP_PRIVATE_KEY" ]; then - echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read the selected publisher and source repositories" exit 1 fi @@ -111,7 +141,7 @@ jobs: app-id: ${{ secrets.BOT_APP_ID }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} owner: arcboxlabs - repositories: linkcodehq + repositories: ${{ steps.repositories.outputs.publisher-name }} permission-contents: read - name: Mint config source read token @@ -121,7 +151,7 @@ jobs: app-id: ${{ secrets.BOT_APP_ID }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} owner: arcboxlabs - repositories: linkcode-config + repositories: ${{ steps.repositories.outputs.source-name }} permission-contents: read - name: Checkout @@ -147,7 +177,9 @@ jobs: uses: ./.github/actions/render-release-config with: app: desktop + publisher-repository: ${{ steps.repositories.outputs.publisher-full }} publisher-token: ${{ steps.publisher-token.outputs.token }} + source-repository: ${{ steps.repositories.outputs.source-full }} source-token: ${{ steps.source-token.outputs.token }} revision: ${{ vars.CONFIG_RELEASE_REVISION }} keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 9a5bc098..0d881d0c 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -86,13 +86,43 @@ jobs: timeout-minutes: 20 environment: ${{ inputs.release_environment || 'release' }} steps: + - name: Validate config repositories + id: repositories + env: + CONFIG_PUBLISHER_REPO: ${{ vars.CONFIG_PUBLISHER_REPO }} + CONFIG_SOURCE_REPO: ${{ vars.CONFIG_SOURCE_REPO }} + run: | + set -euo pipefail + validate_repository() { + local name="$1" value="$2" prefix="$3" + if [ -z "$value" ]; then + echo "::error::$name is required in the protected release environment" + exit 1 + fi + if [[ ! "$value" =~ ^([A-Za-z0-9][A-Za-z0-9-]{0,38})/([A-Za-z0-9][A-Za-z0-9._-]{0,99})$ ]]; then + echo "::error::$name must use canonical owner/repository syntax" + exit 1 + fi + if [ "${BASH_REMATCH[1]}" != arcboxlabs ]; then + echo "::error::$name owner must be arcboxlabs" + exit 1 + fi + printf '%s-full=%s\n%s-name=%s\n' "$prefix" "$value" "$prefix" "${BASH_REMATCH[2]}" >> "$GITHUB_OUTPUT" + } + validate_repository CONFIG_PUBLISHER_REPO "$CONFIG_PUBLISHER_REPO" publisher + validate_repository CONFIG_SOURCE_REPO "$CONFIG_SOURCE_REPO" source + if [ "$CONFIG_PUBLISHER_REPO" = "$CONFIG_SOURCE_REPO" ]; then + echo "::error::CONFIG_PUBLISHER_REPO and CONFIG_SOURCE_REPO must identify different repositories" + exit 1 + fi + - name: Require organization App credentials env: BOT_APP_ID: ${{ secrets.BOT_APP_ID }} BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} run: | if [ -z "$BOT_APP_ID" ] || [ -z "$BOT_APP_PRIVATE_KEY" ]; then - echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read the selected publisher and source repositories" exit 1 fi @@ -103,7 +133,7 @@ jobs: app-id: ${{ secrets.BOT_APP_ID }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} owner: arcboxlabs - repositories: linkcodehq + repositories: ${{ steps.repositories.outputs.publisher-name }} permission-contents: read - name: Mint config source read token @@ -113,7 +143,7 @@ jobs: app-id: ${{ secrets.BOT_APP_ID }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} owner: arcboxlabs - repositories: linkcode-config + repositories: ${{ steps.repositories.outputs.source-name }} permission-contents: read - name: Checkout @@ -132,7 +162,9 @@ jobs: uses: ./.github/actions/render-release-config with: app: mobile + publisher-repository: ${{ steps.repositories.outputs.publisher-full }} publisher-token: ${{ steps.publisher-token.outputs.token }} + source-repository: ${{ steps.repositories.outputs.source-full }} source-token: ${{ steps.source-token.outputs.token }} revision: ${{ vars.CONFIG_RELEASE_REVISION }} keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index 2cfe9cc5..8ba8c7c5 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -230,6 +230,8 @@ jobs: runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} environment: release env: + CONFIG_PUBLISHER_REPO: ${{ vars.CONFIG_PUBLISHER_REPO }} + CONFIG_SOURCE_REPO: ${{ vars.CONFIG_SOURCE_REPO }} CONFIG_RELEASE_KEYRINGS: ${{ vars.CONFIG_RELEASE_KEYRINGS }} CONFIG_RELEASE_REVISION: ${{ vars.CONFIG_RELEASE_REVISION }} steps: @@ -282,13 +284,43 @@ jobs: runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} environment: release steps: + - name: Validate config repositories + id: repositories + env: + CONFIG_PUBLISHER_REPO: ${{ vars.CONFIG_PUBLISHER_REPO }} + CONFIG_SOURCE_REPO: ${{ vars.CONFIG_SOURCE_REPO }} + run: | + set -euo pipefail + validate_repository() { + local name="$1" value="$2" prefix="$3" + if [ -z "$value" ]; then + echo "::error::$name is required in the protected release environment" + exit 1 + fi + if [[ ! "$value" =~ ^([A-Za-z0-9][A-Za-z0-9-]{0,38})/([A-Za-z0-9][A-Za-z0-9._-]{0,99})$ ]]; then + echo "::error::$name must use canonical owner/repository syntax" + exit 1 + fi + if [ "${BASH_REMATCH[1]}" != arcboxlabs ]; then + echo "::error::$name owner must be arcboxlabs" + exit 1 + fi + printf '%s-full=%s\n%s-name=%s\n' "$prefix" "$value" "$prefix" "${BASH_REMATCH[2]}" >> "$GITHUB_OUTPUT" + } + validate_repository CONFIG_PUBLISHER_REPO "$CONFIG_PUBLISHER_REPO" publisher + validate_repository CONFIG_SOURCE_REPO "$CONFIG_SOURCE_REPO" source + if [ "$CONFIG_PUBLISHER_REPO" = "$CONFIG_SOURCE_REPO" ]; then + echo "::error::CONFIG_PUBLISHER_REPO and CONFIG_SOURCE_REPO must identify different repositories" + exit 1 + fi + - name: Require organization App credentials env: BOT_APP_ID: ${{ secrets.BOT_APP_ID }} BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} run: | if [ -z "$BOT_APP_ID" ] || [ -z "$BOT_APP_PRIVATE_KEY" ]; then - echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read the selected publisher and source repositories" exit 1 fi @@ -299,7 +331,7 @@ jobs: app-id: ${{ secrets.BOT_APP_ID }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} owner: arcboxlabs - repositories: linkcodehq + repositories: ${{ steps.repositories.outputs.publisher-name }} permission-contents: read - name: Mint config source read token @@ -309,7 +341,7 @@ jobs: app-id: ${{ secrets.BOT_APP_ID }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} owner: arcboxlabs - repositories: linkcode-config + repositories: ${{ steps.repositories.outputs.source-name }} permission-contents: read - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -334,7 +366,9 @@ jobs: with: app: desktop brand-artifacts: true + publisher-repository: ${{ steps.repositories.outputs.publisher-full }} publisher-token: ${{ steps.publisher-token.outputs.token }} + source-repository: ${{ steps.repositories.outputs.source-full }} source-token: ${{ steps.source-token.outputs.token }} source-root: examples/acme-zenith revision: ${{ vars.CONFIG_RELEASE_REVISION }} @@ -345,7 +379,9 @@ jobs: uses: ./.github/actions/render-release-config with: app: mobile + publisher-repository: ${{ steps.repositories.outputs.publisher-full }} publisher-token: ${{ steps.publisher-token.outputs.token }} + source-repository: ${{ steps.repositories.outputs.source-full }} source-token: ${{ steps.source-token.outputs.token }} source-root: examples/acme-zenith revision: ${{ vars.CONFIG_RELEASE_REVISION }} diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index fa01d067..a716bfc3 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -110,7 +110,7 @@ client configuration or new build. | `CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER` | `apps/desktop/scripts/stage-sidecar.mts` | `aarch64-linux-gnu-gcc` for the linux-arm64 sidecar cross-build. | | `NODE_OPTIONS` | `.github/workflows/ci.yml` | `--max-old-space-size=4096` for every CI job. | | `POSTHOG_HOST` | desktop/mobile build workflows | Organization Actions variable mapped to the platform-specific PostHog host for production bundles. | -| `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS` | release workflows | Protected `release` environment vars containing exact revision/public-keyring JSON bytes; release manifests digest-bind the JSON inputs. Publisher and source repository identities are fixed in workflow code. | +| `CONFIG_PUBLISHER_REPO`, `CONFIG_SOURCE_REPO`, `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS` | release workflows | Protected `release` environment vars. The repository vars are different canonical `arcboxlabs/repository` identities for publisher code and structural source data; trusted steps validate them before minting separate repository-scoped read tokens. Current values are `arcboxlabs/linkcodehq` and `arcboxlabs/linkcode-config`. Release manifests bind their commits independently and digest-bind the exact revision/public-keyring JSON inputs. | ## Release-only secrets @@ -129,7 +129,7 @@ Set as GitHub repository/environment secrets, never locally. Signing and notariz | `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` | `build-desktop.yml` | `azure/login` **inputs** for OIDC federation. No `AZURE_*` credential env exists during packaging on purpose, so `DefaultAzureCredential` falls through to the Azure CLI entry. | | `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | `release-desktop.yml` | Cloudflare R2 credentials for publishing the electron-updater feed. `AWS_REQUEST_CHECKSUM_CALCULATION`/`AWS_RESPONSE_CHECKSUM_VALIDATION` are pinned to `WHEN_REQUIRED` because R2 doesn't implement the checksums recent aws-cli sends. | | `_R2_ACCOUNT_ID`, `_R2_ACCESS_KEY_ID`, `_R2_SECRET_ACCESS_KEY` | `release-brand-matrix.yml` | Per-brand R2 account and S3 credentials. `` is the validated `credentialSecretPrefix` in that brand's matrix row. Scope each key pair to only that row's bucket/prefix with object read/write/list; never share one prefix between brands. | -| `BOT_APP_ID`, `BOT_APP_PRIVATE_KEY` | release and config-render workflows | Organization GitHub App credentials. The App needs Contents, Issues, and Pull requests read/write on this repo so release-please can maintain PRs, draft Releases, and tags; install it on private `arcboxlabs/linkcodehq` and `arcboxlabs/linkcode-config` so config rendering can mint separate short-lived tokens restricted to Contents read on each repository. Package-manager bumps additionally require installations on `arcboxlabs/homebrew-tap` and `arcboxlabs/winget-pkgs` with contents + pull-requests write. Missing credentials fail release automation before any tag is created; only package-manager bumps remain an optional self-skip. | +| `BOT_APP_ID`, `BOT_APP_PRIVATE_KEY` | release and config-render workflows | Organization GitHub App credentials. The App needs Contents, Issues, and Pull requests read/write on this repo so release-please can maintain PRs, draft Releases, and tags; install it on the private repositories selected by `CONFIG_PUBLISHER_REPO` and `CONFIG_SOURCE_REPO` so config rendering can mint separate short-lived tokens restricted to Contents read on each repository. Package-manager bumps additionally require installations on `arcboxlabs/homebrew-tap` and `arcboxlabs/winget-pkgs` with contents + pull-requests write. Missing credentials fail release automation before any tag is created; only package-manager bumps remain an optional self-skip. | Mobile certificates, provisioning profiles, the Android keystore, the App Store Connect API key, and the Google Play service-account key are EAS-managed credentials, not GitHub variables. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index c7362dbc..08871b30 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -89,21 +89,30 @@ Desktop signing and R2 secrets live in the repo's GitHub **`release` Environment ## Immutable config bundle (build-time render) -Signed desktop builds and every mobile store build embed an immutable config bundle (bootstrap endpoints, public keyrings, bundled defaults) rendered at build time by the config publisher — the client never re-implements rendering. The `render-config` job in `build-desktop.yml` (signed builds only) and `build-mobile.yml` (always) calls `.github/actions/render-release-config`, which checks out publisher code from fixed `arcboxlabs/linkcodehq` at `publisherGitSha` and structural data from fixed `arcboxlabs/linkcode-config` at the independent `sourceGitSha`. It renders through `pnpm -F @linkcode/ config:render` and verifies the manifest's digest bindings (revision bytes, public keyring bytes, target identity, telemetry endpoint, expected snapshot SHA-256). Nothing falls back to a mutable ref, a configurable repository, a global install, or stale generated output. +Signed desktop builds and every mobile store build embed an immutable config bundle (bootstrap endpoints, public keyrings, bundled defaults) rendered at build time by the config publisher — the client never re-implements rendering. The `render-config` job in `build-desktop.yml` (signed builds only) and `build-mobile.yml` (always) calls `.github/actions/render-release-config`, which checks out publisher code from protected `CONFIG_PUBLISHER_REPO` at `publisherGitSha` and structural data from protected `CONFIG_SOURCE_REPO` at the independent `sourceGitSha`. It renders through `pnpm -F @linkcode/ config:render` and verifies the manifest's digest bindings (revision bytes, public keyring bytes, target identity, telemetry endpoint, expected snapshot SHA-256). Nothing falls back to a mutable ref, an unvalidated or cross-organization repository, a global install, or stale generated output. Each checkout uses its own short-lived installation token minted from the organization secrets `BOT_APP_ID` and `BOT_APP_PRIVATE_KEY`. Trusted workflow steps mint these tokens before checking out the selected client ref; client-controlled actions receive only repository-scoped read tokens, never the App private key. Each token requests only Contents read and is explicitly limited to -`linkcodehq` or `linkcode-config`. The App must be installed on both private repositories. Missing -secrets or installation access fail before rendering; no long-lived config-read token is used. +the selected publisher or source repository. The App must be installed on both private +repositories. Missing secrets or installation access fail before rendering; no long-lived +config-read token is used. -Production rendering reads the root of `linkcode-config` and fails closed while production data is -absent. Workflow code may select only that root or the reviewed `examples/acme-zenith` root used by -the nonproduction pilot; configuration data cannot supply a repository or path. +Production rendering reads the root of `CONFIG_SOURCE_REPO` and fails closed while production data +is absent. Workflow code may select only that root or the reviewed `examples/acme-zenith` root used +by the nonproduction pilot; configuration data cannot supply a path. Inputs live in the GitHub **`release` environment** and a missing value fails the build with an actionable error: +- `CONFIG_PUBLISHER_REPO` (var) — exact canonical `owner/repository` identity for the publisher. + The current org-wide App contract requires owner `arcboxlabs`; the repository name is not + hardcoded. Trusted workflow steps validate and split this value before minting a token restricted + to that one repository. +- `CONFIG_SOURCE_REPO` (var) — exact canonical `owner/repository` identity for structural + manifest, layers, and assets. It has the same owner restriction and receives a separate token. + The two repository values must differ. Current values are `arcboxlabs/linkcodehq` and + `arcboxlabs/linkcode-config`, respectively. - `CONFIG_RELEASE_REVISION` / `CONFIG_RELEASE_KEYRINGS` (vars) — exact revision-metadata and public-keyrings JSON bytes; the manifest pins their SHA-256s, so drifted content fails closed. Public keys only — private keys never enter this repo or its CI. - `CONFIG_RELEASE_MANIFEST_DESKTOP` / `CONFIG_RELEASE_MANIFEST_IOS` / `CONFIG_RELEASE_MANIFEST_ANDROID` (vars) — release-render manifest v1 JSON per target (produced by the publisher's release flow), pinning `publisherGitSha`, `sourceGitSha`, brand/platform/channel, telemetry endpoint, input digests, and the expected published snapshot digest. @@ -172,12 +181,16 @@ credentials are organization secrets. Trusted workflow steps report missing bot checking out selected client code, and the input scripts report missing render, signing, or upload values without receiving those bot credentials: -- Vars: `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS`, and `POSTHOG_HOST`. +- Vars: `CONFIG_PUBLISHER_REPO`, `CONFIG_SOURCE_REPO`, `CONFIG_RELEASE_REVISION`, + `CONFIG_RELEASE_KEYRINGS`, and `POSTHOG_HOST`. Both repository vars must be canonical, + different `arcboxlabs/repository` identities; malformed, absent, cross-organization, and equal + values fail before token minting or checkout. Revision/keyring values are exact JSON bytes already digest-pinned by each release manifest. - Config checkouts: organization secrets `BOT_APP_ID` and `BOT_APP_PRIVATE_KEY` mint separate, - short-lived installation tokens with **Contents: read** only on `arcboxlabs/linkcodehq` and - `arcboxlabs/linkcode-config`. The workflow fixes both repository identities and requests no write - or organization permission. + short-lived installation tokens with **Contents: read** only on the validated publisher + and source repositories. The workflow requests no write or organization permission and never + passes the App private key to selected code. Each exact commit must be reachable from that + repository's reviewed `master` branch before its role-specific contract is accepted. - macOS Desktop: `MACOS_CSC_LINK`, `MACOS_CSC_KEY_PASSWORD`, `APPLE_API_KEY_BASE64`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`, and `APPLE_TEAM_ID`. The App Store Connect API key needs only Developer ID notarization access; it must not have app-management or finance roles.