Skip to content
Merged
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
20 changes: 10 additions & 10 deletions .github/actions/publish-preview/src/artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ import { join } from 'node:path'

/** Advisory description of a packed batch. Never trusted by the reader. */
export interface PackManifest {
/** `commit.<sha>` the pack ran for. Re-derived by the trusted leg. */
/** `commit.<sha>` the pack ran for. Re-derived by the publishing workflow. */
ref: string
/** `0.0.0-commit.<sha>`. Re-derived by the trusted leg. */
/** `0.0.0-commit.<sha>`. Re-derived by the publishing workflow. */
version: string
/** One entry per packed tarball, for human inspection of a failed run. */
packages: Array<{ file: string; name: string; dir: string }>
Expand All @@ -39,7 +39,7 @@ export const MANIFEST_NAME = 'manifest.json'
/**
* Ceiling on how many packages one artifact may carry. The vite-plus batch is
* ~11; this leaves room to grow while stopping a modified build workflow from
* handing the trusted leg tens of thousands of small archives to validate.
* handing the publishing workflow tens of thousands of small archives.
*/
export const MAX_ARTIFACT_PACKAGES = 128

Expand All @@ -66,15 +66,15 @@ function isActionOutput(entry: string): boolean {
* Create `dir` and remove this action's own outputs from any previous run.
*
* Packing writes `pkg-0..N`, so a rerun that produces FEWER packages than the
* last one would otherwise leave the higher indices behind. The upload leg
* enumerates every `pkg-<n>.tgz` it finds and ignores `manifest.json`, so those
* leftovers would be republished under the new commit version, or collide as a
* duplicate package name and fail the run. CI gets a clean runner, but local
* runs and `pnpm warm` reuse a workspace.
* last one would otherwise leave the higher indices behind. The publishing
* workflow enumerates every `pkg-<n>.tgz` it finds and ignores `manifest.json`,
* so those leftovers would be republished under the new commit version, or
* collide as a duplicate package name and fail the run. CI gets a clean runner,
* but local runs and `pnpm warm` reuse a workspace.
*
* Only action-owned names are removed. Anything else in the directory is left
* alone and will make the upload leg refuse the artifact, which is better than
* this quietly deleting a file it did not create.
* alone and will make the publishing workflow refuse the artifact, which is
* better than this quietly deleting a file it did not create.
*/
export function prepareOutputDir(dir: string): void {
mkdirSync(dir, { recursive: true })
Expand Down
4 changes: 2 additions & 2 deletions .github/actions/publish-preview/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
* `output-dir`. No network and no credentials, so it can run in a job that
* builds an untrusted fork pull request.
* - `upload`: the remote half only, reading `input-dir`. Runs in the trusted
* `workflow_run` leg and treats the artifact as hostile: every archive is
* `workflow_run` workflow and treats the artifact as hostile: every archive
* validated against the SR-6 canonical policy, the version comes from the
* trusted `sha` input, and each package is REBUILT here so the bytes
* published are ones this step constructed.
Expand Down Expand Up @@ -360,7 +360,7 @@ async function main(): Promise<void> {
const dirs = expandPackageDirs(parsePackagesInput(input('packages') || DEFAULT_PACKAGES), cwd)
const packages = dirs.map((dir) => ({ label: dir, manifest: readManifest(dir) }))
// Fail on the batch before packing anything, so a missing platform dir
// stops the build leg rather than producing a partial artifact.
// stops the build workflow rather than producing a partial artifact.
assertValidBatch(packages, env)

prepareOutputDir(outputDir)
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/publish-preview/src/oidcToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* `permissions: id-token: write`. Requesting a token with the bridge as
* audience proves "this workflow, in this repository, ran this" without the
* repository storing a bridge credential, which is what lets fork PRs publish
* through the trusted `workflow_run` leg.
* through the publishing `workflow_run` workflow.
*
* Reads the runner environment directly rather than depending on
* `@actions/core`, keeping the bundled action free of runtime deps.
Expand Down
33 changes: 19 additions & 14 deletions docs/ci-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ Publishing is split across two workflows, because GitHub denies fork
authenticate to the bridge from the job that builds it, no matter how the
credential is delivered.

**Build leg** (`publish-preview.yml`, on `pull_request`, label `preview-build`).
**Build workflow** (`publish-preview.yml`, on `pull_request`, label `preview-build`).
Builds the PR and runs the bridge action in `mode: pack`, which packs each
locally built package directory with `pnpm pack` and stops. No network, no
credentials. The packed tarballs upload as the `bridge-packages` artifact. This
runs for forks.

**Trusted leg** (`publish-preview-register.yml`, on `workflow_run`). Runs the
**Publishing workflow** (`publish-preview-register.yml`, on `workflow_run`). Runs the
workflow file from the default branch in base-repo context, so it can mint an
OIDC token whatever repository the PR came from. It downloads the artifact and
runs the action in `mode: upload`, which validates every archive, rewrites and
Expand All @@ -30,10 +30,10 @@ metadata never diverge for longer than one upload. A final `POST /-/register`
flips the whole version visible atomically, so a run cancelled mid-way leaves
only invisible artifacts.

The trusted leg treats the artifact as hostile, because it was produced by a job
The publishing workflow treats the artifact as hostile, because it was produced by a job
that ran the PR's code. The published version comes from
`workflow_run.head_sha`, every package name is read back out of a validated
archive, and the bytes published are ones the trusted leg rebuilt. Nothing in
archive, and the bytes published are ones the publishing workflow rebuilt. Nothing in
the artifact selects what gets published.

The Worker then only serves bytes from R2, with `dist.integrity` matching the
Expand All @@ -43,15 +43,20 @@ exact bytes served. pkg.pr.new is not involved anywhere.

`preview-build` is the consent step, and only maintainers can apply it. But on
`pull_request` events GitHub runs the workflow file from the merge ref, so a PR
author can edit the build leg and delete its own label check, or add a workflow
with a matching `name:` to trigger the trusted leg (`workflow_run` matches on
author can edit the build workflow and delete its own label check, or add a workflow
with a matching `name:` to trigger the publishing workflow (`workflow_run` matches on
workflow name).

The `authorize` job is what makes the label mean anything. It re-resolves the PR
from `workflow_run.head_sha` through the API, requires it open against this
repository and currently labeled, and fails closed on a missing PR, a missing
label, or an API error. Do not remove it, and do not let the publish job run
without `needs: authorize`.
through the API, requires it open against this repository, currently labeled,
and still pointing at the commit that was built, and fails closed on a missing
PR, a missing label, or an API error. Do not remove it, and do not let the
publish job run without `needs: authorize`.

Resolve by head repository and branch, not by commit:
`listPullRequestsAssociatedWithCommit` returns nothing for a fork PR's head
commit, and `workflow_run.pull_requests` is empty for forks too, so both are
blind to exactly the case this setup exists for.

## Setup

Expand Down Expand Up @@ -84,7 +89,7 @@ someone else. A rename keeps the same `repository_id` but changes
`workflow_ref`, so publishes fail until the allowlist is updated. That is the
correct direction to fail, but it will look like an outage to whoever hits it.

### 2. Build leg
### 2. Build workflow

In the job that assembles the build artifacts, after `pnpm install`, the
artifact downloads, and `publish-native-addons.ts --mode pkg-pr-new` (which
Expand Down Expand Up @@ -117,7 +122,7 @@ than publishing a version with a dangling dep.
> events `github.sha` is the ephemeral **merge** commit, whereas the checkout
> being packed is the PR **head** commit.

### 3. Trusted leg
### 3. Publishing workflow

```yaml
on:
Expand Down Expand Up @@ -183,7 +188,7 @@ would let one PR change what installs for another. Derive it from the API.

### 4. Verify

After a labeled PR builds and the trusted leg runs:
After a labeled PR builds and the publishing workflow runs:

```bash
curl https://registry-bridge.viteplus.dev/-/refs
Expand All @@ -193,7 +198,7 @@ Verify a same-repo PR first, then a fork PR, then confirm an unlabeled fork PR
does **not** publish.

Note that `workflow_run` only fires for workflow files already on the default
branch, so the trusted leg cannot be exercised from the pull request that
branch, so the publishing workflow cannot be exercised from the pull request that
introduces it. The first real verification happens after merge.

## Notes
Expand Down
2 changes: 1 addition & 1 deletion docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ manual operations.
This alone does not give you fork-PR publishing: that also needs the
two-workflow split on the consumer side, because fork `pull_request` runs cannot
mint an OIDC token either. [`ci-setup.md`](./ci-setup.md) has the full wiring
(build leg, `authorize` job, trusted leg) and RFC 0002 has the reasoning.
(build workflow, `authorize` job, publishing workflow) and RFC 0002 has the reasoning.

## 6. Attach a custom domain (optional)

Expand Down
Loading