Repository navigation
ci: build release tarballs without an OIDC token and narrow deploy-tests inputs - #433
Conversation
…sts inputs The publish job held the release environment and `id-token: write` while it restored the pnpm cache, installed every dev dependency and ran the whole build, check and test toolchain, so any of that code could have asked for the OIDC token and published with valid provenance. A `build` job without an `id-token` now does all of it, packs each published package with pnpm (which folds `publishConfig` in and writes `workspace:` and `catalog:` ranges as versions) in dependency order, and uploads the tarballs with their SHA-256 digests. The publish job restores no cache and installs nothing: it checks the digests, checks that the tarballs are exactly this tag's packages at its version, and runs `npm publish <tarball> --provenance` for each, skipping a version npm already holds. npm 11.5.1 or later is required for trusted publishing. In deploy-tests, the host token is no longer on disk while Next.js is installed and built: the preflight writes and removes its own copy, and the file the suite reads is written right before the suite runs. A dispatch's `nextjsRef` has to be a Next.js release tag.
|
Arkor — Preview deployments for arkorlab-upwind Use the checkbox below to choose whether this pull request gets preview deployments.
Checking this builds the latest commit and future pushes. Unchecking stops queued and future builds; a running build can finish and an existing preview stays available. |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
No actionable comments were generated in the recent review. Walkthrough
Merge Risk:
|
[Critical impact] The PR appears safe to merge with no new actionable issues found. SummaryThe PR separates release builds from the job that can publish packages.
soleil-colza explicitly deferred repeatable release-script tests and independent checks of build output. The stated build-trust limitation remains intentional. Diagram%%{init: {'theme': 'neutral'}}%%
flowchart LR
V[Verify signed tag] --> B[Build and pack without OIDC]
B --> A[Tarballs and checksums]
A --> P[Approve and publish with OIDC]
V --> P
P --> R[Read back provenance]
R --> D[Create draft release]
Reviews (5) · Last reviewed commit: "Merge origin/main into ci/publish-and-de..." · Reviewed by Greptile |
There was a problem hiding this comment.
2 issues found across 4 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name=".github/workflows/publish.yaml">
<violation number="1" location=".github/workflows/publish.yaml:472">
P1: This checksum does not authenticate the tarball: `build` creates and uploads it alongside `SHA256SUMS`, so a compromised install/build dependency can replace both while preserving the package name and version. The later checks still pass and `npm publish` uploads the altered package with this job’s OIDC identity; verify tarball contents or digests through an independent trusted path before publishing.</violation>
</file>
<file name="tools/deploy-tests/test/workflow-security.test.ts">
<violation number="1" location="tools/deploy-tests/test/workflow-security.test.ts:700">
P3: These tests only assert that shell commands appear in `publish.yaml`; they never execute the pack or tarball-validation logic. Extract that logic into testable scripts and cover valid, modified, extra, and mismatched artifacts, plus already-published versions.</violation>
</file>
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
| set -euo pipefail | ||
| version="${TAG#v}" | ||
| dir="${RUNNER_TEMP}/tarballs" | ||
| (cd "${dir}" && sha256sum --check --strict SHA256SUMS) |
There was a problem hiding this comment.
P1: This checksum does not authenticate the tarball: build creates and uploads it alongside SHA256SUMS, so a compromised install/build dependency can replace both while preserving the package name and version. The later checks still pass and npm publish uploads the altered package with this job’s OIDC identity; verify tarball contents or digests through an independent trusted path before publishing.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At .github/workflows/publish.yaml, line 472:
<comment>This checksum does not authenticate the tarball: `build` creates and uploads it alongside `SHA256SUMS`, so a compromised install/build dependency can replace both while preserving the package name and version. The later checks still pass and `npm publish` uploads the altered package with this job’s OIDC identity; verify tarball contents or digests through an independent trusted path before publishing.</comment>
<file context>
@@ -351,6 +356,145 @@ jobs:
+ set -euo pipefail
+ version="${TAG#v}"
+ dir="${RUNNER_TEMP}/tarballs"
+ (cd "${dir}" && sha256sum --check --strict SHA256SUMS)
+ listed="$(awk '{ print $2 }' "${dir}/SHA256SUMS" | sort)"
+ present="$(find "${dir}" -mindepth 1 -maxdepth 1 ! -name SHA256SUMS -exec basename {} \; | sort)"
</file context>
| expect(publish).toContain('id-token: write'); | ||
| expect(publish).toContain('environment: release'); | ||
| expect(running).toStrictEqual([]); | ||
| expect(publish).toContain('sha256sum --check --strict SHA256SUMS'); |
There was a problem hiding this comment.
P3: These tests only assert that shell commands appear in publish.yaml; they never execute the pack or tarball-validation logic. Extract that logic into testable scripts and cover valid, modified, extra, and mismatched artifacts, plus already-published versions.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tools/deploy-tests/test/workflow-security.test.ts, line 700:
<comment>These tests only assert that shell commands appear in `publish.yaml`; they never execute the pack or tarball-validation logic. Extract that logic into testable scripts and cover valid, modified, extra, and mismatched artifacts, plus already-published versions.</comment>
<file context>
@@ -638,8 +666,48 @@ describe('workflow security', () => {
+ expect(publish).toContain('id-token: write');
+ expect(publish).toContain('environment: release');
+ expect(running).toStrictEqual([]);
+ expect(publish).toContain('sha256sum --check --strict SHA256SUMS');
+ expect(publish).toMatch(/npm publish "\$\{dir\}\/\$\{file\}" "\$@"/u);
+ });
</file context>
The build job packs what is published, so a pnpm store restored from a cache would be one more input to the tarballs, written by whichever run last saved it, and a cache saved there would be read by the default branch's other runs. CodeQL reports the second as cache poisoning (`actions/cache-poisoning/poisonable-step`) now that the job is no longer behind the release environment. The job installs from the registry against the lockfile instead.
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name=".github/workflows/publish.yaml">
<violation number="1" location=".github/workflows/publish.yaml:258">
P3: This rationale is inaccurate for the common path: a tag-push release writes its cache under the tag ref, which the default branch's runs cannot restore. The no-cache decision can stay, but the comment should state the dispatch-from-main case or drop the claim.</violation>
</file>
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
| - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 | ||
| # No `cache`, though nothing here can publish: what is packed below is what gets published, | ||
| # and a store restored from a cache would be one more thing it was built from, written by | ||
| # whichever run last saved it. A cache written here would be read by the default branch's |
There was a problem hiding this comment.
P3: This rationale is inaccurate for the common path: a tag-push release writes its cache under the tag ref, which the default branch's runs cannot restore. The no-cache decision can stay, but the comment should state the dispatch-from-main case or drop the claim.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At .github/workflows/publish.yaml, line 258:
<comment>This rationale is inaccurate for the common path: a tag-push release writes its cache under the tag ref, which the default branch's runs cannot restore. The no-cache decision can stay, but the comment should state the dispatch-from-main case or drop the claim.</comment>
<file context>
@@ -253,10 +253,14 @@ jobs:
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
+ # No `cache`, though nothing here can publish: what is packed below is what gets published,
+ # and a store restored from a cache would be one more thing it was built from, written by
+ # whichever run last saved it. A cache written here would be read by the default branch's
+ # other runs, too. A release installs from the registry, against the lockfile, and pays the
+ # minute that costs.
</file context>
…no cache The deploy-tests token file is removed once the suite step is done, since nothing after it reads the token. The build job's comment now says what a cache would do on each path: a run on a tag restores what the default branch's runs saved, and one dispatched from the default branch saves into it. The workflow tests also refuse `npm ci`, `npm install`, `npx` and `actions/cache` in the publish job, and `actions/cache` in the build job.
Given a release tag by its name alone, the checkout looks for a branch of that name first, so a branch pushed under a release's name would be what every shard built and ran. A ref that names a release is now checked out as `refs/tags/<tag>`; a night's canary is already a commit.
|
@codex security review |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
Two workflow hardenings.
Release: build where nothing can publish, publish where nothing runs
publish.yaml'spublishjob held thereleaseenvironment andid-token: write, then restoredcache: pnpm, ranpnpm install --frozen-lockfileand the whole build/check/test toolchain beforepnpm --recursive publish --provenance. Every dev dependency, and whatever a restored cache brought back, ran while the OIDC token request credentials were in the job, so a compromised dependency could have exchanged them for a publish grant and published with valid provenance.Now:
build(contents: read, noid-token, and nocache: what it packs is what gets published, so a restored pnpm store would be one more input, and a cache saved from it would be read by the default branch's other runs, which CodeQL flags asactions/cache-poisoning/poisonable-steponce the job is outside thereleaseenvironment): checks out the verified commit (persist-credentials: false), installs from the registry against the lockfile, builds, runs every check andpnpm testas before, makes the SBOM (moved here, as it needs pnpm's store), then packs each non-privatepackages/*package withpnpm pack --config.ignore-scripts=true, one at a time in pnpm's dependency order, and uploads the tarballs plus aSHA256SUMSlist in that order (artifacttarballs, one day).publish(environment: release,id-token: write): no cache, no install, nopnpm. It checks out the verified commit only to read manifests as data and the Node.js version, re-checks the tag, checks npm is 11.5.1 or later, downloads the artifact, runssha256sum --check --strict, refuses files the list does not name, and checks that the tarballs' own manifests are exactly this tag's public packages at its version. Then, in order,npm publish <tarball> --access public --provenance --tag <latest|next> --ignore-scripts(plus--dry-runfor a rehearsal). A version npm already holds is skipped, aspnpm -r publishskipped it, so a release that stopped partway can still be dispatched again.verify), the "nothing has published a release for this tag" re-check before uploading, the provenance read-back and the draft job.release.yamland thereleaseenvironment are untouched, so npm's trusted publisher configuration still matches. The environment approval is now asked for afterbuildhas passed.Why this is equivalent for what gets published:
pnpm packwritesworkspace:*andcatalog:ranges as concrete versions and foldspublishConfig(bin,exports) into the packed manifest, aspnpm publishdoes. Checked in a scratch workspace; that scratch run also ran the new pack, check and dry-run publish steps end to end, and the check refused a changed tarball, an extra file and a tree naming another package.npm publishwith a tarball reads the manifest out of the tarball, runs lifecycle scripts only for a directory, and does the OIDC exchange for any spec (lib/commands/publish.jsandlib/utils/oidc.jsin npm/cli). npm documents trusted publishing as needing npm 11.5.1+, Node 22.14+,id-token: writeand a cloud-hosted runner (Trusted publishing for npm packages). It does not document a tarball argument, so the first real release is where that gets confirmed.workflow-security.test.tsnow expects the fourth pinned checkout, and checks thatid-token: writeappears once, in a job that installs, restores and runs nopnpm, and thatbuildholds noid-tokenand nocache.What this does not do: the digests guard the hand-over between the two jobs, not the build. A dependency compromised inside
buildcan still change what it packs, as it could before; what it can no longer do is use the OIDC token itself, to publish whatever it likes, under any name or version, with this workflow's provenance.CodeQL reports
actions/cache-poisoning/poisonable-stepon the pack step (alert 50): it readsneeds.verify.outputs.commitas untrusted, andbuildis no longer behind thereleaseenvironment, which the old job was. That commit is the signed tagverifychecked againstrelease-signers.ascandmain, and the same code ran with the same cache access in the old job.buildno longer restores or saves a cache, which is the remedy the query names. A maintainer can dismiss the alert as a false positive, or ask for another arrangement.Note for the next first publish: reverting the commit that retired
bootstrap(CONTRIBUTING) now touches the restructuredpublishjob, so the token-writing step needs re-applying by hand there.deploy-tests
pnpm install && pnpm build, Chromium's install and Corepack's, all of them code of the requested Next.js ref and its dependencies. Now Preflight writes its own copy, hands the tool only the path (env -u TOKEN), and removes it on exit. The file the suite reads is written right beforeRun the suite. It is removed again right after the suite (if: always()).nextjsRef: a dispatch must name a release tag,^v\d+\.\d+\.\d+(-(canary|rc|beta)\.\d+)?$, checked inplanafter the existing single-line check. A release-tag ref is checked out asrefs/tags/<tag>, sinceactions/checkoutlooks for a branch of a short ref's name first. The nightly canary run still resolves its tag to a commit as before, and the pull-request and nightly runs useNIGHTLY_NEXTJS_REF. New plan tests cover accepted tags and refused refs (a branch, a SHA,16.4.3,v16.4.3-alpha.1,v16.4.3 --upload-pack=…, and others).persist-credentials: false: all three checkouts here already set it, so nothing changed.Not done here, and why
publishjob is that it runs nothing from the repository; moving its tarball check into a script in the checkout would put repository code into the one job that holds the OIDC token. So the steps stay inline.workflow-security.test.tschecks the jobs' shape (oneid-token: write, no install, cache or pnpm inpublish, noid-tokenor cache inbuild), and the scratch run described above exercised the steps end to end: a valid artifact, a changed tarball, an extra file, a tree naming another package, and a dry-run publish. A follow-up could run the same inline step bodies from a test, as the suite already runs theplanstep's body, without the job executing repository files.buildpacked (cubic P1). As "What this does not do" says,SHA256SUMSonly guards the hand-over between the two jobs; a dependency compromised insidebuildcan change both the tarballs and their digests. Checking the build itself needs a source the build cannot write, such as a second, independent build to compare against or reproducible tarballs checked elsewhere, which is a larger change than this PR. What this PR removes is that dependency's use of the OIDC token.Actions stay pinned by full SHA. Checked locally: both workflows parse with
yaml; the build and publish step scripts passbash -n; ESLint and oxfmt on the changed files;workflow-security,provenance-securityandpopulationtests pass. actionlint and shellcheck are not installed here.Summary by CodeRabbit