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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 66 additions & 20 deletions .github/actions/render-release-config/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down
24 changes: 12 additions & 12 deletions .github/release/brand-matrices/code-561-pilot.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand All @@ -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"
}
}
Expand Down Expand Up @@ -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": {
Expand All @@ -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": {
Expand All @@ -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"
}
}
Expand Down
66 changes: 64 additions & 2 deletions .github/scripts/brand-matrix.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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:'),
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 0 additions & 2 deletions .github/scripts/release-inputs.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
],
Expand Down
4 changes: 2 additions & 2 deletions .github/scripts/release-inputs.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ 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/;
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,
);
Expand Down
43 changes: 37 additions & 6 deletions .github/workflows/build-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading