Skip to content

feat(release): gate brand platform build matrix - #429

Open
AprilNEA wants to merge 21 commits into
masterfrom
xuan/code-559
Open

feat(release): gate brand platform build matrix#429
AprilNEA wants to merge 21 commits into
masterfrom
xuan/code-559

Conversation

@AprilNEA

@AprilNEA AprilNEA commented Aug 7, 2026

Copy link
Copy Markdown
Member

Summary

Implements Linear CODE-559 as a stack on #428 (xuan/code-558@77f3fb89d4268ffa93a9cc70d5281b5496b7318e).

  • adds a strict brand × Desktop/iOS/Android release matrix that consumes CODE-558's immutable rendered brand artifacts and release-manifest bindings
  • validates protected render/sign/upload inputs, exact store-compliance attestations, disclosed configurable features, destination uniqueness, and per-brand credentials
  • isolates render roots, build roots, artifacts, upload destinations, EAS projects, App Store apps, and R2 credential prefixes per brand
  • writes and re-verifies create-once provenance for artifact bytes, client commit, brand manifest, defaults, config revision, snapshot, release manifest, publisher, and source commits
  • blocks publication for every brand until aggregate preflight succeeds, and rejects executable-code configuration or undisclosed review-feature surfaces
  • documents the exact protected release environment vars/secrets and least-privilege contract

No LinkCode HQ change is required: this workflow consumes the immutable CODE-558 publisher output without changing render semantics.

Verification

  • pnpm check:ci
  • pnpm test — 331 files passed, 2,806 tests passed, 1 skipped
  • focused CODE-559 Vitest suite — 31 passed
  • focused TypeScript build for packages/foundation/common and apps/mobile
  • workflow YAML parsed with the repository's yaml dependency (actionlint was unavailable locally)
  • real unsigned Linux x64 Desktop package: Debian + AppImage generated; packaged asar contains the exact rendered bundle bytes
  • CI=1 EXPO_NO_TELEMETRY=1 pnpm -F @linkcode/mobile smoke:export — iOS and Android production Metro/Hermes bundles exported
  • clean credential-free Expo prebuilds for iOS and Android
  • provenance creation + verification passed for Desktop/iOS/Android evidence at this PR head

No production credentials were used. Nothing was signed, uploaded, store-submitted, or released. Android SDK was unavailable locally, so Android evidence is Hermes export + clean native prebuild rather than an APK/AAB; iOS is likewise unsigned and not an IPA.

Checklist

  • pnpm check:ci and pnpm test both pass (no Rust changes)
  • I ran the affected surfaces and observed the change working
  • No wire message changed
  • New code and assets are my own work
  • Docs and comments are updated where behavior changed

AprilNEA added 19 commits August 7, 2026 06:55
@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown

CODE-559

@pullfrog

pullfrog Bot commented Aug 7, 2026

Copy link
Copy Markdown

Your Claude subscription has hit its usage limit. It resets at 11am (UTC). Re-trigger Pullfrog after the reset, or add an ANTHROPIC_API_KEY repo secret — Pullfrog routes around an exhausted subscription automatically when one is present.

Add repo secret → · Model settings → · Setup docs → · Ask in Discord →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

lucas77778
lucas77778 previously approved these changes Aug 8, 2026
Base automatically changed from xuan/code-558 to master August 8, 2026 08:48
@lucas77778
lucas77778 dismissed their stale review August 8, 2026 08:48

The base branch was changed.

environment: release
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
R2_ACCESS_KEY_ID: ${{ secrets[format('{0}_R2_ACCESS_KEY_ID', matrix.distribution.desktop.credentialSecretPrefix)] }}
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
R2_ACCESS_KEY_ID: ${{ secrets[format('{0}_R2_ACCESS_KEY_ID', matrix.distribution.desktop.credentialSecretPrefix)] }}
R2_ACCOUNT_ID: ${{ secrets[format('{0}_R2_ACCOUNT_ID', matrix.distribution.desktop.credentialSecretPrefix)] }}
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
R2_ACCESS_KEY_ID: ${{ secrets[format('{0}_R2_ACCESS_KEY_ID', matrix.distribution.desktop.credentialSecretPrefix)] }}
R2_ACCOUNT_ID: ${{ secrets[format('{0}_R2_ACCOUNT_ID', matrix.distribution.desktop.credentialSecretPrefix)] }}
R2_SECRET_ACCESS_KEY: ${{ secrets[format('{0}_R2_SECRET_ACCESS_KEY', matrix.distribution.desktop.credentialSecretPrefix)] }}
runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }}
environment: release
env:
AWS_ACCESS_KEY_ID: ${{ secrets[format('{0}_R2_ACCESS_KEY_ID', matrix.distribution.desktop.credentialSecretPrefix)] }}
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)] }}
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)] }}
R2_ACCOUNT_ID: ${{ secrets[format('{0}_R2_ACCOUNT_ID', matrix.distribution.desktop.credentialSecretPrefix)] }}

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Two changes recommended before this ships a real brand release.

Reviewed the full CODE-559 diff (18 files). Note the incremental range-diff for this push is almost entirely deletions because b54ea67 is a merge: sync master into code-559 and master had already absorbed #428 — so I reviewed the complete diff rather than the range-diff.

The design holds up well. The fail-closed posture in brand-matrix.cjs (exact key sets, cross-platform pin agreement, R2 prefix overlap detection, unique credential prefixes / EAS project IDs / ASC app IDs) and the create-once flag: 'wx' provenance with realpath escape guards are the right shape for a multi-tenant release train. Two things to fix, one note.

Findings — all three inline:

  1. release-brand-matrix.yml:548 — provenance sidecars get synced into the public update-feed prefix, disclosing private-repo commit SHAs.
  2. build-desktop.yml:210 — the POSTHOG_HOST secrets.vars. migration is incomplete; ci.yml still reads the secret.
  3. release-brand-matrix.yml:193 — note only: coupling to the composite action's private scratch layout.

Things I chased down and cleared (recording these so they aren't re-litigated):

  • Mobile identities do not need --brand-artifacts. The matrix passes brand-artifacts: true for desktop only, and the action gates BRAND_ARTIFACTS inside if [ "$APP" = desktop ] — which looked like it would starve the ~8 downstream steps that read apps/mobile/generated/brand-identity.{ios,android}.json. It doesn't: apps/mobile/scripts/render-config-bundle.mts:104 writes both identities unconditionally, and the flag has no mobile counterpart. Correct as written.
  • publish-desktop's for … in mac win linux matches build-desktop.yml:138-145 exactly, and build-mobile.yml:118-125 really does produce linkcode-android.aab, so publish-mobile's unconditional android submit is reachable. Removing the commented-out android rows from build-mobile's own submit matrix is inert here — the brand matrix runs its own publish job.
  • release-inputs/mobile-release.json can never be missing on the upload path. brand-matrix.cjs:232 rejects a null distribution.mobile when build=true, and upload⇒sign⇒build, so the unconditional jq -er .ios.ascAppId is safe.
  • verifyReleaseArtifactProvenance not re-calling assertStoreCompliance is correct, even though docs/RELEASE.md says publish "re-hashes … all immutable inputs". The declaration is fully derived from the bundle — disclosedFeatures must exactly equal the feature./modules. key set, and the checklist must be an exact 5-key all-true set — so it carries no information the hash-bound bundle doesn't already pin. Nothing to re-verify, and the --verify path correctly takes no --compliance.
  • .github/scripts/**/*.test.mjs is in the root vitest include, so both new validators are actually exercised.
  • The = { bundle: / lastIndexOf('};') parser in release-artifact-cli.mts looks fragile but is sound: the marker precedes any JSON payload, nothing after the const terminator contains };, and the single .replace hits index 0 of the slice.
  • CONFIG_PUBLISHER_TOKEN stays out of persisted git config (per-command -c http.…extraheader) — good.

Recommendation: because this is the first run to exercise the reworked packaging and publish scripts end to end, drive it once with build/sign but upload: false (or release-desktop.yml's dry_run: true) before pointing it at a live R2 prefix.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED
AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED
run: |
aws s3 sync "artifacts/${{ matrix.brandId }}/" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aws s3 sync copies the whole artifacts/<brand>/ directory with no --exclude, and that directory provably contains the provenance sidecars — the step directly above asserts test -s "artifacts/${{ matrix.brandId }}/release-provenance.${platform}.json" for each of mac win linux.

The destination is the public electron-updater feed (distribution.desktop.r2Prefix, served at the validated updateUrl that clients fetch unauthenticated). So each release publishes, on a public CDN:

  • publisherGitSha — a commit SHA of the private CONFIG_PUBLISHER_REPO
  • sourceGitSha and configSnapshotSha256
  • the full per-artifact digest and size inventory

None of that is needed by the updater. If publishing provenance is intentional as a transparency artifact, docs/RELEASE.md should say so; otherwise add --exclude 'release-provenance.*.json' — the files are still uploaded as workflow artifacts and re-verified in publish-preflight, so nothing downstream loses them.

MAIN_VITE_SENTRY_DSN: ${{ inputs.sign && secrets.SENTRY_DSN_DESKTOP || '' }}
RENDERER_VITE_POSTHOG_PROJECT_TOKEN: ${{ inputs.sign && secrets.POSTHOG_PROJECT_TOKEN || '' }}
RENDERER_VITE_POSTHOG_HOST: ${{ inputs.sign && secrets.POSTHOG_HOST || '' }}
RENDERER_VITE_POSTHOG_HOST: ${{ inputs.sign && vars.POSTHOG_HOST || '' }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR moves POSTHOG_HOST from a secret to an Actions variable here, in build-mobile.yml:135,206, and in release-brand-matrix.yml:119 — and docs/ENVIRONMENT.md:112 now documents it as "Organization Actions variable".

But .github/workflows/ci.yml was not migrated and still reads the secret in two places:

  • ci.yml:224VITE_POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }} (webview, unconditional)
  • ci.yml:255EXPO_PUBLIC_POSTHOG_HOST: ${{ github.event_name != 'pull_request' && secrets.POSTHOG_HOST || '' }}

While both the secret and the var exist this is invisible. The moment the secret is retired to match the new docs, those two silently resolve to '' — and docs/ENVIRONMENT.md:84 states both webview PostHog vars are required, so this either hard-fails the build or ships a bundle pointed at no host.

Either migrate ci.yml to vars.POSTHOG_HOST in the same PR, or leave a line in docs/ENVIRONMENT.md recording that the secret must stay provisioned until ci.yml catches up.

run: |
set -euo pipefail
mkdir release-inputs
cp "$RUNNER_TEMP/config-render-desktop/source/packages/config-structural/brands.manifest.yaml" release-inputs/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note, not a blocker — this works today (the composite action never removes $work).

This reaches into render-release-config's private scratch directory, which this same PR renamed from config-render to config-render-$APP. That rename is itself the evidence the layout isn't a stable interface: a second rename, or adding a cleanup step to the action, breaks this cp from a file that gives no signal it has a consumer.

Worth promoting to a real contract — add an outputs: entry on the composite action exposing the pinned brands.manifest.yaml path, and consume that step output here instead of the literal temp path.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

One change in dab0be6 rests on an invariant that can't be checked from this repo, and the pipeline has no detector for the way it fails.

Reviewed changes — this round covers dab0be6 ("harden brand matrix trust") on top of the round-1 review:

  • The release plan moved out of vars.BRAND_BUILD_MATRIX into a committed file selected by a new matrix_file input.
  • A new ancestry gate requires the client ref to be an ancestor of origin/master (with the matching fetch-depth: 0).
  • The matrix bytes are now hashed and threaded end-to-end as delivery_descriptor_sha256, written into provenance and re-verified at publish time.
  • secrets: inherit was dropped from both reusable-workflow calls.
  • Test coverage widened (byte-exact digest test, mismatched-descriptor verify test).
  • BRAND_BUILD_MATRIX is fully retired — the docs row is gone and there are zero remaining references.

The hardening direction is right, and the implementation of it is careful. matrix_file is regex-pinned to a single directory with no / and no traversal, git ls-tree is mode-checked to 100644 blob so symlinks and gitlinks are rejected, and the bytes are read via git cat-file from the verified ref rather than the working tree. matrix_json surviving as plan-only and being rejected when build=true is the right split. The new tests are not theatre: the digest test asserts the exact bytes including the trailing newline, so a CLI that re-serialized the JSON would fail it, and the verify test feeds mismatched descriptor bytes and asserts .rejects.toThrow('reviewed release matrix').

I also independently confirmed a few things that looked risky and aren't: actions/checkout at fetch-depth: 0 does populate refs/remotes/origin/*, so the merge-base --is-ancestor gate resolves; MATRIX_JSON / MATRIX_FILE are passed as env: rather than interpolated into shell, so there is no injection surface; and the empty-string default for delivery_descriptor_sha256 can never reach the CLI, because every provenance step is gated on inputs.rendered_artifact != '' and only the brand matrix sets that — always alongside the digest.

⚠️ The new "reviewed matrix" trust root is branch protection alone

The security value of this commit is "committed to master ⇒ reviewed". That premise has no enforcement in the repo: there is no CODEOWNERS file anywhere, .github/release/brand-matrices/ is not created by this PR, and AGENTS.md records that master merges sometimes go through gh pr merge --admin because the ruleset's review requirements can't always be satisfied. So the gate currently reduces to "someone with admin merged it", which is a weaker property than the commit message implies.

Technical details

Affected: .github/workflows/release-brand-matrix.yml:72-108, and the (not-yet-created) .github/release/brand-matrices/ directory.

Required outcome: a change to a release matrix cannot reach master without a review by someone entitled to authorize a branded release.

Suggested approach: add a CODEOWNERS entry for .github/release/brand-matrices/ naming the release owners, and make sure the master ruleset requires code-owner review for that path specifically — so an --admin merge of unrelated work doesn't also carry a matrix edit.

ℹ️ Nitpicks

  • vars.BRAND_BUILD_MATRIX now has zero readers. Worth deleting the org/repo variable itself so a stale plan can't be mistaken for live configuration later.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.prepare.outputs.brands) }}
uses: ./.github/workflows/build-desktop.yml

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping secrets: inherit here (and at line 381) is safe only if every secrets.* reachable on the sign: true path is stored on the release environment. GitHub forwards repository/organization secrets to a called workflow solely via inherit, while environment secrets resolve at job level independently of the caller — so the two tiers behave oppositely under this change.

The repo currently asserts both readings at once. build-desktop.yml:10 now says callers need not forward secrets, but release-desktop.yml:35 still carries secrets: inherit # required so build-desktop.yml can read the signing secrets. One of those comments is wrong, and I can't tell which from inside the repo.

If any signing secret turns out to be repo/org-scoped, the macOS path degrades silently rather than failing: an empty MACOS_CSC_LINK skips the CSC_LINK export by design (build-desktop.yml:250-256) and electron-builder simply produces an unsigned package.

Technical details

Affected: .github/workflows/release-brand-matrix.yml:308, :381; .github/workflows/build-desktop.yml:10, :71-76, :135; .github/workflows/release-desktop.yml:35.

Required outcome: every secret read on the signing path resolves in the called job without inherit, and the repo's comments agree on why.

Suggested approach: confirm the storage tier of each signing secret in org settings, then run this workflow once with build: true, sign: true, upload: false and check the produced artifact is actually signed. Note build-desktop.yml:71-76 reads the AZURE_* values in a workflow-level env: block while the comment calls them environment secrets — that block is outside the job that declares environment: release.

Open question: are MACOS_CSC_LINK, MACOS_CSC_KEY_PASSWORD, the Apple API-key secrets, and AZURE_* stored on the release environment, or at repo/org level?

--release-manifest release-inputs/release-manifest.desktop.json \
--compliance release-inputs/compliance.desktop.json \
--out "release-provenance.${{ matrix.platform }}.json" \
${{ inputs.sign && '--signed' || '' }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--signed is derived from inputs.sign, so the provenance sidecar restates a workflow input rather than recording an observation about the bytes.

Nothing downstream closes that gap. verify-artifacts.mts (422 lines) checks arch coverage and feed sha512s but never invokes codesign / signtool / spctl, and publish-preflight's --verify … --signed only compares provenance.signed against the same flag (release-artifact.ts:319). An artifact whose signing silently no-opped is therefore attested signed: true and clears every publish gate — which is what turns the secrets: inherit question above from a loud failure into a quiet one.

Technical details

Affected: .github/workflows/build-desktop.yml:315, apps/desktop/scripts/verify-artifacts.mts, packages/foundation/common/src/node/release-artifact.ts:164, :319.

Required outcome: signed: true in provenance means a signature was observed on the artifact, not that a flag was set.

Suggested approach: when inputs.sign is true, have verify-artifacts.mts run codesign --verify --deep --strict on the macOS bundle and signtool verify /pa on the Windows installer, and fail the build if either reports no valid signature. That makes the failure mode loud at build time rather than discoverable only after publication.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants