Skip to content

RFC 0002: zero-trust publishing via GitHub Actions OIDC - #86

Merged
fengmk2 merged 12 commits into
mainfrom
rfc-oidc-trusted-publishing
Aug 10, 2026
Merged

RFC 0002: zero-trust publishing via GitHub Actions OIDC#86
fengmk2 merged 12 commits into
mainfrom
rfc-oidc-trusted-publishing

Conversation

@fengmk2

@fengmk2 fengmk2 commented Aug 7, 2026

Copy link
Copy Markdown
Member

RFC 0002 and its bridge-side and action-side implementation. Consumer wiring is voidzero-dev/vite-plus#2387, which depends on this.

Problem

The publish path requires Authorization: Bearer <ADMIN_TOKEN>, and GitHub withholds secrets from fork pull_request runs. vite-plus's publish-preview.yml therefore skips the bridge step for external contributors, so reviewers cannot install or smoke-test a third-party PR.

Approach

Verify GitHub Actions OIDC tokens against GitHub's JWKS instead of a shared secret. Fork pull_request runs cannot mint OIDC tokens either, so publishing splits into an untrusted build leg (pull_request, no credentials, packs an artifact) and a trusted publish leg (workflow_run, base-repo context, mints the token). After rollout no bridge secret lives in the consumer repo; ADMIN_TOKEN stays for /-/purge, warm, and ops.

Contents

  • RFC rfcs/0002-*.md, including the security section and accepted risks.
  • Bridge src/security/oidc.ts plus wiring on the three publish endpoints. /-/purge stays admin-only. RS256 is hardcoded on both key import and verify so the token header never selects an algorithm; key by kid from GitHub's JWKS (KV-cached, unknown-kid refetch behind a cooldown); parse bounds before any crypto; identity anchored on the immutable repository_id/repository_owner_id ahead of workflow_ref.
  • Action mode input: publish (unchanged), pack (no network, no credentials), upload (validate, rebuild, publish). src/tarball/validateArchive.ts is the SR-6 canonical policy. admin-token is now optional; without it the action mints an OIDC token.
  • Docs ci-setup.md rewritten for the two legs; README and self-hosting.md updated.

177 tests (81 new), typecheck clean, no action-bundle drift.

Security requirements

Section 8 records seven requirements found while reviewing the design, plus what the preview-build label does and does not bound. Two are load-bearing rather than defense-in-depth:

  • SR-1: the trusted leg must re-resolve the PR from head_sha and re-check the label via the API. On pull_request events GitHub runs the workflow file from the merge ref, so the build leg's own label check sits in a file the PR author can edit, and workflow_run matches on workflow name. Without SR-1 an unlabeled fork PR publishes to production. Implemented in #2387.
  • SR-3: the verifier must pin RS256 in code rather than read alg from the header. An alg: none acceptance is a full bypass for anyone on the internet.

It also fixes a hole that predates this RFC: /-/register took prUrl from the caller and getConfiguredRefs.ts:78 derives prNumber from it, so any publisher could retarget another PR's pr-<n> dist-tag and change what VP_PR_VERSION=<n> installs.

Two corrections worth reading

SR-2 changed during implementation. The RFC first proposed rejecting a prUrl whose PR number was already bound to another commit. That would have broken every multi-commit PR, since a PR accumulates one ref per pushed commit sharing one prUrl, and that is exactly how pr-<n> advances to the head build. Replaced with per-ref immutability, with a test for the legitimate case beside the hijack case (commit e70e020).

The pkg.pr.new comparison was wrong. I had described it as an App whose server pulls artifacts from the GitHub API and rejected it on CPU grounds. Their CLI sends no credential at all and uploads directly; authorization comes from a publish window their App opens on workflow_run webhooks. Rebuilt the rejection on accurate grounds (commit f9ec9e1). Their maintainers declined OIDC in stackblitz-labs/pkg.pr.new#535 precisely because it loses fork-PR support, which independently confirms the constraint driving the split.

Deploying this is safe on its own

With the OIDC vars unset, the admin-token path is unchanged and a JWT is a plain 401; there are tests for that state, and the staging job runs under it. vite-plus pins the action to an older commit, so merging this does not change what it executes.

One hazard: the config resolves before the credential is routed, so setting the four vars partially returns 503 for every publish including admin-token ones. It fails loudly and names the missing var, but the four should go in as a single change. Also covered by a test.

Review asks

  1. Is the two-workflow split acceptable, given it moves the sticky comment and Docker preview jobs into the workflow_run workflow?
  2. Accepted risks in 8.4, in particular that the label becomes a publishing permission for triage-role users, and the GHCR question (open question 6). #2387 currently keeps the Docker preview same-repo only.
  3. Open questions in section 11, especially 3 (bind the token to the commit it may publish), and rollout item 0 (confirm the trigger forgery empirically). Note that workflow_run only fires for files already on the default branch, so SR-1 cannot get a live negative test until #2387 merges.

fengmk2 added 5 commits August 7, 2026 22:51
Proposes replacing the shared PKG_PR_BRIDGE_ADMIN_TOKEN on the publish path
with GitHub Actions OIDC tokens the Worker verifies against GitHub's JWKS,
so fork PRs can publish preview builds. Fork pull_request runs cannot mint
OIDC tokens either, so the consumer workflow splits into an untrusted build
leg (pull_request, no credentials, packs tarballs into an artifact) and a
trusted publish leg (workflow_run, base-repo context, mints the token).

Section 8 records six security requirements found while reviewing the
design. Two are load-bearing: the trusted leg must re-check the
preview-build label against the API, because the build leg's own check runs
in a file the PR author can edit; and the JWT verifier must pin RS256 in
code rather than read alg from the token header. It also documents an
existing hole on the admin-token path, where a caller-supplied prUrl lets a
publisher retarget another PR's pr-<n> dist-tag, and what the label gate
does and does not bound.

Status: Proposed. No implementation yet.
The alternatives section described pkg.pr.new as a GitHub App whose server
pulls artifacts from the GitHub API, and rejected it on CPU grounds. That
was wrong on both counts. Their CLI sends no credential and uploads
tarballs directly; authorization comes from a publish window their App
opens on workflow_run webhooks, keyed by a hash of public run metadata.
Fork PRs work because nothing token-shaped is involved. Rebuilt the
rejection on accurate grounds: the App private key and webhook secret
reintroduce the stored credential this RFC removes.

Also noted that their maintainers declined OIDC trusted publishing in
issue #535 precisely because it loses fork-PR support, which independently
confirms constraint 1, and that their README recommends an approval gate
matching our preview-build label.

Separately, the GHCR entry in accepted risks implied fork code could
already reach ghcr.io/voidzero-dev/vite-plus:pr-<n>. It cannot: fork
pull_request runs get a read-only GITHUB_TOKEN, so the push fails today.
Moving the Docker job into the workflow_run leg is what makes it succeed.
Recorded as open question 6 rather than a settled decision.
Adds SR-7: pin repository_id and repository_owner_id alongside
workflow_ref. workflow_ref embeds a repository name, and names are mutable
and reusable, so a rename, transfer, or deletion could later let a repo the
org does not control satisfy a string match. Also pins owner id, because
repository_id alone follows a repo transferred out of the org.

Rewrites SR-6 from "bound the parsing" into a canonical archive policy: an
explicit reject list (duplicate normalized paths, multiple
package/package.json, traversal, absolute paths, non-file entry types,
entry-count and size caps, entries outside package/), and a requirement
that the trusted leg rebuild the tarball with its own codec and hash its
own output rather than forwarding fork bytes. The duplicate-manifest case
is the reason: tar permits duplicate entries and extractors disagree on
precedence, so a validator reading the first entry can approve metadata
pnpm never extracts. Emitting a fresh archive removes the ambiguity instead
of trying to match every extractor.

Extends SR-3 with parse bounds (token and decoded-segment size caps, kid
length, strict three-segment split, strict Base64URL, claim type checks).

Records commit-binding as open question 3 with a concrete mechanism
(audience = <OIDC_AUDIENCE>#<sha>, bridge requires every published version
to match), proposed for adoption: it limits authority rather than reuse,
which jti replay protection does not.

Notes in section 6 that since the trusted leg now rebuilds, pack mode could
shrink to raw pnpm pack output and move the rewrite, pin, repack and hash
to the trusted side. Left for the action PR; it changes the artifact
contract between the two legs.
Implements RFC 0002 rollout step 1. The three publish endpoints (tarball
upload, /-/publish, /-/register) now accept either the operator's admin
token or a short-lived GitHub-signed OIDC token. /-/purge stays
admin-only, so a CI identity can add preview builds and nothing else.

src/security/oidc.ts verifies the token on WebCrypto rather than pulling a
JWT library, which means owning the pitfalls explicitly (SR-3): RS256 is
hardcoded on both the key import and the verify so the token header never
selects an algorithm, the key comes from GitHub's JWKS by kid, and every
length is capped before any parsing or crypto runs. Identity is anchored on
the immutable repository_id and repository_owner_id ahead of workflow_ref
(SR-7), because that claim embeds a repository name and names can be
renamed, transferred, or released and reclaimed.

All four OIDC vars are required together; a partial config throws 503
rather than silently disabling the path, so a deployment missing just the
repository id fails loudly instead of rejecting every token as 401.

SR-2, containing the pr-<n> dist-tag: /-/register rejects a prUrl outside
the token's repository claim, and refuses to re-point an existing ref at a
different prUrl. Note this is NOT the rule the RFC first proposed
(rejecting a PR number already bound to another commit), which would have
broken every multi-commit PR, since a PR accumulates one ref per pushed
commit and that is how pr-<n> advances to the head build. A test covers
that case next to the hijack case.
Implementing SR-2 showed the second bridge-side rule was wrong. Rejecting a
prUrl whose PR number is already bound to a ref under a different commit
would break every multi-commit PR: a PR accumulates one commit.<sha> ref
per pushed commit, all sharing one prUrl, and that is exactly how
latestVersionByPr advances pr-<n> to the PR's head build.

Replaced with per-ref immutability: a given commit's prUrl cannot be
rewritten, which still blocks dragging another PR's tag onto a commit
without touching the legitimate case. Also states why the bridge cannot do
better alone (verifying a commit belongs to a PR needs a GitHub API call,
which belongs in the trusted leg that is already calling the API for SR-1).
@fengmk2

fengmk2 commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

@codex review

@fengmk2
fengmk2 force-pushed the rfc-oidc-trusted-publishing branch from 7b45888 to e70e020 Compare August 7, 2026 14:51
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: e70e0206e6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@fengmk2

fengmk2 commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

@codex review

fengmk2 added 2 commits August 9, 2026 20:13
Implements RFC 0002 rollout step 2. The action gains a `mode` input:

  publish (default)  pack and upload in one job, admin-token, unchanged
  pack               raw `pnpm pack` output to output-dir, no network,
                     no credentials; safe in a job building a fork PR
  upload             read input-dir, validate, rebuild, publish; runs in
                     the trusted workflow_run leg

Settles the section 6 question in favour of the thinner build leg: `pack`
emits raw pnpm pack output and every transform whose result the bridge
trusts (version rewrite, batch pinning, repack, hash) now runs in the
trusted leg. The extra CPU there is free; the constraint in RFC 0001 was
the Worker's, never CI's.

src/tarball/validateArchive.ts is the SR-6 canonical policy: bounded
inflate against gzip bombs, entry-count and per-file caps, no duplicate
normalized paths, exactly one package/package.json, files and directories
only, nothing outside package/. Published bytes are always rebuilt through
buildPreviewTarball, so a crafted archive cannot make the validator and
pnpm disagree about which manifest is authoritative.

admin-token becomes optional; without it the action mints a GitHub Actions
OIDC token from the runner, cached and re-minted near expiry so a stalled
upload never sends an expired one, and dropped on a 401.

Two notes for reviewers. nanotar sanitizes entry names on both parse and
write (it resolves .. and strips /, C:/), so escaping paths are normally
refused for landing outside package/ rather than by our traversal check;
the check stays as the thing that refuses if that ever changes, and the
tests say which mechanism they exercise. And GitHub exposes inputs as
INPUT_<NAME> with dashes preserved, not underscored, which warm.mjs also
relies on.
Rewrites docs/ci-setup.md, which still described the admin-token single-job
setup that no longer matches the code. Covers the build leg (mode: pack plus
artifact upload), the authorize job, the trusted workflow_run leg, and the
four bridge vars with vite-plus's real repository and owner ids.

Calls out the parts that are easy to get wrong rather than only the happy
path: the preview-build label is not the security boundary (a PR author can
edit the build leg's own check, and workflow_run matches on workflow name),
download-artifact's run-id defaults to the CURRENT run so omitting it looks
in the wrong place, permissions are scoped per job so nothing that runs
preview code can mint a token, and pr-url must come from the API because the
bridge maps it to the pr-<n> tag that VP_PR_VERSION resolves.

README gains a line noting the publish endpoints accept OIDC while /-/purge
stays admin-only; self-hosting.md cross-references the full wiring.

Also adds the config-state tests that were missing: with the OIDC vars
entirely unset (the state right after this deploys, and what the staging
smoke runs under) the admin token keeps working and a JWT is a plain 401;
with the vars HALF set every publish 503s including the admin path, because
the config resolves before the credential is routed. That last one is a
deployment hazard worth having pinned by a test.
@fengmk2
fengmk2 force-pushed the rfc-oidc-trusted-publishing branch from b8825ef to fd9ad0e Compare August 9, 2026 12:14

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8825ef5a1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tarball/validateArchive.ts
Comment thread .github/actions/publish-preview/src/index.ts Outdated
Comment thread src/security/auth.ts Outdated
Comment thread .github/actions/publish-preview/src/index.ts Outdated
fengmk2 added 3 commits August 9, 2026 20:37
Four findings from the automated review, all valid.

P1, tar extension records. parseTar handles extendedHeader,
globalExtendedHeader and the GNU long-name types with `continue`: it decodes
their payload into strings and drops them from the returned files, so the
entry-count, per-file and entry-type checks never observed them. An archive
could carry unlimited metadata records, or one claiming hundreds of
megabytes, and reach large allocations inside the parser. Adds a raw
512-byte record scan that runs BEFORE parseTar and bounds every record
including the hidden ones. Real npm tarballs do contain these records (a
path over 100 bytes cannot fit the ustar name field), so they are bounded to
64KB rather than rejected. Also rejects the GNU base-256 size form and
non-octal size fields.

P1, OIDC in publish mode. The default publish mode reached the OIDC fallback
whenever admin-token was omitted. That mode packs and uploads in one job, and
`pnpm pack` runs the packed package's own prepack/prepare scripts, so it
would put token minting in a job that executes packaged code, which is the
separation SR-5 exists to keep. OIDC is now confined to upload mode and
anything else fails with an explanatory error.

P2, JWT-shaped admin tokens. looksLikeJwt routed on shape before the admin
comparison ran, so an ADMIN_TOKEN containing three dot-separated segments
would be sent to OIDC verification and rejected, breaking every publish and
`pnpm warm` while still working on /-/purge. The constant-time admin
comparison now runs first; shape only picks the path when the value is not
the operator token.

P2, stale pack output. pack mode created output-dir but never cleaned it, so
a rerun producing fewer packages left higher pkg-<n> indices behind for the
upload leg to republish under the new commit version or collide on. Adds
prepareOutputDir, which removes only action-owned names and leaves anything
else for the upload leg to refuse.

One note on the entry-count message: the raw scan now rejects an oversized
archive before assertCanonicalEntries sees it, so that assertion moved and
the entry-level bound is covered by a direct test instead.
Closes the gap flagged on the SR-6 review thread. The tar header's mode is
attacker-controlled for any archive the bridge did not build, and it passed
straight through the repack, so a crafted tarball's setuid, setgid or sticky
bit survived into the published one. Small exposure, since npm and pnpm
apply their own modes on extract, but "the bytes we publish are ones we
constructed" is only true if the metadata is ours too.

Modes now collapse to 755 or 644, preserving the one bit that means anything
in an npm package (executable, which `bin` entries need) and discarding the
rest. Ownership is flattened to 0/0 with empty user and group: uid, gid,
user and group describe the packing machine, never anything a consumer
should honour.

Correcting my own reply on that thread: I said a crafted PAX record could
override `mode`. It cannot. nanotar builds attrs as
`{...globalExtendedHeader, ...nextExtendedHeader, mode, uid, ...}`, so the
header-derived values are assigned AFTER the spreads and win, and its writer
reads only those six fields, which makes any PAX-injected key inert. The
real exposure was narrower than I described: the tar header's own mode
field. Fixed either way.

mtime is deliberately unchanged. It is inert on extract, and pinning it
would alter the bytes of every package on the trusted publish path too.
Doing so would make republishes byte-identical, which is worth having for
reproducibility, but it is a separate change from this security fix.
Fixes an mtime bug found while implementing this. nanotar's parser returns
mtime in SECONDS while its writer expects MILLISECONDS and divides by 1000,
so passing parsed attrs straight back to the writer divided by 1000 twice:
1985-10-26 was written out as 1970-01-06. Every preview tarball published
so far carries that mangled date.

Entries now carry a fixed mtime of 499162500000ms (1985-10-26T08:15:00Z),
which is what node-tar's `portable` mode stamps and therefore already the
date on every entry `pnpm pack` produces. So repacking preserves the source
date rather than inventing one, and an attacker-supplied mtime on the
untrusted upload path is flattened to the same value.

With mode, ownership and mtime all fixed, a rebuild is deterministic:
identical content produces identical bytes. Verified that the gzip layer
cooperates, since CompressionStream writes a zeroed MTIME into the gzip
header rather than the current time. Republishing a commit therefore lands
on the SAME content-addressed key instead of accumulating one object per
run, which matters given the store is keyed by shasum.

Correcting the previous commit's claim that pinning mtime would change the
bytes of every package on the trusted publish path, and my reply on the
review thread saying the same: the direction was right but the reason was
not. The bytes change because the old code was mangling the date, not
because pnpm pack disagreed with the constant.

One consequence: republishing a commit published before this produces
different bytes and a new CAS key. Harmless, since the packument advertises
the new shasum and the old object expires with its ref, but it means an
already-published version will not hash to its stored value.
@fengmk2

fengmk2 commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f1ac787d73

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tarball/validateArchive.ts Outdated
Comment thread .github/actions/publish-preview/src/index.ts Outdated
Comment thread .github/actions/publish-preview/src/artifact.ts
Comment thread .github/actions/publish-preview/src/artifact.ts
Comment thread .github/actions/publish-preview/src/index.ts Outdated
fengmk2 added 2 commits August 9, 2026 21:24
Cleanup pass from four parallel reviews (reuse, simplification, efficiency,
altitude). No behavior change intended beyond the two noted below.

Deduplication, which three of the four reviews flagged independently:

- One definition of a tar entry path. `normalizeEntryName` moves into
  validateTarballPath.ts next to `assertSafeTarballPath` and
  `isUnderPackageRoot`, and both of those normalize internally. There were
  three spellings of "the same path" in the module whose job is to remove
  exactly that ambiguity.
- One predicate for "is this the manifest". `isPackageManifest` replaces
  PACKAGE_JSON_NAMES plus a bolted-on normalized comparison; the archive
  policy no longer imports from the module it constrains.
- One batch assertion. `resolveArchiveBatch` (was `batchFromArchives`) now
  collects manifests and calls `assertValidBatch` instead of keeping a
  second copy of the allowlist, duplicate-name and dependency rules, down to
  the error strings. PackageDir.dir becomes .label, since a tarball path is
  not a directory.
- One request policy. `send()` holds the retry, auth-header and
  401-invalidate handling that `uploadTarball` and `post` each had.

Efficiency:

- Memoize the JWKS and the imported CryptoKey per isolate. A publish run is
  ~23 authenticated requests sharing one unchanging key, each of which was
  doing a KV read, a JSON parse and an importKey. Keyed by modulus, not kid,
  so a rotated key reusing a kid cannot hit a stale entry.
- Only prefetch one package ahead where reading is actually async. In upload
  mode `read` is readFileSync, so the prefetch returned an already-settled
  promise and just held a second archive resident through the heaviest
  allocation phase. Kept for publish mode, where packDirectory spawns a
  process and the overlap is real.
- The two JWKS KV writes are independent; run them together.

Simplification: verifyOidcToken returns only what callers read (repository,
workflow_ref) instead of echoing back four values it just compared against
constants; requirePublisher flattens to one guard chain; PackManifest.files
duplicated packages[].file; dropped a dead mkdirSync import, an unused
mintOidcToken export, and an identity wrapper in the tests.

Two deliberate deviations from the review suggestions. The path helpers were
proposed to REQUIRE pre-normalized input; they normalize internally instead,
because a future caller who forgets must not silently get a weaker answer
from a security check, and normalizeEntryName is idempotent so the extra
pass is free. And the entry-level count/size bounds stay even though the raw
record scan now reaches them first, so assertCanonicalEntries remains valid
called on its own.

Also replaced a 1.1s wall-clock sleep in the reproducibility test with a
direct assertion that the gzip header MTIME is zeroed, which is what the
sleep was indirectly probing.
Five findings, all valid. Verified each against nanotar's source and, for
the two that turned on its exact behaviour, empirically.

P1, tar size fields. The raw scanner stopped at the first space, while
nanotar's _readNumber passes the whole field to parseInt, which skips
leading whitespace. So ` 2000000000\0` read as 0 here and as 256MiB there:
the scanner advanced one block into an attacker-controlled payload, hit a
zero byte, treated it as end-of-archive, and left the rest of the archive
unbounded, defeating the metadata-record bound added last commit. The field
is now parsed exactly as nanotar parses it, with a separate strict check
that it is octal digits plus space/NUL padding.

P2, long paths. nanotar's writer encodes the name into the 100-byte ustar
field and emits neither a prefix nor a long-name record, so a longer path is
silently TRUNCATED on rebuild. Measured: `package/<100 a's>/index.js` comes
back as 96 bytes, and a sibling sharing that prefix comes back as the SAME
name, so two files become one and the duplicate-path invariant is defeated
after validation passed. This is pre-existing and affects `publish` mode in
production today, not just the new upload path, so the check lives in
buildPreviewTarball where it covers both. Refusing is the only honest option
while the writer cannot represent them: a truncated path is a broken
package. If a real package ever hits this, the fix is a writer that emits
PAX, not a higher limit.

P1, compressed size. readFileSync materialized the whole tarball before
gunzipBounded could bound the inflated size, so a multi-gigabyte pkg-0.tgz
exhausted the runner first. Now bounded from the directory entry we already
lstat, at 128MB against a ~23MB largest real package.

P1, manifest retention. The per-file limit is sized for binaries, so a
hostile artifact could ship several highly compressible 256MB package.json
files, each parsed and retained for the whole batch scan. Bounded to 4MB and
projected to just the name and dependency keys the batch check reads;
buildPreviewTarball re-reads the manifest from the archive anyway.

P2, package count. The artifact reader accepted unlimited pkg-<n>.tgz, so a
modified build workflow could hand the trusted leg tens of thousands of
small archives. Capped at 128 against a batch of ~11.
@fengmk2
fengmk2 merged commit 4ca2c31 into main Aug 10, 2026
4 checks passed
@fengmk2
fengmk2 deleted the rfc-oidc-trusted-publishing branch August 10, 2026 01:04
fengmk2 added a commit to voidzero-dev/vite-plus that referenced this pull request Aug 10, 2026
voidzero-dev/pkg-pr-registry-bridge#86 is merged and deployed, so both pins
move from that PR's branch head to the resulting main commit and the TODO
comments go away.

Worth noting the old pin was stale even against the branch: fd9ad0e predated
the simplify pass and the second review round, so it would have run the
action without the SR-6 hardening (tar size-field agreement, artifact size
and count bounds, the long-path refusal).

Verified action.yml and the bundled dist resolve at the new SHA.
fengmk2 added a commit to voidzero-dev/vite-plus that referenced this pull request Aug 10, 2026
Splits preview publishing into an untrusted build leg and a trusted
publish leg so external contributors' PRs can be installed and reviewed.

Until now the bridge step was gated on the PR coming from this repo,
because GitHub withholds secrets from fork pull_request runs. Fork PRs
therefore got no preview build, no install instructions, and no Docker
image, which is most of the value of the preview flow for exactly the
contributions that need review most.

publish-preview.yml keeps building, but now only packs (`mode: pack`,
no network, no credentials) and uploads a workflow artifact. The new
publish-preview-register.yml triggers on it completing, runs from main
in base-repo context, and mints a GitHub Actions OIDC token that the
bridge verifies against GitHub's JWKS. No bridge secret in this repo.

The `authorize` job is the security boundary, not the label check in
the build leg: on pull_request events GitHub runs the workflow file from
the merge ref, so a PR author can edit that file to delete its own gate,
or add a workflow with a matching name to trigger the trusted leg. So
`authorize` re-resolves the PR from workflow_run.head_sha via the API,
requires it to be open against this repo and currently labeled
preview-build, and fails closed otherwise. It also supplies the PR url,
which the bridge maps to the pr-<n> dist-tag; taking that from the
artifact would let one PR retarget what VP_PR_VERSION installs for
another.

The job holding id-token does nothing but move bytes. The comment and
Docker jobs are separate jobs with their own permissions, so the job
that runs a preview package's install scripts cannot mint a publish
token.

The Docker preview stays same-repo only for now. It installs the preview
package and pushes to ghcr.io/voidzero-dev/vite-plus, and before this
change a fork could not reach it (fork runs get a read-only token, so the
push failed). Running in base-repo context is what would make it succeed,
so whether to put unreviewed fork code under the org's namespace is left
as a deliberate decision: drop the is-fork condition to enable it.

Fork builds get a warning banner naming the source fork above the
install instructions, since those lines are official-looking and end in
`curl | bash`.

Requires on the bridge side (voidzero-dev/pkg-pr-registry-bridge#86):
OIDC_AUDIENCE, OIDC_TRUSTED_WORKFLOWS, OIDC_TRUSTED_REPOSITORY_ID and
OIDC_TRUSTED_OWNER_ID set on the production Worker. The action pin here
points at that PR's head and must be repointed to a main SHA before this
merges.
fengmk2 added a commit to voidzero-dev/vite-plus that referenced this pull request Aug 10, 2026
voidzero-dev/pkg-pr-registry-bridge#86 is merged and deployed, so both pins
move from that PR's branch head to the resulting main commit and the TODO
comments go away.

Worth noting the old pin was stale even against the branch: fd9ad0e predated
the simplify pass and the second review round, so it would have run the
action without the SR-6 hardening (tar size-field agreement, artifact size
and count bounds, the long-path refusal).

Verified action.yml and the bundled dist resolve at the new SHA.
fengmk2 added a commit that referenced this pull request Aug 10, 2026
The four OIDC_* vars were declared in src/config.ts's Env interface but never
in env.ts or .env.production, so they were absent at runtime and the OIDC
path has been disabled since #86 deployed.

They are not secrets: all four hold public identifiers and the verification
key is GitHub's public JWKS, so per the README's configuration model they
belong in env.ts plus the committed .env.production, not `void secret put`.

Declared optional as a group. All four unset disables OIDC and leaves
admin-token publishing untouched; setting only some is already rejected at
request time with a 503 naming the missing var.

Recorded alongside the values: `pnpm deploy:staging` runs the same
`void deploy`, so staging inherits .env.production (which is also why
PUBLIC_BASE_URL there points at prod) and shares this audience and allowlist.
Not an escalation while both are identical, but staging cannot be given a
looser allowlist for testing without that workflow also being able to publish
to production.
fengmk2 added a commit to voidzero-dev/vite-plus that referenced this pull request Aug 10, 2026
Splits preview publishing into an untrusted build leg and a trusted
publish leg so external contributors' PRs can be installed and reviewed.

Until now the bridge step was gated on the PR coming from this repo,
because GitHub withholds secrets from fork pull_request runs. Fork PRs
therefore got no preview build, no install instructions, and no Docker
image, which is most of the value of the preview flow for exactly the
contributions that need review most.

publish-preview.yml keeps building, but now only packs (`mode: pack`,
no network, no credentials) and uploads a workflow artifact. The new
publish-preview-register.yml triggers on it completing, runs from main
in base-repo context, and mints a GitHub Actions OIDC token that the
bridge verifies against GitHub's JWKS. No bridge secret in this repo.

The `authorize` job is the security boundary, not the label check in
the build leg: on pull_request events GitHub runs the workflow file from
the merge ref, so a PR author can edit that file to delete its own gate,
or add a workflow with a matching name to trigger the trusted leg. So
`authorize` re-resolves the PR from workflow_run.head_sha via the API,
requires it to be open against this repo and currently labeled
preview-build, and fails closed otherwise. It also supplies the PR url,
which the bridge maps to the pr-<n> dist-tag; taking that from the
artifact would let one PR retarget what VP_PR_VERSION installs for
another.

The job holding id-token does nothing but move bytes. The comment and
Docker jobs are separate jobs with their own permissions, so the job
that runs a preview package's install scripts cannot mint a publish
token.

The Docker preview stays same-repo only for now. It installs the preview
package and pushes to ghcr.io/voidzero-dev/vite-plus, and before this
change a fork could not reach it (fork runs get a read-only token, so the
push failed). Running in base-repo context is what would make it succeed,
so whether to put unreviewed fork code under the org's namespace is left
as a deliberate decision: drop the is-fork condition to enable it.

Fork builds get a warning banner naming the source fork above the
install instructions, since those lines are official-looking and end in
`curl | bash`.

Requires on the bridge side (voidzero-dev/pkg-pr-registry-bridge#86):
OIDC_AUDIENCE, OIDC_TRUSTED_WORKFLOWS, OIDC_TRUSTED_REPOSITORY_ID and
OIDC_TRUSTED_OWNER_ID set on the production Worker. The action pin here
points at that PR's head and must be repointed to a main SHA before this
merges.
fengmk2 added a commit to voidzero-dev/vite-plus that referenced this pull request Aug 10, 2026
voidzero-dev/pkg-pr-registry-bridge#86 is merged and deployed, so both pins
move from that PR's branch head to the resulting main commit and the TODO
comments go away.

Worth noting the old pin was stale even against the branch: fd9ad0e predated
the simplify pass and the second review round, so it would have run the
action without the SR-6 hardening (tar size-field agreement, artifact size
and count bounds, the long-path refusal).

Verified action.yml and the bundled dist resolve at the new SHA.
fengmk2 added a commit to voidzero-dev/vite-plus that referenced this pull request Aug 10, 2026
Splits preview publishing into an untrusted build leg and a trusted
publish leg so external contributors' PRs can be installed and reviewed.

Until now the bridge step was gated on the PR coming from this repo,
because GitHub withholds secrets from fork pull_request runs. Fork PRs
therefore got no preview build, no install instructions, and no Docker
image, which is most of the value of the preview flow for exactly the
contributions that need review most.

publish-preview.yml keeps building, but now only packs (`mode: pack`,
no network, no credentials) and uploads a workflow artifact. The new
publish-preview-register.yml triggers on it completing, runs from main
in base-repo context, and mints a GitHub Actions OIDC token that the
bridge verifies against GitHub's JWKS. No bridge secret in this repo.

The `authorize` job is the security boundary, not the label check in
the build leg: on pull_request events GitHub runs the workflow file from
the merge ref, so a PR author can edit that file to delete its own gate,
or add a workflow with a matching name to trigger the trusted leg. So
`authorize` re-resolves the PR from workflow_run.head_sha via the API,
requires it to be open against this repo and currently labeled
preview-build, and fails closed otherwise. It also supplies the PR url,
which the bridge maps to the pr-<n> dist-tag; taking that from the
artifact would let one PR retarget what VP_PR_VERSION installs for
another.

The job holding id-token does nothing but move bytes. The comment and
Docker jobs are separate jobs with their own permissions, so the job
that runs a preview package's install scripts cannot mint a publish
token.

The Docker preview stays same-repo only for now. It installs the preview
package and pushes to ghcr.io/voidzero-dev/vite-plus, and before this
change a fork could not reach it (fork runs get a read-only token, so the
push failed). Running in base-repo context is what would make it succeed,
so whether to put unreviewed fork code under the org's namespace is left
as a deliberate decision: drop the is-fork condition to enable it.

Fork builds get a warning banner naming the source fork above the
install instructions, since those lines are official-looking and end in
`curl | bash`.

Requires on the bridge side (voidzero-dev/pkg-pr-registry-bridge#86):
OIDC_AUDIENCE, OIDC_TRUSTED_WORKFLOWS, OIDC_TRUSTED_REPOSITORY_ID and
OIDC_TRUSTED_OWNER_ID set on the production Worker. The action pin here
points at that PR's head and must be repointed to a main SHA before this
merges.
fengmk2 added a commit to voidzero-dev/vite-plus that referenced this pull request Aug 10, 2026
voidzero-dev/pkg-pr-registry-bridge#86 is merged and deployed, so both pins
move from that PR's branch head to the resulting main commit and the TODO
comments go away.

Worth noting the old pin was stale even against the branch: fd9ad0e predated
the simplify pass and the second review round, so it would have run the
action without the SR-6 hardening (tar size-field agreement, artifact size
and count bounds, the long-path refusal).

Verified action.yml and the bundled dist resolve at the new SHA.
fengmk2 added a commit to voidzero-dev/vite-plus that referenced this pull request Aug 10, 2026
Splits preview publishing into an untrusted build leg and a trusted
publish leg so external contributors' PRs can be installed and reviewed.

Until now the bridge step was gated on the PR coming from this repo,
because GitHub withholds secrets from fork pull_request runs. Fork PRs
therefore got no preview build, no install instructions, and no Docker
image, which is most of the value of the preview flow for exactly the
contributions that need review most.

publish-preview.yml keeps building, but now only packs (`mode: pack`,
no network, no credentials) and uploads a workflow artifact. The new
publish-preview-register.yml triggers on it completing, runs from main
in base-repo context, and mints a GitHub Actions OIDC token that the
bridge verifies against GitHub's JWKS. No bridge secret in this repo.

The `authorize` job is the security boundary, not the label check in
the build leg: on pull_request events GitHub runs the workflow file from
the merge ref, so a PR author can edit that file to delete its own gate,
or add a workflow with a matching name to trigger the trusted leg. So
`authorize` re-resolves the PR from workflow_run.head_sha via the API,
requires it to be open against this repo and currently labeled
preview-build, and fails closed otherwise. It also supplies the PR url,
which the bridge maps to the pr-<n> dist-tag; taking that from the
artifact would let one PR retarget what VP_PR_VERSION installs for
another.

The job holding id-token does nothing but move bytes. The comment and
Docker jobs are separate jobs with their own permissions, so the job
that runs a preview package's install scripts cannot mint a publish
token.

The Docker preview stays same-repo only for now. It installs the preview
package and pushes to ghcr.io/voidzero-dev/vite-plus, and before this
change a fork could not reach it (fork runs get a read-only token, so the
push failed). Running in base-repo context is what would make it succeed,
so whether to put unreviewed fork code under the org's namespace is left
as a deliberate decision: drop the is-fork condition to enable it.

Fork builds get a warning banner naming the source fork above the
install instructions, since those lines are official-looking and end in
`curl | bash`.

Requires on the bridge side (voidzero-dev/pkg-pr-registry-bridge#86):
OIDC_AUDIENCE, OIDC_TRUSTED_WORKFLOWS, OIDC_TRUSTED_REPOSITORY_ID and
OIDC_TRUSTED_OWNER_ID set on the production Worker. The action pin here
points at that PR's head and must be repointed to a main SHA before this
merges.
fengmk2 added a commit to voidzero-dev/vite-plus that referenced this pull request Aug 10, 2026
voidzero-dev/pkg-pr-registry-bridge#86 is merged and deployed, so both pins
move from that PR's branch head to the resulting main commit and the TODO
comments go away.

Worth noting the old pin was stale even against the branch: fd9ad0e predated
the simplify pass and the second review round, so it would have run the
action without the SR-6 hardening (tar size-field agreement, artifact size
and count bounds, the long-path refusal).

Verified action.yml and the bundled dist resolve at the new SHA.
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.

1 participant