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 9b74dc37..e5a554e2 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); @@ -226,3 +252,117 @@ 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( + `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 '${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'); + expect(validation).not.toContain('secrets.'); + expect(validation).not.toContain('release-environment-preflight'); + }); + + 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', + ); + const preflight = workflow.slice( + workflow.indexOf(' release-environment-preflight:'), + workflow.indexOf(' render-inputs:'), + ); + + 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('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: release'); + const signingInputs = workflow.slice( + workflow.indexOf(' signing-inputs:'), + workflow.indexOf(' render:'), + ); + expect(signingInputs).toContain('needs: [prepare, release-environment-preflight]'); + expect(signingInputs).toContain('environment: release'); + expect(workflow.split(' environment: release')).toHaveLength(7); + expect(workflow.split('release_environment: release')).toHaveLength(3); + }); + + 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'), + ]); + + 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 () => { + 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/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 18bb0c5e..82c9f56a 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,10 +127,110 @@ jobs: --sign "${{ inputs.sign }}" \ --upload "${{ inputs.upload }}" + credential-free-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: + 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 + if: ${{ matrix.platform == 'desktop' }} + 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 + 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 '${{ 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" \ + --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-${{ matrix.brandId }}-${{ matrix.platform }} + path: credential-free-evidence + if-no-files-found: error + retention-days: 7 + + release-environment-preflight: + name: Protected release environment preflight + if: ${{ inputs.build }} + needs: prepare + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + steps: + - name: Require protected release environment + env: + GH_TOKEN: ${{ secrets.RELEASE_ENVIRONMENT_ADMIN_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 ' + .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::release must require reviewers and a deployment branch policy before release work" + 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 env: @@ -146,7 +247,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: @@ -314,9 +415,79 @@ jobs: sign: ${{ inputs.sign }} brand_id: ${{ matrix.brandId }} delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} + release_environment: release 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' }} @@ -383,6 +554,7 @@ jobs: ref: ${{ inputs.ref }} brand_id: ${{ matrix.brandId }} delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} + release_environment: release rendered_artifact: brand-render-${{ matrix.brandId }} submit: false 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. |