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/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 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)