ci(release): preview-artifact build-once workflow, LTS cut dry-run script and GO/NO-GO checklist (rail 3.8.58–.59) (#16210)

* ci(release): preview-artifact build-once workflow with artifact identity (#8084)

Manual (workflow_dispatch pr_number) or `preview-artifact` label lane that
builds the PR head once (npm ci + build:release), validates that build with
check:pack-artifact and check:pack-boot (fake secrets, ephemeral DATA_DIR),
proves the uploaded tarball is byte-identical to a re-pack of the validated
tree, records its identity (head/base SHA, lockfile hash, platform/arch/node
ABI, bundler, build policy) and uploads tarball + identity + SHA256SUMS.
A separate GitHub-hosted `attest` job, the only one holding id-token/
attestations write, verifies the checksums and attests the tarball. Fork PRs
are refused on both trigger paths; nothing publishes.

Refs #8084

* feat(release): dry-run script for the 3.9.0 LTS cut (rail 3.8.58)

`npm run release:dry-run-lts-cut` prints and validates the cut without
executing anything: preconditions (source resolves, previous tag v3.8.59,
package.json = 3.9.0, release-freeze open, release branch green per the
"Release branch not green" issue, stable/v3 + develop absent), stable/v3 from
the tip, develop with the 4.0.0 bump (package.json x3, lockfile, openapi),
which dormant-workflow triggers/`if:` turn true (and which stay gated by a
repository variable or are pinned to the canonical repo), expected dist-tags
(latest -> 3.9.0, next/nightly empty; uses scripts/release/dist-tag.mjs when
present, a minimal fallback otherwise) and the rollback.

`--execute --remote <fork>` runs the two branch steps (or `--rollback`) only
against a fork: origin and any remote whose URL is the canonical repository
are refused, every step is confirmed on the terminal, the develop bump commit
is built with git plumbing (no working tree touched) and every command is an
execFile argv array. Rehearsal parameters (`--target-version`,
`--previous-tag`, `--advisory`) let 3.8.58 run it on the current tip.

* feat(release): print the stable/v3 and develop SHAs after an LTS-cut rehearsal

* docs(release): 3.9.0 LTS cut runbook and GO/NO-GO checklist (rail 3.8.58-.59)

RELEASE_CHECKLIST gains "3.9.0 LTS cut (rehearsed in 3.8.58)": the read-only
dry-run (`npm run release:dry-run-lts-cut`), the fork-only rehearsal and its
rollback, the PR preview-artifact lane (download, checksum, attestation
verify, install the same bytes) and the ordered real cut.

New docs/ops/LTS_GO_NO_GO.md: rail gates G0/G1/G2/G11/G12/G13/G14 and the
10-item closing battery, each with the command that proves it and an empty
evidence column for the owner, ending in the GO / NO-GO line. Linked from the
ROADMAP (Phase 3), the docs index and the ops sidebar.

* fix(release): model G11 as the Mergify queue and report base-green as unknown for missing branches

G11 is the Mergify `release` queue (GitHub's native merge queue was rejected
in v3.8.49: no branch wildcards on a personal-account repo), not a ruleset
`merge_queue` rule. The dry-run now reads `.mergify.yml` at the cut source
(queue_rules[release] targeting release/*, entered via the `queue` label, with
a checks_timeout) and checks the `queue` label exists; the release/* ruleset
is checked separately for deletion + non_fast_forward only.

base-green no longer reads "green" when the release branch does not exist on
origin: no open base-red issue is not evidence for a branch the observer
cannot see, so it reports `unknown ... (not verifiable)` and fails closed.
This commit is contained in:
Diego Rodrigues de Sa e Souza 2026-10-10 10:09:19 -03:00 • committed by GitHub
parent ac92b0bdac
commit 1d5128f55f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 2348 additions and 2 deletions

252
.github/workflows/preview-artifact.yml vendored Normal file
View file

@ -0,0 +1,252 @@
name: Preview artifact (build once)
# PR preview artifact — #8084 slice (a), rehearsed in 3.8.58 (ROADMAP Phase 2).
#
# Builds ONE production-equivalent npm tarball from the exact PR head, validates
# THAT build (check:pack-artifact + check:pack-boot, which installs the packed
# tarball into a clean prefix and boots it with fake secrets on an isolated
# DATA_DIR), records its identity (head SHA + base SHA + lockfile hash +
# platform/arch/node ABI + build policy, scripts/release/artifact-identity.mjs),
# uploads tarball + identity + SHA256SUMS and attests the tarball's provenance.
# The uploaded bytes are what a preview install or a promotion consumes: there is
# no second source build anywhere downstream.
#
# Out of scope on purpose: publishing (nothing here talks to a registry), DB-clone
# migration rehearsal (#8084 reply 3 — its own subsystem) and live provider probes.
#
# Triggers: `workflow_dispatch` with a PR number, or adding the `preview-artifact`
# label to a PR. Fork PRs are never built: the label path checks
# head.repo.full_name in the job `if:`, the dispatch path re-checks it from the API
# before anything is checked out. This runs PR code (same-repository branches
# only), so the build job holds a read-only token and no OIDC permission; the
# OIDC/attestation permissions live in the separate `attest` job, which never
# checks out or executes repository code.
on:
workflow_dispatch:
inputs:
pr_number:
description: "Pull request number to build (same-repository PRs only)"
required: true
type: string
pull_request:
types: [labeled]
permissions:
contents: read # default for every job; each job narrows or extends below
concurrency:
group: preview-artifact-${{ github.event.pull_request.number || inputs.pr_number }}
cancel-in-progress: true
env:
PREVIEW_NODE_VERSION: "24"
jobs:
resolve:
name: Resolve PR head and base
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.label.name == 'preview-artifact' &&
github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
pull-requests: read # gh api repos/<repo>/pulls/<N> (head repo, state, SHAs)
outputs:
pr_number: ${{ steps.pr.outputs.pr_number }}
head_sha: ${{ steps.pr.outputs.head_sha }}
base_sha: ${{ steps.pr.outputs.base_sha }}
base_ref: ${{ steps.pr.outputs.base_ref }}
steps:
- name: Resolve the PR and refuse forks
id: pr
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
EVENT_PR: ${{ github.event.pull_request.number }}
INPUT_PR: ${{ inputs.pr_number }}
run: |
set -euo pipefail
PR="${EVENT_PR:-$INPUT_PR}"
if ! printf '%s' "$PR" | grep -qE '^[0-9]+$'; then
echo "::error::pr_number must be a positive integer"
exit 1
fi
DATA=$(gh api "repos/$REPO/pulls/$PR")
HEAD_REPO=$(jq -r '.head.repo.full_name // ""' <<<"$DATA")
STATE=$(jq -r '.state' <<<"$DATA")
HEAD_SHA=$(jq -r '.head.sha' <<<"$DATA")
BASE_REF=$(jq -r '.base.ref' <<<"$DATA")
if [ "$HEAD_REPO" != "$REPO" ]; then
echo "::error::PR #$PR comes from a fork ($HEAD_REPO) — preview artifacts are built for same-repository branches only"
exit 1
fi
if [ "$STATE" != "open" ]; then
echo "::error::PR #$PR is $STATE — preview artifacts are built for open PRs only"
exit 1
fi
# The base SHA recorded in the identity is the base branch tip at build time.
BASE_SHA=$(gh api "repos/$REPO/git/ref/heads/$BASE_REF" --jq '.object.sha')
{
echo "pr_number=$PR"
echo "head_sha=$HEAD_SHA"
echo "base_sha=$BASE_SHA"
echo "base_ref=$BASE_REF"
} >> "$GITHUB_OUTPUT"
echo "PR #$PR head=$HEAD_SHA base=$BASE_REF@$BASE_SHA"
build:
name: Build once, validate, upload
needs: resolve
# Same pool rule as npm-publish.yml: the hosted runner cannot hold `next build`
# for this tree any more (#11946). The fork clause is redundant here (resolve
# already refused forks) and is kept so the expression stays greppable.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-build"]') || 'ubuntu-latest' }}
timeout-minutes: 120
permissions:
contents: read # checkout + git fetch refs/pull/<N>/head — read-only, no OIDC
outputs:
artifact_name: ${{ steps.name.outputs.artifact_name }}
env:
# Fake, build-time-only values. No real secret is ever exposed to this job.
JWT_SECRET: preview-artifact-fake-jwt-secret-with-sufficient-length
API_KEY_SECRET: preview-artifact-fake-api-key-secret-0000000000000000
steps:
- name: Checkout the PR head
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.resolve.outputs.head_sha }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ env.PREVIEW_NODE_VERSION }}
package-manager-cache: false
- name: Install dependencies (npm ci)
uses: ./.github/actions/npm-ci-retry
with:
# A promotable artifact is never built from a cached node_modules.
cache: "false"
- name: Build release bundle
run: npm run build:release
# check:pack-artifact's provenance guard (#10427) needs a ref the stamped head
# is an ancestor of. refs/pull/<N>/head exists in this repository for every PR.
- name: Fetch the PR head ref for the provenance guard
env:
PR_NUMBER: ${{ needs.resolve.outputs.pr_number }}
run: |
git fetch --no-tags --depth=50 origin \
"+refs/pull/$PR_NUMBER/head:refs/remotes/origin/pr-head"
- name: Validate the npm package artifact
env:
OMNIROUTE_RELEASE_REF: origin/pr-head
run: npm run check:pack-artifact
- name: Pack the tarball (the bytes that get uploaded)
id: pack
run: |
set -euo pipefail
OUT_DIR="$RUNNER_TEMP/preview-artifact"
mkdir -p "$OUT_DIR"
FILE=$(npm pack --json --pack-destination "$OUT_DIR" | jq -r '.[0].filename' | tr '/' '-')
test -f "$OUT_DIR/$FILE"
SHA=$(sha256sum "$OUT_DIR/$FILE" | cut -d' ' -f1)
{
echo "dir=$OUT_DIR"
echo "file=$FILE"
echo "sha256=$SHA"
} >> "$GITHUB_OUTPUT"
echo "packed $FILE sha256=$SHA"
# Packs the same tree again, installs it into a clean prefix and boots it with
# fake JWT/API-key secrets on an ephemeral DATA_DIR (the script also forces its
# own isolated values for the spawned server).
- name: Boot-smoke the packed tarball
env:
DATA_DIR: ${{ runner.temp }}/preview-artifact-data
run: npm run check:pack-boot
# Build once: the tree must not have changed while it was being validated, and
# `npm pack` must reproduce the uploaded bytes exactly — otherwise what booted
# and what gets uploaded are not provably the same artifact.
- name: Prove the validated bytes are the uploaded bytes
env:
OUT_DIR: ${{ steps.pack.outputs.dir }}
FILE: ${{ steps.pack.outputs.file }}
EXPECTED_SHA: ${{ steps.pack.outputs.sha256 }}
run: |
set -euo pipefail
RECHECK="$RUNNER_TEMP/preview-artifact-recheck"
mkdir -p "$RECHECK"
npm pack --pack-destination "$RECHECK" >/dev/null
ACTUAL=$(sha256sum "$RECHECK/$FILE" | cut -d' ' -f1)
if [ "$ACTUAL" != "$EXPECTED_SHA" ]; then
echo "::error::re-pack digest $ACTUAL != uploaded $EXPECTED_SHA — the artifact is not reproducible from the validated tree"
exit 1
fi
rm -rf "$RECHECK"
echo "re-pack reproduces $FILE byte for byte"
- name: Record the artifact identity
env:
HEAD_SHA: ${{ needs.resolve.outputs.head_sha }}
BASE_SHA: ${{ needs.resolve.outputs.base_sha }}
OUT_DIR: ${{ steps.pack.outputs.dir }}
FILE: ${{ steps.pack.outputs.file }}
run: |
set -euo pipefail
node scripts/release/artifact-identity.mjs \
--head "$HEAD_SHA" \
--base "$BASE_SHA" \
--lockfile package-lock.json \
--tarball "$OUT_DIR/$FILE" \
--out "$OUT_DIR/artifact-identity.json"
(cd "$OUT_DIR" && sha256sum "$FILE" artifact-identity.json > SHA256SUMS)
- name: Name the workflow artifact
id: name
env:
PR_NUMBER: ${{ needs.resolve.outputs.pr_number }}
HEAD_SHA: ${{ needs.resolve.outputs.head_sha }}
run: echo "artifact_name=preview-artifact-pr${PR_NUMBER}-${HEAD_SHA:0:7}" >> "$GITHUB_OUTPUT"
- name: Upload tarball + identity
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.name.outputs.artifact_name }}
path: ${{ steps.pack.outputs.dir }}
if-no-files-found: error
retention-days: 14
attest:
name: Attest build provenance
needs: build
# GitHub-hosted on purpose: attestation signing uses the job's OIDC token, and
# this job never checks out or runs repository code.
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read # required by attest-build-provenance
id-token: write # Sigstore signing of the provenance statement
attestations: write # store the attestation on this repository
steps:
- name: Download the uploaded artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ needs.build.outputs.artifact_name }}
path: preview-artifact
- name: Verify checksums
working-directory: preview-artifact
run: sha256sum -c SHA256SUMS
- name: Attest the tarball
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: preview-artifact/*.tgz

View file

@ -64,6 +64,8 @@ branch model:
New features land in the v4 channel. The LTS line is stability-first.
See also: [LTS GO/NO-GO checklist](docs/ops/LTS_GO_NO_GO.md) and the [3.9.0 cut runbook](docs/ops/RELEASE_CHECKLIST.md#390-lts-cut-rehearsed-in-3858) (dry-run, fork rehearsal, PR preview artifact).
## Phase 4 — v4.0: the modular platform
The monolith is intentionally disassembled on `develop`:

View file

@ -193,6 +193,7 @@ Release, deployment, proxies, tunnels, coverage, database, monitoring.
- [BRANCH_PROTECTION_MAIN.md](ops/BRANCH_PROTECTION_MAIN.md) — `main` branch protection.
- [CONTRIBUTION_GOLDEN_PATH.md](ops/CONTRIBUTION_GOLDEN_PATH.md) — contribution golden path (focused checks per change type).
- [COVERAGE_PLAN.md](ops/COVERAGE_PLAN.md) — test coverage plan.
- [LTS_GO_NO_GO.md](ops/LTS_GO_NO_GO.md) — 3.9.0 LTS GO/NO-GO checklist (rail gates + closing battery).
- [DATABASE_GUIDE.md](ops/DATABASE_GUIDE.md) — DB schema and operations.
- [SQLITE_RUNTIME.md](ops/SQLITE_RUNTIME.md) — SQLite driver resolution chain.
- [REDIS_PRODUCTION_CONFIG.md](ops/REDIS_PRODUCTION_CONFIG.md) — Redis production configuration.

67
docs/ops/LTS_GO_NO_GO.md Normal file
View file

@ -0,0 +1,67 @@
---
title: "3.9.0 LTS GO/NO-GO checklist"
lastUpdated: 2026-10-10
---
# 3.9.0 LTS GO/NO-GO checklist
The owner's decision gate at the end of **3.8.59** (ROADMAP Phase 2): is the code that
becomes the v3 LTS line (`stable/v3`, npm `latest` for the whole v4 cycle) ready to be cut
as **3.9.0**? The cut itself is described in
[RELEASE_CHECKLIST.md → 3.9.0 LTS cut](./RELEASE_CHECKLIST.md#390-lts-cut-rehearsed-in-3858)
and the branch/channel model in [RELEASE_STRATEGY.md](./RELEASE_STRATEGY.md) and the
[ROADMAP](../../ROADMAP.md) (Phase 3).
How to use it:
- Run every command against the **3.8.59 release tip** (or the 3.9.0 release PR), not a
feature branch. "Green" means the command exits 0 with no new allowlist entry and no
baseline moved upwards.
- Paste the evidence (CI run URL, command output excerpt, or commit SHA) in the
**Evidence** column. An empty cell is a NO-GO.
- A NO-GO is not a failure of the rail: fix, re-run the row, and decide again.
## Rail gates
Only the gates of the 3.8.x rail. G3–G10 belong to the v4 phases (modular platform) and are
not part of the LTS decision.
| Gate | What must hold | How to prove | Evidence |
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| G0 | PRs to `release/**` (the `quality.yml` lane) run the same ratchets and security gates as PRs to `main` | `Gate / Quality` green on the 3.9.0 release PR: `gh pr checks <release-PR>`; locally `npm run quality:ratchet` | |
| G1 | `check:known-symbols` is registry-aware (no regex false negatives on registered strategies/executors) | `npm run check:known-symbols` | |
| G2 | Every god-file slice left file size and quality metrics at or below baseline (no own-growth without the owner's explicit approval) | `npm run check:file-size` and `npm run quality:ratchet` on the tip; every change to `config/quality/quality-baseline.json` in the cycle (`git log -p origin/main..HEAD -- config/quality/quality-baseline.json`) lowers a number or links the owner's approval | |
| G11 | Mergify queue `release` active: `queue_rules` present, `checks_timeout` defined, label `queue` exists; `--admin merge` is not used (GitHub's native merge queue was rejected in v3.8.49 — no branch wildcards on a personal-account repo, see the `.mergify.yml` header; batching is blocked by the Mergify plan tier) | `npm run release:dry-run-lts-cut -- --from <tip>` shows `✓ merge-queue` (reads `.mergify.yml` at the tip + the `queue` label) and `✓ release-ruleset` (the `release/*` ruleset keeps `deletion` + `non_fast_forward`); no `--admin` merge in the cycle | |
| G12 | `Gate / CI` and `Gate / Quality` are the required status rollups and fail when any job they need fails | `gh pr checks <release-PR>` shows both rollups green; a deliberately red job on a scratch PR turns its rollup red | |
| G13 | The golden sets that pinned behavior before the `combo.ts` / `chatCore.ts` decomposition still pass | `node --import tsx/esm --test tests/unit/g13-combo-chatcore-golden.test.ts tests/unit/executor-map-golden.test.ts tests/unit/provider-translate-path-golden.test.ts` | |
| G14 | `no-restricted-imports` boundaries hold (no `localDb.ts` barrel import outside `src/lib/db/`, no `open-sse/executors/**` in `src/app/**`) | `npm run lint` and `node --import tsx/esm --test tests/unit/eslint-import-boundaries.test.ts` | |
## Closing battery
The same battery closes every rail version; for the LTS it runs on the final tip. Run the
unit and Vitest suites **twice in a row** — a flake is a NO-GO until it is explained.
| # | Item | How to prove | Evidence |
| --- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| 1 | Lint and typecheck | `npm run lint && npm run typecheck:core && npm run typecheck:noimplicit:core` | |
| 2 | Unit and Vitest (disjoint runners, both required) | `npm run test:unit && npm run test:vitest` (twice) | |
| 3 | Coverage at or above the baseline (floor 60/60/60/60) | `npm run test:coverage && npm run quality:ratchet` | |
| 4 | Cycles, file size, complexity, dead code | `npm run check:cycles:ratchet && npm run check:file-size && npm run check:complexity && npm run check:cognitive-complexity && npm run check:dead-code` | |
| 5 | Known symbols, provider consistency, generators clean | `npm run check:known-symbols && npm run check:provider-consistency && npm run gen:provider-reference && git diff --exit-code docs/reference/PROVIDER_REFERENCE.md` | |
| 6 | E2E provider journey and Playwright | `npm run test:integration` (includes `tests/integration/provider-journey.contract.test.ts`) and `npm run test:e2e` | |
| 7 | Build and smoke of the artifact that ships | `gh workflow run preview-artifact.yml -f pr_number=<release-PR>` (build once, `check:pack-artifact` + `check:pack-boot`, attested), or locally `npm run build:release && npm run check:pack-artifact && npm run check:pack-boot` | |
| 8 | VPS canary with live smoke (Hard Rule #18) | Deploy the attested tarball to the VPS (192.168.0.15), then `curl -fsS http://192.168.0.15:<port>/api/monitoring/health` and one live request per critical route (`/v1/chat/completions`, `/v1/messages`, `/v1/models`) | |
| 9 | CHANGELOG consolidated, cycle issues triaged | `npm run check:changelog-integrity && npm run check:docs-sync`; every issue referenced by the cycle is closed or re-pointed | |
| 10 | Characterization tests still green | `node --import tsx/esm --test "tests/unit/**/*.characterization.test.ts"` | |
## Cut readiness
| Item | How to prove | Evidence |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| Release branch green | The release branch exists (`git ls-remote --heads origin <release-branch>` is not empty) AND `gh issue list --repo diegosouzapw/OmniRoute --state open --search "Release branch not green: <release-branch> in:title"` is empty — no issue for a missing branch is not evidence | |
| 3.8.58 rehearsal done and rolled back | Fork rehearsal log of `node scripts/release/dry-run-lts-cut.mjs --execute ...` and of `--execute --rollback` (see the checklist) | |
| Dry-run of the real cut is READY | `npm run release:dry-run-lts-cut -- --from <3.9.0-tip>` exits 0 and prints `RESULT: READY` | |
---
GO / NO-GO: ______ (owner, date)

View file

@ -1,7 +1,7 @@
---
title: "Release Checklist"
version: 3.8.51
lastUpdated: 2026-08-28
lastUpdated: 2026-10-10
---
# Release Checklist
@ -386,6 +386,111 @@ Before shipping any v3.8.x release, verify these additional items:
---
## 3.9.0 LTS cut (rehearsed in 3.8.58)
After v3.8.59 the next version is 3.9.0, and its tip becomes two long-lived branches:
`stable/v3` (the v3 LTS line, npm `latest`) and `develop` (v4, bumped to 4.0.0, npm
`nightly`). The branch/channel model, forward-port and labels are in
[RELEASE_STRATEGY.md](./RELEASE_STRATEGY.md); the plan is in the [ROADMAP](../../ROADMAP.md) (Phase 3). The cut runs once;
3.8.58 rehearses it end to end on a fork, and 3.8.59 closes with the
[GO/NO-GO checklist](./LTS_GO_NO_GO.md).
### Dry-run (read-only, safe any time)
```bash
npm run release:dry-run-lts-cut # the real cut: 3.9.0 from HEAD, previous tag v3.8.59
npm run release:dry-run-lts-cut -- --from <3.9.0-tip> # pin the source commit
```
`scripts/release/dry-run-lts-cut.mjs` executes nothing: it reads git and `gh` and prints the
whole sequence — preconditions (source resolves, previous tag exists, `package.json` is the
target version, a `release-freeze` issue is open, no open `Release branch not green` issue
on an existing release branch — a branch that does not exist reports `?` unknown, never
green — the Mergify `release` queue is configured (G11: `queue_rules`, `checks_timeout`,
label `queue`), the `release/*` ruleset still blocks deletion and force-push, and
`stable/v3` and `develop` do not exist yet), the two branch steps, which dormant-workflow
triggers and `if:` conditions turn true (and which stay gated by a repository variable or
pinned to the canonical repository), the expected dist-tags (`latest` → 3.9.0, `next` and
`nightly` empty) and the rollback. Exit `0` = `RESULT: READY`, `1` = a blocking precondition
failed (`✗`), `2` = usage error. `--advisory <id,...>` downgrades a check to a warning (`!`)
without hiding it.
Run the real cut's dry-run while the 3.9.0 release freeze is still open — the branches are
created after the tag and before Phase 12c lifts the freeze.
### 3.8.58 rehearsal (fork only)
```bash
# 1. Dry-run on the current tip with rehearsal parameters
npm run release:dry-run-lts-cut -- --target-version 3.8.58 --previous-tag v3.8.57 \
--advisory freeze,base-green
# 2. Execute against a FORK remote (origin, or any remote whose URL is the canonical
# repository, is refused; every step asks for confirmation on the terminal)
git remote add rehearsal https://github.com/<you>/OmniRoute.git
node scripts/release/dry-run-lts-cut.mjs --execute --remote rehearsal \
--target-version 3.8.58 --previous-tag v3.8.57 --advisory freeze,base-green
# 3. Exercise the dormant workflows in the fork (workflow_dispatch where the dry-run
# reports a canonical-repository pin), then roll back
node scripts/release/dry-run-lts-cut.mjs --execute --rollback --remote rehearsal \
--target-version 3.8.58 --previous-tag v3.8.57 --advisory freeze,base-green
```
The develop bump commit is built with git plumbing (no working tree is touched) and bumps
the same five files as a cycle-open commit: `package.json`, `open-sse/package.json`,
`electron/package.json`, `package-lock.json` and `docs/openapi.yaml`. The `[4.0.0]`
CHANGELOG section and its i18n mirrors are opened on `develop` afterwards, before its first
PR. The script never changes npm dist-tags — rehearse those on a scratch package.
### PR preview artifact (build once, promote the same bytes)
`.github/workflows/preview-artifact.yml` builds one production tarball from a PR head and
validates that exact build (#8084 slice (a)). Same-repository PRs only; nothing is published.
```bash
gh workflow run preview-artifact.yml -f pr_number=<N> # or add the `preview-artifact` label
gh run download <run-id> --name preview-artifact-pr<N>-<sha7> --dir preview
cd preview && sha256sum -c SHA256SUMS
gh attestation verify omniroute-*.tgz --repo diegosouzapw/OmniRoute
npm install -g ./omniroute-*.tgz # preview install
```
The run does `npm ci`, `npm run build:release`, `npm run check:pack-artifact`, packs the
tarball, runs `npm run check:pack-boot` (fake secrets, ephemeral data dir), re-packs and
fails unless the digest is identical, then records `artifact-identity.json` (head SHA, base
SHA, lockfile hash, platform, arch, node ABI, bundler, build policy —
`scripts/release/artifact-identity.mjs`) and attests the tarball in a separate job. Promoting
a preview means installing that tarball: never rebuild from source.
### The cut (3.9.0, after GO)
1. GO recorded in [LTS_GO_NO_GO.md](./LTS_GO_NO_GO.md).
2. `npm run release:dry-run-lts-cut -- --from v3.9.0` prints `RESULT: READY`.
3. Create the branches on `origin` by hand with the commands the dry-run prints — the
script refuses to push to `origin`. To reuse a reviewed develop commit, run the
`--execute` rehearsal on the 3.9.0 tip against your fork first; it prints both SHAs, and
the same commits can be pushed:
```bash
git push origin <stable-sha>:refs/heads/stable/v3 <develop-sha>:refs/heads/develop
```
4. Protect `stable/v3` and `develop` (rulesets + merge queue) before the first PR lands.
5. The dormant workflows switch on by branch existence: `forward-port.yml` (push to
`stable/v3`), `validate-stable-pr.yml` (PRs to `stable/v3`) and `nightly-v4-build.yml`
(builds `develop`). Before go-live, set the `secrets.FORWARD_PORT_TOKEN` repository secret (so CI runs on
forward-port PRs); nightly publishing stays off until the owner sets the repository
variable `vars.NIGHTLY_PUBLISH` to `true` and npm Trusted Publishing accepts
`nightly-v4-build.yml`. Channel resolution is `scripts/release/dist-tag.mjs`, the same
resolver `npm-publish.yml` uses.
6. Verify the channels: `npm view omniroute dist-tags --json` shows `latest` = 3.9.0 and no
`next` / `nightly` until v4 publishes.
7. Rollback, if needed: `git push origin --delete refs/heads/stable/v3 refs/heads/develop`
and `npm dist-tag add omniroute@3.8.59 latest`.
---
## Rollback
If release has critical issue:

View file

@ -20,6 +20,7 @@
"MONITORING_GUIDE",
"PERF_BASELINE",
"CONTRIBUTION_GOLDEN_PATH",
"COVERAGE_PLAN"
"COVERAGE_PLAN",
"LTS_GO_NO_GO"
]
}

View file

@ -302,6 +302,7 @@
"release:contributors": "node scripts/release/gen-contributors.mjs",
"release:uncovered": "node scripts/release/list-uncovered-commits.mjs",
"release:reconcile": "node scripts/release/reconcile-changelog.mjs",
"release:dry-run-lts-cut": "node scripts/release/dry-run-lts-cut.mjs",
"labels:sync": "node scripts/release/sync-labels.mjs",
"test:coverage:runner": "node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,chatcore,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
"test:unit:serial": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/unit/serial/**/*.test.ts\"",

View file

@ -0,0 +1,181 @@
#!/usr/bin/env node
/**
* Preview-artifact identity (#8084 slice (a), rail 3.8.58).
*
* The `preview-artifact.yml` workflow builds a PR head ONCE, validates that exact
* build (check:pack-artifact + check:pack-boot) and uploads the tarball together
* with this identity. Promotion then consumes the uploaded bytes — never a second
* source build — and anyone holding the identity can tell whether an existing
* artifact is reusable for a given (head, base, lockfile, platform/ABI, node) tuple.
*
* Output (JSON on stdout, optionally also written to --out):
*
* { headSha, baseSha, lockfileHash, platform, arch, nodeAbi, nodeVersion,
* bundler, buildPolicyVersion, identityKey, artifact?: { file, sha256 } }
*
* `identityKey` is a sha256 over the SEMANTIC inputs only (everything above except
* `artifact`), so it works as a reuse/cache key: same key = same build inputs.
* The tarball digest is recorded for promotion integrity but does not feed the key.
*
* Bump BUILD_POLICY_VERSION whenever the build or validation steps of
* preview-artifact.yml change in a way that makes older artifacts non-comparable.
*
* CLI (values arrive as argv — never interpolated into a shell script):
*
* node scripts/release/artifact-identity.mjs --head <sha> --base <sha> \
* [--lockfile package-lock.json] [--tarball <file.tgz>] [--out identity.json]
*/
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const BUILD_POLICY_VERSION = "preview-artifact/1";
const FULL_SHA_RE = /^[0-9a-f]{40}$/;
const SHA256_RE = /^[0-9a-f]{64}$/;
function sha256(content) {
return createHash("sha256").update(content).digest("hex");
}
/** sha256 hex of the lockfile bytes — the dependency fingerprint. */
export function hashLockfile(content) {
return sha256(content);
}
/** Bundler `npm run build` uses (scripts/build/build-next-isolated.mjs). */
export function resolveBundler(env = process.env) {
return env.OMNIROUTE_USE_TURBOPACK === "0" ? "webpack" : "turbopack";
}
function requireSha(name, value) {
const sha = String(value ?? "")
.trim()
.toLowerCase();
if (!FULL_SHA_RE.test(sha)) {
throw new Error(`${name} must be a full 40-hex commit SHA, got ${JSON.stringify(value)}`);
}
return sha;
}
function requireText(name, value) {
const text = String(value ?? "").trim();
if (!text) throw new Error(`${name} is required`);
return text;
}
/** sha256 over the canonical JSON of the semantic identity fields. */
export function identityKey(identity) {
const semantic = {
headSha: identity.headSha,
baseSha: identity.baseSha,
lockfileHash: identity.lockfileHash,
platform: identity.platform,
arch: identity.arch,
nodeAbi: identity.nodeAbi,
nodeVersion: identity.nodeVersion,
bundler: identity.bundler,
buildPolicyVersion: identity.buildPolicyVersion,
};
return sha256(JSON.stringify(semantic));
}
/**
* Pure: assemble and validate the identity. Fails closed on any missing input —
* an artifact that cannot be identified cannot be promoted.
*/
export function buildArtifactIdentity({
headSha,
baseSha,
lockfileContent,
platform,
arch,
nodeAbi,
nodeVersion,
bundler,
tarball,
}) {
if (!lockfileContent || lockfileContent.length === 0)
throw new Error("lockfile content is required (package-lock.json)");
const abi = requireText("nodeAbi", nodeAbi);
if (!/^\d+$/.test(abi)) throw new Error(`nodeAbi must be numeric, got ${JSON.stringify(abi)}`);
const identity = {
headSha: requireSha("headSha", headSha),
baseSha: requireSha("baseSha", baseSha),
lockfileHash: hashLockfile(lockfileContent),
platform: requireText("platform", platform),
arch: requireText("arch", arch),
nodeAbi: abi,
nodeVersion: requireText("nodeVersion", nodeVersion),
bundler: requireText("bundler", bundler),
buildPolicyVersion: BUILD_POLICY_VERSION,
};
identity.identityKey = identityKey(identity);
if (tarball) {
const digest = String(tarball.sha256 ?? "").toLowerCase();
if (!SHA256_RE.test(digest)) {
throw new Error(`tarball sha256 must be 64 hex chars, got ${JSON.stringify(tarball.sha256)}`);
}
identity.artifact = { file: requireText("tarball file", tarball.file), sha256: digest };
}
return identity;
}
const FLAGS = new Set(["--head", "--base", "--lockfile", "--tarball", "--out"]);
export function parseIdentityArgs(argv) {
const values = {};
for (let i = 0; i < argv.length; i += 1) {
const flag = argv[i];
if (!FLAGS.has(flag)) throw new Error(`Unknown flag ${JSON.stringify(flag)}`);
const value = argv[i + 1];
if (value === undefined || value.startsWith("--")) throw new Error(`${flag} needs a value`);
values[flag.slice(2)] = value;
i += 1;
}
if (!values.base) throw new Error("--base <sha> is required (the PR base / target SHA)");
return {
head: values.head,
base: values.base,
lockfile: values.lockfile ?? "package-lock.json",
tarball: values.tarball,
out: values.out,
};
}
function main(argv) {
const args = parseIdentityArgs(argv);
const head = args.head ?? execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
const tarball = args.tarball
? { file: path.basename(args.tarball), sha256: sha256(readFileSync(args.tarball)) }
: undefined;
const identity = buildArtifactIdentity({
headSha: head,
baseSha: args.base,
lockfileContent: readFileSync(args.lockfile),
platform: process.platform,
arch: process.arch,
nodeAbi: process.versions.modules,
nodeVersion: process.versions.node,
bundler: resolveBundler(process.env),
tarball,
});
const json = `${JSON.stringify(identity, null, 2)}\n`;
if (args.out) writeFileSync(args.out, json);
process.stdout.write(json);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
try {
main(process.argv.slice(2));
} catch (error) {
process.stderr.write(
`artifact-identity: ${error instanceof Error ? error.message : String(error)}\n`
);
process.exit(1);
}
}

View file

@ -0,0 +1,854 @@
#!/usr/bin/env node
/**
* Dry-run (and fork-only rehearsal) of the 3.9.0 LTS cut — rail 3.8.58.
*
* The cut (ROADMAP Phase 3, docs/ops/RELEASE_STRATEGY.md): after v3.8.59 the
* next version is 3.9.0; its tip becomes `stable/v3` (the LTS line, npm
* `latest`) and `develop` opens v4 with a 4.0.0 bump (npm `nightly`).
*
* Default `--dry-run` prints and VALIDATES the whole sequence and executes
* nothing (read-only git/gh queries only):
*
* 0. preconditions — source resolves, previous tag (v3.8.59) exists,
* package.json at the source is the target version (3.9.0), the
* release-freeze of the cut is open, the release branch is green (no open
* "Release branch not green: <branch>" issue), stable/v3 + develop absent
* 1. stable/v3 from the source tip
* 2. develop = source tip + a 4.0.0 version bump commit
* 3. dormant workflows — which `on:`/`if:` conditions turn true after the cut
* 4. expected npm dist-tags (latest → 3.9.0, next/nightly empty)
* 5. rollback (delete both branches, restore `latest`)
*
* `--execute --remote <name>` runs steps 1–2 (or `--rollback`) against a FORK
* remote only: never `origin`, never a remote whose URL is the canonical
* repository, and every step asks for confirmation on the terminal. npm
* dist-tags are never changed by this script (printed for the captain).
*
* Every external command goes through execFile with an argv array — no shell.
*
* Usage:
* npm run release:dry-run-lts-cut
* npm run release:dry-run-lts-cut -- --target-version 3.8.58 --previous-tag v3.8.57 \
* --advisory freeze,base-green # 3.8.58 rehearsal on the current tip
* node scripts/release/dry-run-lts-cut.mjs --execute --remote rehearsal
* node scripts/release/dry-run-lts-cut.mjs --execute --rollback --remote rehearsal
*/
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { createInterface } from "node:readline/promises";
import { fileURLToPath } from "node:url";
import { load as yamlLoad } from "js-yaml";
import { resolveDistTag } from "./dist-tag.mjs";
export const CANONICAL_REPO = "diegosouzapw/OmniRoute";
const PACKAGE_NAME = "omniroute";
export const DEFAULTS = Object.freeze({
targetVersion: "3.9.0",
previousTag: "v3.8.59",
developVersion: "4.0.0",
stableBranch: "stable/v3",
developBranch: "develop",
});
const PRECONDITION_IDS = [
"source",
"tag",
"version",
"freeze",
"base-green",
"merge-queue",
"release-ruleset",
"branches-absent",
];
/** Files bumped on `develop` — the same set the cycle-open commit bumps. */
const BUMP_FILES = Object.freeze([
"package.json",
"open-sse/package.json",
"electron/package.json",
"package-lock.json",
"docs/openapi.yaml",
]);
/** Workflows that stay dormant until the cut (docs/ops/RELEASE_STRATEGY.md → branches/channels). */
const DORMANT_WORKFLOWS = Object.freeze([
"forward-port.yml",
"validate-stable-pr.yml",
"nightly-v4-build.yml",
]);
class UsageError extends Error {}
// ── arguments ───────────────────────────────────────────────────────────────
const VALUE_FLAGS = new Set([
"--remote",
"--from",
"--repo",
"--target-version",
"--previous-tag",
"--develop-version",
"--source-branch",
"--advisory",
]);
const BOOL_FLAGS = new Set(["--dry-run", "--execute", "--rollback"]);
export function parseCutArgs(argv) {
const values = {};
const bools = new Set();
for (let i = 0; i < argv.length; i += 1) {
const flag = argv[i];
if (BOOL_FLAGS.has(flag)) {
bools.add(flag);
continue;
}
if (!VALUE_FLAGS.has(flag)) throw new UsageError(`Unknown flag ${JSON.stringify(flag)}`);
const value = argv[i + 1];
if (value === undefined || value.startsWith("--")) {
throw new UsageError(`${flag} needs a value`);
}
values[flag.slice(2)] = value;
i += 1;
}
if (bools.has("--execute") && bools.has("--dry-run")) {
throw new UsageError("--dry-run and --execute are mutually exclusive");
}
const mode = bools.has("--execute") ? "execute" : "dry-run";
if (bools.has("--rollback") && mode !== "execute") {
throw new UsageError("--rollback requires --execute (the dry-run always prints the rollback)");
}
if (mode === "execute") {
if (!values.remote) {
throw new UsageError(
"--execute requires --remote <fork-remote> (the rehearsal runs on a fork)"
);
}
if (values.remote === "origin") {
throw new UsageError("--execute targets a fork remote, never origin");
}
}
const advisory = new Set(
(values.advisory ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
);
for (const id of advisory) {
if (!PRECONDITION_IDS.includes(id)) {
throw new UsageError(
`Unknown precondition ${JSON.stringify(id)} (known: ${PRECONDITION_IDS.join(", ")})`
);
}
}
const targetVersion = values["target-version"] ?? DEFAULTS.targetVersion;
return {
mode,
rollback: bools.has("--rollback"),
remote: values.remote ?? "origin",
from: values.from ?? "HEAD",
repo: values.repo ?? CANONICAL_REPO,
targetVersion,
previousTag: values["previous-tag"] ?? DEFAULTS.previousTag,
developVersion: values["develop-version"] ?? DEFAULTS.developVersion,
sourceBranch: values["source-branch"] ?? `release/v${targetVersion}`,
stableBranch: DEFAULTS.stableBranch,
developBranch: DEFAULTS.developBranch,
advisory,
};
}
/** True when a git remote URL (https / ssh / scp-like, with or without .git) is `repo`. */
export function isCanonicalRemoteUrl(url, repo = CANONICAL_REPO) {
const match = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(String(url ?? ""));
if (!match) return false;
return `${match[1]}/${match[2]}`.toLowerCase() === repo.toLowerCase();
}
// ── preconditions ───────────────────────────────────────────────────────────
/**
* Pure: is the Mergify `release` queue (rail gate G11) configured? GitHub's native
* merge queue was rejected in v3.8.49 (no branch wildcards on a personal-account
* repo — see the .mergify.yml header), so G11 is the Mergify queue: a
* `queue_rules` entry named `release` covering `release/vX.Y.Z`, entered through
* the `queue` label, with a `checks_timeout` so a hung check cannot park the queue.
*/
export function evaluateMergifyQueue(yamlText) {
if (yamlText == null) return { ok: false, problems: [".mergify.yml missing at the source"] };
let doc;
try {
doc = yamlLoad(yamlText) ?? {};
} catch {
return { ok: false, problems: [".mergify.yml is not valid YAML"] };
}
const rules = Array.isArray(doc.queue_rules) ? doc.queue_rules : [];
const release = rules.find((rule) => rule?.name === "release");
if (!release) return { ok: false, problems: ["no queue_rules entry named `release`"] };
const problems = [];
const conditions = (release.queue_conditions ?? []).map((c) => String(c).replace(/\s+/g, ""));
if (!conditions.some((c) => c.startsWith("base~=") && c.includes("release/"))) {
problems.push("queue `release` does not target release/* branches");
}
const autoMerge = (doc.merge_protections_settings?.auto_merge_conditions ?? []).map((c) =>
String(c).replace(/\s+/g, "")
);
if (!conditions.includes("label=queue") && !autoMerge.includes("label=queue")) {
problems.push("queue `release` is not entered through the `queue` label");
}
if (release.checks_timeout == null || String(release.checks_timeout).trim() === "") {
problems.push("queue `release` has no checks_timeout");
}
return { ok: problems.length === 0, problems };
}
function issueList(issues) {
return issues.map((i) => `#${i.number}`).join(", ");
}
/**
* Pure. `facts` come from gatherFacts(); null means "could not be determined",
* which fails the check (fail closed).
*/
export function evaluatePreconditions(facts, opts) {
const check = (id, label, expected, ok, actual) => ({
id,
label,
expected,
actual,
ok,
blocking: !opts.advisory.has(id),
});
const freeze = facts.freezeIssues;
const red = facts.baseRedIssues;
const queue = evaluateMergifyQueue(facts.mergifyConfig ?? null);
const rulesetRules = facts.releaseRulesetRules;
const missingRules = Array.isArray(rulesetRules)
? ["deletion", "non_fast_forward"].filter((r) => !rulesetRules.includes(r))
: null;
// No open base-red issue is only evidence of green when the branch exists: the
// nightly observer never files an issue for a branch it cannot see.
const branchMissing = facts.sourceBranchExists === false;
const existing = Object.entries(facts.remoteBranches ?? {})
.filter(([, sha]) => sha)
.map(([name]) => name);
return [
check(
"source",
`cut source ${opts.from} resolves to a commit`,
"a commit SHA",
Boolean(facts.sourceSha),
facts.sourceSha ?? `${opts.from} does not resolve to a commit`
),
check(
"tag",
`previous release tag ${opts.previousTag} exists`,
`${opts.previousTag} present`,
facts.previousTagExists === true,
facts.previousTagExists === null
? "unknown (tag lookup failed)"
: facts.previousTagExists
? "present"
: "missing"
),
check(
"version",
`package.json at the source is ${opts.targetVersion}`,
opts.targetVersion,
facts.sourceVersion === opts.targetVersion,
facts.sourceVersion ?? "unknown (package.json unreadable at the source)"
),
check(
"freeze",
"the cut's release-freeze is open (captain owns the branch while it is cut)",
"≥ 1 open `release-freeze` issue",
Array.isArray(freeze) && freeze.length > 0,
freeze === null
? "unknown (gh query failed)"
: freeze.length
? issueList(freeze)
: "none open"
),
{
...check(
"base-green",
`${opts.sourceBranch} is green (no open "Release branch not green" issue)`,
"no open base-red issue on an existing branch",
!branchMissing && facts.sourceBranchExists !== null && Array.isArray(red) && !red.length,
branchMissing
? `unknown — ${opts.sourceBranch} does not exist on origin (not verifiable)`
: facts.sourceBranchExists === null
? "unknown (ls-remote failed)"
: red === null
? "unknown (gh query failed)"
: red.length
? `red: ${issueList(red)}`
: "green"
),
verifiable: !branchMissing && facts.sourceBranchExists !== null && red !== null,
},
check(
"merge-queue",
"G11: Mergify queue `release` active (queue_rules, checks_timeout, `queue` label)",
"queue_rules[release] with checks_timeout + label `queue` exists",
queue.ok && facts.queueLabelExists === true,
[
...queue.problems,
facts.queueLabelExists === null
? "label lookup failed"
: facts.queueLabelExists
? null
: "label `queue` missing",
]
.filter(Boolean)
.join("; ") || "configured"
),
check(
"release-ruleset",
"release/* ruleset blocks deletion and force-push",
"rules deletion + non_fast_forward",
Array.isArray(missingRules) && missingRules.length === 0,
missingRules === null
? "unknown (ruleset lookup failed)"
: missingRules.length
? `missing: ${missingRules.join(", ")}`
: "deletion + non_fast_forward"
),
check(
"branches-absent",
`${opts.stableBranch} and ${opts.developBranch} do not exist yet on ${opts.remote}`,
"both absent",
facts.remoteBranches !== null && existing.length === 0,
facts.remoteBranches === null
? "unknown (ls-remote failed)"
: existing.length
? `already exist: ${existing.join(", ")}`
: "both absent"
),
];
}
// ── develop bump (pure text edits, verified by parsing) ─────────────────────
/** Bump the top-level `"version"` of a package.json, touching that one line only. */
export function bumpManifest(text, version) {
const re = /^( {2}"version":\s*")[^"]*(")/m;
if (!re.test(text)) throw new Error("no top-level version field to bump");
const out = text.replace(re, `$1${version}$2`);
if (JSON.parse(out).version !== version) throw new Error("version bump did not land");
return out;
}
/** Bump the lockfile root, packages[""] and the open-sse workspace entry. */
export function bumpLockfile(text, version) {
let out = text.replace(/^( {2}"version":\s*")[^"]*(")/m, `$1${version}$2`);
out = out.replace(/^( {4}"": \{\n(?: {6}.*\n)*? {6}"version":\s*")[^"]*(")/m, `$1${version}$2`);
out = out.replace(
/^( {4}"open-sse": \{\n {6}"name": "@omniroute\/open-sse",\n {6}"version":\s*")[^"]*(")/m,
`$1${version}$2`
);
const lock = JSON.parse(out);
if (
lock.version !== version ||
lock.packages?.[""]?.version !== version ||
lock.packages?.["open-sse"]?.version !== version
) {
throw new Error('lockfile bump did not land on root, packages[""] and open-sse');
}
return out;
}
/** Bump `info.version` of the OpenAPI document. */
export function bumpOpenApi(text, version) {
const re = /^(info:\n(?:[ ].*\n)*? {2}version: )([^\n]+)/m;
if (!re.test(text)) throw new Error("no info.version to bump in openapi.yaml");
return text.replace(re, `$1${version}`);
}
const BUMPERS = Object.freeze({
"package.json": bumpManifest,
"open-sse/package.json": bumpManifest,
"electron/package.json": bumpManifest,
"package-lock.json": bumpLockfile,
"docs/openapi.yaml": bumpOpenApi,
});
// ── plan ────────────────────────────────────────────────────────────────────
export function buildCutPlan(opts, facts) {
const sha = facts.sourceSha ?? "<source-sha>";
const remote = opts.remote;
const stableRef = `refs/heads/${opts.stableBranch}`;
const developRef = `refs/heads/${opts.developBranch}`;
const previousVersion = opts.previousTag.replace(/^v/, "");
return {
remote,
executes: opts.mode === "execute",
sourceSha: sha,
bumpFiles: [...BUMP_FILES],
steps: [
{
id: "create-stable",
title: `Create ${opts.stableBranch} from the cut tip`,
preview: [["git", "push", remote, `${sha}:${stableRef}`]],
},
{
id: "create-develop",
title: `Create ${opts.developBranch} with the ${opts.developVersion} bump`,
preview: [
[
"git",
"commit-tree",
"<tree with the bump>",
"-p",
sha,
"-m",
developCommitMessage(opts),
],
["git", "push", remote, `<bump-commit>:${developRef}`],
],
},
],
rollback: [
["git", "push", remote, "--delete", stableRef, developRef],
["npm", "dist-tag", "add", `${PACKAGE_NAME}@${previousVersion}`, "latest"],
],
};
}
function developCommitMessage(opts) {
return `chore(release): open the v4 development line (${opts.developVersion})`;
}
// ── dormant workflows ───────────────────────────────────────────────────────
function triggerSummary(on) {
if (!on || typeof on !== "object") return [];
return Object.entries(on).map(([event, cfg]) => {
const branches = cfg && typeof cfg === "object" ? cfg.branches : undefined;
return Array.isArray(branches) ? `${event} → ${branches.join(", ")}` : event;
});
}
/** Pure: classify what a dormant workflow does once stable/v3 + develop exist. */
export function analyzeDormantWorkflow(file, yamlText) {
if (yamlText == null) {
return {
file,
present: false,
triggers: [],
activates: [],
stillGated: [],
forkCaveats: [],
note: "MISSING at the cut source — the LTS line needs it before the cut",
};
}
const doc = yamlLoad(yamlText) ?? {};
const triggers = triggerSummary(doc.on);
const activates = [];
const stillGated = [];
const forkCaveats = [];
for (const trigger of triggers) {
if (/stable\/v3|develop/.test(trigger)) activates.push(`trigger ${trigger} starts firing`);
}
for (const [jobId, job] of Object.entries(doc.jobs ?? {})) {
const cond = typeof job?.if === "string" ? job.if : "";
if (/stable\/v3|develop/.test(cond)) activates.push(`job ${jobId}: \`${cond}\` → true`);
for (const m of cond.matchAll(/vars\.([A-Z0-9_]+)/g)) {
stillGated.push(`job ${jobId}: needs repository variable vars.${m[1]} (owner flips it)`);
}
const repoGuard = /github\.repository\s*==\s*'([^']+)'/.exec(cond);
if (repoGuard && /github\.event_name\s*!=\s*'schedule'\s*\|\|/.test(cond)) {
forkCaveats.push(
`job ${jobId}: scheduled runs only on ${repoGuard[1]} — dispatch it by hand in a fork rehearsal`
);
} else if (repoGuard) {
forkCaveats.push(`job ${jobId}: pinned to ${repoGuard[1]} — false in a fork rehearsal`);
}
for (const step of job?.steps ?? []) {
if (typeof step?.run === "string" && /ls-remote[^\n]*--heads[^\n]*develop/.test(step.run)) {
activates.push(`job ${jobId}: runtime "develop exists" check → true`);
}
}
}
return { file, present: true, triggers, activates, stillGated, forkCaveats };
}
// ── dist-tags ───────────────────────────────────────────────────────────────
/**
* Expected post-cut dist-tags, cross-checked against the channel resolver that
* npm-publish.yml uses (scripts/release/dist-tag.mjs#resolveDistTag).
*/
export function expectedDistTags(opts, resolver) {
const major = Number(opts.targetVersion.split(".")[0]);
const probes = [
[opts.targetVersion, "latest"],
[`${opts.developVersion}-nightly.20261010.abcdef0`, "nightly"],
[`${opts.developVersion}-rc.1`, "next"],
];
const checks = probes.map(([version, want]) => {
let got;
try {
got = resolver.resolveDistTag(version, { latestMajor: major });
} catch (error) {
got = `error: ${error instanceof Error ? error.message : String(error)}`;
}
return { version, expected: want, actual: got, ok: got === want };
});
return {
source: resolver.source,
expected: { latest: opts.targetVersion, next: null, nightly: null },
checks,
};
}
// ── report ──────────────────────────────────────────────────────────────────
const fmt = (argv) => argv.map((a) => (/[\s"'()]/.test(a) ? JSON.stringify(a) : a)).join(" ");
export function renderReport({ opts, checks, plan, workflows, distTags }) {
const lines = [];
const header =
opts.mode === "execute"
? `LTS cut — EXECUTE on fork remote "${opts.remote}"${opts.rollback ? " (ROLLBACK)" : ""}`
: "LTS cut — DRY-RUN — nothing was executed";
lines.push(header, "");
lines.push(
`Cut: ${opts.targetVersion} from ${opts.from} (${plan.sourceSha}) · previous ${opts.previousTag} · develop ${opts.developVersion}`,
""
);
lines.push("Preconditions");
for (const c of checks) {
const mark = c.ok ? "✓" : c.verifiable === false ? "?" : c.blocking ? "✗" : "!";
lines.push(
` ${mark} ${c.id} — ${c.label}: ${c.actual}${c.ok ? "" : ` (expected ${c.expected})`}`
);
}
lines.push("");
lines.push(`1. Create ${opts.stableBranch}`);
for (const cmd of plan.steps[0].preview) lines.push(` $ ${fmt(cmd)}`);
lines.push(
`2. Create ${opts.developBranch} (${opts.developVersion} bump, built with git plumbing)`
);
lines.push(` bumps: ${plan.bumpFiles.join(", ")}`);
for (const cmd of plan.steps[1].preview) lines.push(` $ ${fmt(cmd)}`);
lines.push(
" then: open the [4.0.0] CHANGELOG section + i18n mirrors on develop (cycle-open flow) before its first PR"
);
lines.push("3. Dormant workflows after the cut");
for (const wf of workflows) {
if (!wf.present) {
lines.push(` - ${wf.file}: ${wf.note}`);
continue;
}
lines.push(` - ${wf.file} (triggers: ${wf.triggers.join("; ") || "none"})`);
for (const l of wf.activates) lines.push(` on: ${l}`);
for (const l of wf.stillGated) lines.push(` gated: ${l}`);
for (const l of wf.forkCaveats) lines.push(` fork: ${l}`);
}
lines.push(`4. npm dist-tags (resolver: ${distTags.source})`);
const e = distTags.expected;
lines.push(` expected: latest → ${e.latest} · next → (empty) · nightly → (empty)`);
for (const c of distTags.checks) {
lines.push(
` ${c.ok ? "✓" : "✗"} ${c.version} resolves to ${c.actual} (expected ${c.expected})`
);
}
lines.push(
` verify: $ npm view ${PACKAGE_NAME} dist-tags --json (never changed by this script)`
);
lines.push("5. Rollback");
for (const cmd of plan.rollback) lines.push(` $ ${fmt(cmd)}`);
return lines.join("\n");
}
// ── execution (fork only) ───────────────────────────────────────────────────
/**
* Run the mutating steps against the fork remote. `io.git(args)` runs git with
* an argv array; `confirm(question)` must resolve true for each step.
*/
export async function executeCut(plan, opts, io, confirm) {
if (opts.mode !== "execute" || opts.remote === "origin") {
throw new Error("executeCut only runs in --execute mode against a fork remote");
}
const url = io.remoteUrl(opts.remote);
if (!url) throw new Error(`git remote ${JSON.stringify(opts.remote)} does not exist`);
if (isCanonicalRemoteUrl(url, opts.repo)) {
throw new Error(
`remote ${opts.remote} points at the canonical repository (${url}) — rehearse on a fork`
);
}
if (opts.rollback) {
const [deleteCmd] = plan.rollback;
if (
!(await confirm(`Delete ${opts.stableBranch} and ${opts.developBranch} on ${opts.remote}?`))
) {
return { aborted: true };
}
io.git(deleteCmd.slice(1));
return { aborted: false };
}
if (!(await confirm(`Push ${plan.sourceSha} as ${opts.stableBranch} to ${opts.remote}?`))) {
return { aborted: true };
}
io.git(["push", opts.remote, `${plan.sourceSha}:refs/heads/${opts.stableBranch}`]);
if (
!(await confirm(
`Create the ${opts.developVersion} bump commit and push it as ${opts.developBranch} to ${opts.remote}?`
))
) {
return { aborted: true };
}
const commit = io.createDevelopCommit(
plan.sourceSha,
opts.developVersion,
plan.bumpFiles,
developCommitMessage(opts)
);
io.git(["push", opts.remote, `${commit}:refs/heads/${opts.developBranch}`]);
return { aborted: false, developCommit: commit };
}
// ── real IO ─────────────────────────────────────────────────────────────────
function tryExec(cmd, args, options = {}) {
try {
return execFileSync(cmd, args, {
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
maxBuffer: 64 * 1024 * 1024,
...options,
}).trim();
} catch {
return null;
}
}
function ghIssues(repo, args) {
const out = tryExec("gh", [
"issue",
"list",
"--repo",
repo,
"--state",
"open",
...args,
"--json",
"number,title",
]);
if (out === null) return null;
try {
return JSON.parse(out);
} catch {
return null;
}
}
function ghJson(args) {
const out = tryExec("gh", args);
if (out === null) return null;
try {
return JSON.parse(out);
} catch {
return null;
}
}
function labelExists(repo, name) {
const labels = ghJson([
"label",
"list",
"--repo",
repo,
"--search",
name,
"--limit",
"100",
"--json",
"name",
]);
return Array.isArray(labels) ? labels.some((l) => l.name === name) : null;
}
/** Rule types of the ruleset whose include covers `refs/heads/release/*`. */
function releaseRulesetRules(repo) {
const list = ghJson(["api", `repos/${repo}/rulesets`]);
if (!Array.isArray(list)) return null;
for (const { id } of list) {
const ruleset = ghJson(["api", `repos/${repo}/rulesets/${id}`]);
const include = ruleset?.conditions?.ref_name?.include ?? [];
if (include.includes("refs/heads/release/*")) {
return (ruleset.rules ?? []).map((r) => r.type);
}
}
return [];
}
export function createRealIo(cwd) {
const gitRaw = (args, options = {}) =>
execFileSync("git", args, {
cwd,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
...options,
});
const git = (args, options = {}) => gitRaw(args, options).trim();
return {
git,
remoteUrl: (name) => tryExec("git", ["remote", "get-url", name], { cwd }),
async gatherFacts(opts) {
const sourceSha = tryExec("git", ["rev-parse", "--verify", `${opts.from}^{commit}`], { cwd });
let sourceVersion = null;
if (sourceSha) {
const pkg = tryExec("git", ["show", `${sourceSha}:package.json`], { cwd });
try {
sourceVersion = pkg ? JSON.parse(pkg).version : null;
} catch {
sourceVersion = null;
}
}
const branchExists = (branch) => {
const out = tryExec("git", ["ls-remote", "--heads", "origin", branch], { cwd });
return out === null ? null : out.length > 0;
};
// Tags are canonical facts: read them from origin (read-only).
const tagOut = tryExec(
"git",
["ls-remote", "--tags", "origin", `refs/tags/${opts.previousTag}`],
{ cwd }
);
const previousTagExists = tagOut === null ? null : tagOut.length > 0;
const headsOut = tryExec(
"git",
["ls-remote", "--heads", opts.remote, opts.stableBranch, opts.developBranch],
{ cwd }
);
let remoteBranches = null;
if (headsOut !== null) {
remoteBranches = { [opts.stableBranch]: null, [opts.developBranch]: null };
for (const line of headsOut.split("\n").filter(Boolean)) {
const [sha, ref] = line.split(/\s+/);
const name = ref.replace(/^refs\/heads\//, "");
if (name in remoteBranches) remoteBranches[name] = sha;
}
}
return {
sourceSha,
sourceVersion,
previousTagExists,
sourceBranchExists: branchExists(opts.sourceBranch),
mergifyConfig: sourceSha
? tryExec("git", ["show", `${sourceSha}:.mergify.yml`], { cwd })
: null,
queueLabelExists: labelExists(opts.repo, "queue"),
releaseRulesetRules: releaseRulesetRules(opts.repo),
freezeIssues: ghIssues(opts.repo, ["--label", "release-freeze"]),
baseRedIssues: ghIssues(opts.repo, [
"--search",
`"Release branch not green: ${opts.sourceBranch}" in:title`,
]),
remoteBranches,
};
},
readWorkflowAt(sha, file) {
if (!sha) return null;
return tryExec("git", ["show", `${sha}:.github/workflows/${file}`], { cwd });
},
async loadDistTagResolver() {
return { source: "scripts/release/dist-tag.mjs", resolveDistTag };
},
createDevelopCommit(sourceSha, version, files, message) {
const tmp = mkdtempSync(path.join(os.tmpdir(), "lts-cut-index-"));
const env = { ...process.env, GIT_INDEX_FILE: path.join(tmp, "index") };
try {
git(["read-tree", sourceSha], { env });
for (const file of files) {
const bumped = BUMPERS[file](gitRaw(["show", `${sourceSha}:${file}`]), version);
const blob = git(["hash-object", "-w", "--stdin"], { input: bumped });
git(["update-index", "--cacheinfo", `100644,${blob},${file}`], { env });
}
const tree = git(["write-tree"], { env });
return git(["commit-tree", tree, "-p", sourceSha, "-m", message]);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
},
async confirm(question) {
if (!process.stdin.isTTY) {
throw new Error("--execute needs an interactive terminal to confirm each step");
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
const answer = await rl.question(`${question} [y/N] `);
return /^y(es)?$/i.test(answer.trim());
} finally {
rl.close();
}
},
};
}
/** Orchestration with injected IO. Returns the process exit code. */
export async function runCut(opts, io, log = (line) => process.stdout.write(`${line}\n`)) {
const facts = await io.gatherFacts(opts);
const checks = evaluatePreconditions(facts, opts);
const plan = buildCutPlan(opts, facts);
const readWorkflow = io.readWorkflow ?? ((file) => io.readWorkflowAt(facts.sourceSha, file));
const workflows = DORMANT_WORKFLOWS.map((file) =>
analyzeDormantWorkflow(file, readWorkflow(file))
);
const distTags = expectedDistTags(opts, await io.loadDistTagResolver());
log(renderReport({ opts, checks, plan, workflows, distTags }));
const blockingFailed = checks.some((c) => c.blocking && !c.ok);
const distTagFailed = distTags.checks.some((c) => !c.ok) || workflows.some((wf) => !wf.present);
if (opts.mode === "dry-run") {
log("");
log(blockingFailed || distTagFailed ? "RESULT: NOT READY" : "RESULT: READY");
return blockingFailed || distTagFailed ? 1 : 0;
}
if (!opts.rollback && (blockingFailed || distTagFailed)) {
log("");
log("Refusing to execute: a blocking precondition failed (see ✗ above).");
return 1;
}
const result = await executeCut(plan, opts, io, (q) => io.confirm(q));
log("");
if (result.developCommit) {
log(`stable/v3 → ${plan.sourceSha}`);
log(`develop → ${result.developCommit} (bump commit; reusable for the real cut)`);
}
log(result.aborted ? "ABORTED at a declined step — nothing after it ran." : "DONE.");
return result.aborted ? 1 : 0;
}
async function main(argv) {
let opts;
try {
opts = parseCutArgs(argv);
} catch (error) {
process.stderr.write(`dry-run-lts-cut: ${error.message}\n`);
return 2;
}
const root = tryExec("git", ["rev-parse", "--show-toplevel"]) ?? process.cwd();
return runCut(opts, createRealIo(root));
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
main(process.argv.slice(2)).then(
(code) => process.exit(code),
(error) => {
process.stderr.write(
`dry-run-lts-cut: ${error instanceof Error ? error.message : String(error)}\n`
);
process.exit(1);
}
);
}

View file

@ -0,0 +1,201 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
BUILD_POLICY_VERSION,
buildArtifactIdentity,
hashLockfile,
identityKey,
parseIdentityArgs,
resolveBundler,
} from "../../scripts/release/artifact-identity.mjs";
// Preview-artifact identity (#8084 slice (a)): the build-once workflow binds the
// artifact to head SHA + base SHA + lockfile fingerprint + platform/ABI + node,
// so the SAME validated tarball is the one that gets promoted — never a rebuild.
const SCRIPT = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"../../scripts/release/artifact-identity.mjs"
);
const HEAD = "a".repeat(40);
const BASE = "b".repeat(40);
const LOCK = '{"name":"omniroute","lockfileVersion":3}\n';
function baseInput(overrides: Record<string, unknown> = {}) {
return {
headSha: HEAD,
baseSha: BASE,
lockfileContent: LOCK,
platform: "linux",
arch: "x64",
nodeAbi: "137",
nodeVersion: "24.11.0",
bundler: "turbopack",
...overrides,
};
}
test("hashLockfile is a sha256 hex of the exact bytes", () => {
const digest = hashLockfile(LOCK);
assert.match(digest, /^[0-9a-f]{64}$/);
assert.equal(digest, hashLockfile(LOCK), "deterministic");
assert.notEqual(digest, hashLockfile(LOCK.replace("3", "2")), "any byte change moves the hash");
});
test("buildArtifactIdentity emits every field the workflow contract requires", () => {
const identity = buildArtifactIdentity(baseInput());
for (const key of [
"headSha",
"baseSha",
"lockfileHash",
"platform",
"arch",
"nodeAbi",
"buildPolicyVersion",
]) {
assert.ok(key in identity, `missing ${key}`);
assert.ok(String(identity[key]).length > 0, `${key} must not be empty`);
}
assert.equal(identity.headSha, HEAD);
assert.equal(identity.baseSha, BASE);
assert.equal(identity.lockfileHash, hashLockfile(LOCK));
assert.equal(identity.buildPolicyVersion, BUILD_POLICY_VERSION);
assert.equal(identity.nodeVersion, "24.11.0");
assert.equal(identity.bundler, "turbopack");
assert.match(identity.identityKey, /^[0-9a-f]{64}$/);
});
test("SHAs are normalized to lowercase and must be full 40-hex commits", () => {
const identity = buildArtifactIdentity(baseInput({ headSha: "A".repeat(40) }));
assert.equal(identity.headSha, "a".repeat(40));
assert.throws(() => buildArtifactIdentity(baseInput({ headSha: "abc1234" })), /headSha/);
assert.throws(() => buildArtifactIdentity(baseInput({ baseSha: "" })), /baseSha/);
assert.throws(() => buildArtifactIdentity(baseInput({ baseSha: "z".repeat(40) })), /baseSha/);
});
test("missing lockfile, platform, arch or ABI fail closed", () => {
assert.throws(() => buildArtifactIdentity(baseInput({ lockfileContent: "" })), /lockfile/);
assert.throws(() => buildArtifactIdentity(baseInput({ platform: "" })), /platform/);
assert.throws(() => buildArtifactIdentity(baseInput({ arch: undefined })), /arch/);
assert.throws(() => buildArtifactIdentity(baseInput({ nodeAbi: "abc" })), /nodeAbi/);
});
test("identityKey is stable for the same semantic inputs and moves with each of them", () => {
const key = identityKey(buildArtifactIdentity(baseInput()));
assert.equal(key, identityKey(buildArtifactIdentity(baseInput())));
const variants: Record<string, unknown>[] = [
{ headSha: "c".repeat(40) },
{ baseSha: "d".repeat(40) },
{ lockfileContent: LOCK + " " },
{ platform: "darwin" },
{ arch: "arm64" },
{ nodeAbi: "127" },
{ bundler: "webpack" },
];
for (const variant of variants) {
assert.notEqual(
identityKey(buildArtifactIdentity(baseInput(variant))),
key,
`identityKey must change for ${Object.keys(variant)[0]}`
);
}
});
test("the tarball digest is recorded but does NOT feed the reuse key", () => {
const plain = buildArtifactIdentity(baseInput());
const withTarball = buildArtifactIdentity(
baseInput({ tarball: { file: "omniroute-3.8.52.tgz", sha256: "e".repeat(64) } })
);
assert.deepEqual(withTarball.artifact, { file: "omniroute-3.8.52.tgz", sha256: "e".repeat(64) });
assert.equal(withTarball.identityKey, plain.identityKey);
assert.equal("artifact" in plain, false);
assert.throws(
() => buildArtifactIdentity(baseInput({ tarball: { file: "x.tgz", sha256: "nope" } })),
/sha256/
);
});
test("resolveBundler mirrors build-next-isolated.mjs: turbopack unless OMNIROUTE_USE_TURBOPACK=0", () => {
assert.equal(resolveBundler({}), "turbopack");
assert.equal(resolveBundler({ OMNIROUTE_USE_TURBOPACK: "1" }), "turbopack");
assert.equal(resolveBundler({ OMNIROUTE_USE_TURBOPACK: "0" }), "webpack");
});
test("parseIdentityArgs requires --base and rejects flags without values", () => {
assert.deepEqual(parseIdentityArgs(["--head", HEAD, "--base", BASE]), {
head: HEAD,
base: BASE,
lockfile: "package-lock.json",
tarball: undefined,
out: undefined,
});
assert.throws(() => parseIdentityArgs(["--head", HEAD]), /--base/);
assert.throws(() => parseIdentityArgs(["--base"]), /--base needs a value/);
assert.throws(() => parseIdentityArgs(["--base", BASE, "--bogus", "x"]), /Unknown flag/);
});
test("CLI prints the identity JSON and writes --out, hashing the real tarball", () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "artifact-identity-"));
try {
const lockfile = path.join(dir, "package-lock.json");
const tarball = path.join(dir, "omniroute-0.0.0.tgz");
const out = path.join(dir, "identity.json");
writeFileSync(lockfile, LOCK);
writeFileSync(tarball, "fake tarball bytes");
const stdout = execFileSync(
process.execPath,
[
SCRIPT,
"--head",
HEAD,
"--base",
BASE,
"--lockfile",
lockfile,
"--tarball",
tarball,
"--out",
out,
],
{ encoding: "utf8", env: { ...process.env, OMNIROUTE_USE_TURBOPACK: "0" } }
);
const printed = JSON.parse(stdout);
assert.equal(printed.headSha, HEAD);
assert.equal(printed.lockfileHash, hashLockfile(LOCK));
assert.equal(printed.platform, process.platform);
assert.equal(printed.arch, process.arch);
assert.equal(printed.nodeAbi, process.versions.modules);
assert.equal(printed.bundler, "webpack");
assert.equal(printed.artifact.file, "omniroute-0.0.0.tgz");
assert.equal(
printed.artifact.sha256,
createHash("sha256").update("fake tarball bytes").digest("hex")
);
const written = JSON.parse(readFileSync(out, "utf8"));
assert.deepEqual(written, printed);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("CLI exits non-zero with a readable error on a bad SHA", () => {
let failed = false;
try {
execFileSync(process.execPath, [SCRIPT, "--head", "nope", "--base", BASE], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
failed = true;
const err = error as { status: number; stderr: string };
assert.equal(err.status, 1);
assert.match(err.stderr, /artifact-identity: .*headSha/);
}
assert.equal(failed, true);
});

View file

@ -0,0 +1,504 @@
/**
* scripts/release/dry-run-lts-cut.mjs — the 3.9.0 LTS cut, rehearsed in 3.8.58.
*
* Default mode prints and VALIDATES the cut without executing anything:
* preconditions → stable/v3 from the tip → develop with the 4.0.0 bump → which
* dormant-workflow conditions turn true → expected dist-tags → rollback.
* `--execute` only ever targets a fork remote (never `origin`, never a remote
* whose URL is the canonical repository) and confirms every step.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
CANONICAL_REPO,
DEFAULTS,
analyzeDormantWorkflow,
buildCutPlan,
bumpLockfile,
bumpManifest,
bumpOpenApi,
evaluateMergifyQueue,
evaluatePreconditions,
executeCut,
expectedDistTags,
isCanonicalRemoteUrl,
parseCutArgs,
renderReport,
runCut,
} from "../../scripts/release/dry-run-lts-cut.mjs";
import { resolveDistTag } from "../../scripts/release/dist-tag.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const SCRIPT = path.join(repoRoot, "scripts/release/dry-run-lts-cut.mjs");
const SHA = "1".repeat(40);
// The real .mergify.yml of this checkout (G11: queue `release` with checks_timeout, #16066).
const MERGIFY = fs.readFileSync(path.join(repoRoot, ".mergify.yml"), "utf8");
type Facts = Parameters<typeof evaluatePreconditions>[0];
function goodFacts(overrides: Partial<Facts> = {}): Facts {
return {
sourceSha: SHA,
sourceVersion: "3.9.0",
previousTagExists: true,
freezeIssues: [{ number: 900, title: "🧊 Release freeze: release/v3.9.0" }],
baseRedIssues: [],
sourceBranchExists: true,
mergifyConfig: MERGIFY,
queueLabelExists: true,
releaseRulesetRules: ["deletion", "non_fast_forward"],
remoteBranches: { "stable/v3": null, develop: null },
...overrides,
};
}
// ── argument parsing ──────────────────────────────────────────────────────
test("defaults to --dry-run against the real 3.9.0 cut parameters", () => {
const opts = parseCutArgs([]);
assert.equal(opts.mode, "dry-run");
assert.equal(opts.rollback, false);
assert.equal(opts.targetVersion, "3.9.0");
assert.equal(opts.previousTag, "v3.8.59");
assert.equal(opts.developVersion, "4.0.0");
assert.equal(opts.from, "HEAD");
assert.equal(opts.repo, CANONICAL_REPO);
assert.equal(opts.sourceBranch, "release/v3.9.0");
assert.equal(DEFAULTS.stableBranch, "stable/v3");
assert.equal(DEFAULTS.developBranch, "develop");
});
test("--execute requires --remote and refuses origin", () => {
assert.throws(() => parseCutArgs(["--execute"]), /--execute requires --remote/);
assert.throws(() => parseCutArgs(["--execute", "--remote", "origin"]), /never origin/);
const opts = parseCutArgs(["--execute", "--remote", "rehearsal"]);
assert.equal(opts.mode, "execute");
assert.equal(opts.remote, "rehearsal");
});
test("flag validation: exclusive modes, rollback only with execute, known advisory ids", () => {
assert.throws(() => parseCutArgs(["--execute", "--dry-run", "--remote", "x"]), /exclusive/);
assert.throws(() => parseCutArgs(["--rollback"]), /--rollback requires --execute/);
assert.throws(() => parseCutArgs(["--advisory", "nope"]), /Unknown precondition/);
assert.throws(() => parseCutArgs(["--remote"]), /--remote needs a value/);
assert.throws(() => parseCutArgs(["--bogus"]), /Unknown flag/);
const rehearsal = parseCutArgs([
"--target-version",
"3.8.58",
"--previous-tag",
"v3.8.57",
"--advisory",
"freeze,base-green",
]);
assert.equal(rehearsal.targetVersion, "3.8.58");
assert.equal(rehearsal.sourceBranch, "release/v3.8.58");
assert.deepEqual([...rehearsal.advisory].sort(), ["base-green", "freeze"]);
});
test("isCanonicalRemoteUrl catches every URL form of the canonical repository", () => {
for (const url of [
"https://github.com/diegosouzapw/OmniRoute.git",
"https://github.com/diegosouzapw/omniroute",
"git@github.com:diegosouzapw/OmniRoute.git",
"ssh://git@github.com/diegosouzapw/OmniRoute.git",
"https://x-access-token:abc@github.com/diegosouzapw/OmniRoute.git",
]) {
assert.equal(isCanonicalRemoteUrl(url, CANONICAL_REPO), true, url);
}
for (const url of [
"https://github.com/someone/OmniRoute.git",
"git@github.com:diegosouzapw/OmniRoute-rehearsal.git",
"",
]) {
assert.equal(isCanonicalRemoteUrl(url, CANONICAL_REPO), false, url);
}
});
// ── preconditions ─────────────────────────────────────────────────────────
test("all preconditions pass on a ready cut", () => {
const checks = evaluatePreconditions(goodFacts(), parseCutArgs([]));
assert.deepEqual(
checks.map((c) => c.id),
[
"source",
"tag",
"version",
"freeze",
"base-green",
"merge-queue",
"release-ruleset",
"branches-absent",
]
);
assert.ok(checks.every((c) => c.ok && c.blocking));
});
test("each failing precondition is reported with expected vs actual", () => {
const opts = parseCutArgs([]);
const cases: [Partial<Facts>, string, RegExp][] = [
[{ sourceSha: null }, "source", /does not resolve/],
[{ previousTagExists: false }, "tag", /missing/],
[{ sourceVersion: "3.8.59" }, "version", /3\.8\.59/],
[{ freezeIssues: [] }, "freeze", /none open/],
[
{ baseRedIssues: [{ number: 15306, title: "🔴 Release branch not green: release/v3.9.0" }] },
"base-green",
/#15306/,
],
[{ remoteBranches: { "stable/v3": SHA, develop: null } }, "branches-absent", /stable\/v3/],
[{ freezeIssues: null }, "freeze", /unknown/],
[{ queueLabelExists: false }, "merge-queue", /label `queue` missing/],
[{ mergifyConfig: null }, "merge-queue", /\.mergify\.yml missing/],
[{ releaseRulesetRules: ["deletion"] }, "release-ruleset", /missing: non_fast_forward/],
[{ releaseRulesetRules: null }, "release-ruleset", /unknown/],
];
for (const [override, id, actual] of cases) {
const check = evaluatePreconditions(goodFacts(override), opts).find((c) => c.id === id);
assert.equal(check?.ok, false, `${id} must fail`);
assert.match(String(check?.actual), actual);
assert.ok(check?.expected, `${id} states what it expected`);
}
});
test("base-green is unknown (never green) when the release branch does not exist", () => {
const opts = parseCutArgs([]);
const missing = evaluatePreconditions(goodFacts({ sourceBranchExists: false }), opts).find(
(c) => c.id === "base-green"
);
assert.equal(missing?.ok, false, "no issue for a branch that does not exist is not evidence");
assert.equal(missing?.verifiable, false);
assert.match(String(missing?.actual), /unknown .*does not exist.*not verifiable/);
const lookupFailed = evaluatePreconditions(goodFacts({ sourceBranchExists: null }), opts).find(
(c) => c.id === "base-green"
);
assert.equal(lookupFailed?.ok, false);
assert.doesNotMatch(String(lookupFailed?.actual), /green$/);
const existing = evaluatePreconditions(goodFacts(), opts).find((c) => c.id === "base-green");
assert.equal(existing?.ok, true);
assert.equal(existing?.verifiable, true);
});
test("G11 is the Mergify `release` queue, not GitHub's native merge queue", () => {
assert.deepEqual(
evaluateMergifyQueue(MERGIFY),
{ ok: true, problems: [] },
"G11 GO on this base"
);
const noTimeout = MERGIFY.replace(/^ {4}checks_timeout:.*\n/m, "");
assert.notEqual(noTimeout, MERGIFY, "fixture removed checks_timeout");
assert.deepEqual(evaluateMergifyQueue(noTimeout).problems, [
"queue `release` has no checks_timeout",
]);
assert.match(
evaluateMergifyQueue("queue_rules:\n - name: other\n").problems.join(),
/no queue_rules entry named `release`/
);
const noLabel = MERGIFY.replace(/label ?= ?queue/g, "label=other");
assert.match(evaluateMergifyQueue(noLabel).problems.join(), /`queue` label/);
assert.equal(evaluateMergifyQueue(null).ok, false);
assert.equal(evaluateMergifyQueue(": : :\n - [").ok, false);
});
test("--advisory downgrades a check to a warning without hiding it", () => {
const opts = parseCutArgs(["--advisory", "freeze"]);
const freeze = evaluatePreconditions(goodFacts({ freezeIssues: [] }), opts).find(
(c) => c.id === "freeze"
);
assert.equal(freeze?.ok, false);
assert.equal(freeze?.blocking, false);
});
// ── develop bump (pure, applied to the real files of this checkout) ───────
test("bumpManifest changes only the top-level version of the real package.json files", () => {
for (const file of ["package.json", "open-sse/package.json", "electron/package.json"]) {
const before = fs.readFileSync(path.join(repoRoot, file), "utf8");
const after = bumpManifest(before, "4.0.0");
assert.equal(JSON.parse(after).version, "4.0.0", file);
const changed = before.split("\n").filter((line, i) => line !== after.split("\n")[i]);
assert.equal(changed.length, 1, `${file}: exactly one line changes`);
}
assert.throws(() => bumpManifest('{"name":"x"}', "4.0.0"), /version/);
});
test("bumpLockfile moves the root and the open-sse workspace entries only", () => {
const before = fs.readFileSync(path.join(repoRoot, "package-lock.json"), "utf8");
const after = bumpLockfile(before, "4.0.0");
const lock = JSON.parse(after);
assert.equal(lock.version, "4.0.0");
assert.equal(lock.packages[""].version, "4.0.0");
assert.equal(lock.packages["open-sse"].version, "4.0.0");
const beforeLines = before.split("\n");
const changed = after.split("\n").filter((line, i) => line !== beforeLines[i]);
assert.equal(changed.length, 3);
});
test("bumpOpenApi changes info.version only", () => {
const before = fs.readFileSync(path.join(repoRoot, "docs/openapi.yaml"), "utf8");
const after = bumpOpenApi(before, "4.0.0");
assert.match(after, /^info:\n(?:[ ].*\n)*? {2}version: 4\.0\.0$/m);
const beforeLines = before.split("\n");
assert.equal(after.split("\n").filter((line, i) => line !== beforeLines[i]).length, 1);
assert.throws(() => bumpOpenApi("openapi: 3.1.0\n", "4.0.0"), /info\.version/);
});
// ── plan ──────────────────────────────────────────────────────────────────
test("the plan orders the cut and only ever pushes to the selected remote", () => {
const opts = parseCutArgs(["--execute", "--remote", "rehearsal"]);
const plan = buildCutPlan(opts, goodFacts());
assert.deepEqual(
plan.steps.map((s) => s.id),
["create-stable", "create-develop"]
);
for (const step of plan.steps) {
for (const cmd of step.preview) {
assert.ok(Array.isArray(cmd), "commands are argv arrays, never shell strings");
if (cmd[0] === "git" && cmd[1] === "push") assert.equal(cmd[2], "rehearsal");
}
}
assert.deepEqual(plan.steps[0].preview[0], [
"git",
"push",
"rehearsal",
`${SHA}:refs/heads/stable/v3`,
]);
assert.deepEqual(plan.rollback[0], [
"git",
"push",
"rehearsal",
"--delete",
"refs/heads/stable/v3",
"refs/heads/develop",
]);
assert.deepEqual(plan.rollback[1], ["npm", "dist-tag", "add", "omniroute@3.8.59", "latest"]);
assert.deepEqual(plan.bumpFiles, [
"package.json",
"open-sse/package.json",
"electron/package.json",
"package-lock.json",
"docs/openapi.yaml",
]);
});
test("dry-run plans against origin by name but marks every step as not executed", () => {
const plan = buildCutPlan(parseCutArgs([]), goodFacts());
assert.equal(plan.remote, "origin");
assert.equal(plan.executes, false);
});
// ── dormant workflows ─────────────────────────────────────────────────────
const FORWARD_PORT = `
name: Forward-port
on:
push:
branches: [stable/v3]
workflow_dispatch:
jobs:
forward-port:
if: github.ref == 'refs/heads/stable/v3' && github.repository == 'diegosouzapw/OmniRoute'
runs-on: ubuntu-latest
steps:
- name: Check that develop exists
run: git ls-remote --exit-code --heads origin develop
`;
const NIGHTLY = `
name: Nightly
on:
schedule:
- cron: "23 4 * * *"
jobs:
detect:
runs-on: ubuntu-latest
steps:
- run: git ls-remote --heads origin develop
publish:
needs: detect
if: (github.event_name != 'schedule' || github.repository == 'diegosouzapw/OmniRoute') && vars.NIGHTLY_PUBLISH == 'true'
runs-on: ubuntu-latest
steps:
- run: echo publish
`;
test("analyzeDormantWorkflow reports what turns on, what stays gated, and fork caveats", () => {
const fp = analyzeDormantWorkflow("forward-port.yml", FORWARD_PORT);
assert.equal(fp.present, true);
assert.ok(fp.triggers.includes("push → stable/v3"));
assert.ok(fp.activates.some((l) => /forward-port.*stable\/v3/.test(l)));
assert.ok(fp.activates.some((l) => /develop exists/.test(l)));
assert.ok(fp.forkCaveats.some((l) => /diegosouzapw\/OmniRoute/.test(l)));
const nightly = analyzeDormantWorkflow("nightly-v4-build.yml", NIGHTLY);
assert.ok(nightly.stillGated.some((l) => /vars\.NIGHTLY_PUBLISH/.test(l)));
assert.ok(nightly.activates.some((l) => /develop exists/.test(l)));
assert.ok(
nightly.forkCaveats.some((l) => /scheduled runs only on .*dispatch it by hand/.test(l)),
"a schedule-only repo guard is not reported as 'always false' in a fork"
);
const missing = analyzeDormantWorkflow("validate-stable-pr.yml", null);
assert.equal(missing.present, false);
});
// ── dist-tags ─────────────────────────────────────────────────────────────
test("the real dist-tag resolver keeps nightly/rc off latest and 3.9.x on lts after 4.0 GA", () => {
assert.equal(resolveDistTag("3.9.0", { latestMajor: 3 }), "latest");
assert.equal(resolveDistTag("4.0.0-nightly.20261010.abcdef0", {}), "nightly");
assert.equal(resolveDistTag("4.0.0-rc.1", {}), "next");
assert.equal(resolveDistTag("3.9.1", { latestMajor: 4 }), "lts");
});
test("expectedDistTags: latest → 3.9.0, next/nightly empty, resolver agrees", () => {
const tags = expectedDistTags(parseCutArgs([]), {
source: "test",
resolveDistTag: resolveDistTag,
});
assert.deepEqual(tags.expected, { latest: "3.9.0", next: null, nightly: null });
assert.ok(
tags.checks.every((c) => c.ok),
JSON.stringify(tags.checks)
);
const broken = expectedDistTags(parseCutArgs([]), {
source: "broken",
resolveDistTag: () => "latest",
});
assert.ok(
broken.checks.some((c) => !c.ok),
"a resolver that leaks nightly to latest is caught"
);
});
// ── execution guard rails ─────────────────────────────────────────────────
function fakeIo(remoteUrl = "https://github.com/rehearsal-bot/OmniRoute.git") {
const calls: string[][] = [];
return {
calls,
remoteUrl: (name: string) => (name === "rehearsal" ? remoteUrl : null),
git: (args: string[]) => {
calls.push(["git", ...args]);
return "f".repeat(40);
},
createDevelopCommit: () => "d".repeat(40),
};
}
test("executeCut refuses a remote whose URL is the canonical repository", async () => {
const io = fakeIo("git@github.com:diegosouzapw/OmniRoute.git");
const opts = parseCutArgs(["--execute", "--remote", "rehearsal"]);
await assert.rejects(
executeCut(buildCutPlan(opts, goodFacts()), opts, io, async () => true),
/canonical repository/
);
assert.deepEqual(io.calls, []);
});
test("executeCut stops at the first declined step and runs nothing after it", async () => {
const io = fakeIo();
const opts = parseCutArgs(["--execute", "--remote", "rehearsal"]);
const result = await executeCut(buildCutPlan(opts, goodFacts()), opts, io, async () => false);
assert.equal(result.aborted, true);
assert.deepEqual(io.calls, []);
});
test("executeCut pushes stable/v3 and the bumped develop commit to the fork only", async () => {
const io = fakeIo();
const opts = parseCutArgs(["--execute", "--remote", "rehearsal"]);
const result = await executeCut(buildCutPlan(opts, goodFacts()), opts, io, async () => true);
assert.equal(result.aborted, false);
assert.deepEqual(io.calls, [
["git", "push", "rehearsal", `${SHA}:refs/heads/stable/v3`],
["git", "push", "rehearsal", `${"d".repeat(40)}:refs/heads/develop`],
]);
});
test("executeCut --rollback deletes both branches on the fork after confirmation", async () => {
const io = fakeIo();
const opts = parseCutArgs(["--execute", "--rollback", "--remote", "rehearsal"]);
await executeCut(buildCutPlan(opts, goodFacts()), opts, io, async () => true);
assert.deepEqual(io.calls, [
["git", "push", "rehearsal", "--delete", "refs/heads/stable/v3", "refs/heads/develop"],
]);
});
// ── report + CLI ──────────────────────────────────────────────────────────
test("runCut in dry-run renders the whole sequence and exits 1 on a failed blocking check", async () => {
const lines: string[] = [];
const io = {
...fakeIo(),
gatherFacts: async () => goodFacts({ previousTagExists: false }),
readWorkflow: (file: string) => (file === "forward-port.yml" ? FORWARD_PORT : null),
loadDistTagResolver: async () => ({
source: "fallback",
resolveDistTag: resolveDistTag,
}),
};
const code = await runCut(parseCutArgs([]), io, (l: string) => lines.push(l));
const out = lines.join("\n");
assert.equal(code, 1);
for (const section of [
"Preconditions",
"1. Create stable/v3",
"2. Create develop",
"3. Dormant workflows",
"4. npm dist-tags",
"5. Rollback",
]) {
assert.ok(out.includes(section), `report has "${section}"`);
}
assert.match(out, /DRY-RUN — nothing was executed/);
assert.match(out, /✗ tag/);
assert.equal(io.calls.length, 0, "dry-run never runs git mutations");
assert.equal(typeof renderReport, "function");
});
test("runCut is READY only when every dormant workflow exists at the source", async () => {
const real = (file: string) =>
fs.readFileSync(path.join(repoRoot, ".github/workflows", file), "utf8");
const run = async (readWorkflow: (file: string) => string | null) => {
const lines: string[] = [];
const io = {
...fakeIo(),
gatherFacts: async () => goodFacts(),
readWorkflow,
loadDistTagResolver: async () => ({ source: "dist-tag.mjs", resolveDistTag }),
};
const code = await runCut(parseCutArgs([]), io, (l: string) => lines.push(l));
return { code, out: lines.join("\n") };
};
const ready = await run(real);
assert.equal(ready.code, 0, ready.out);
assert.match(ready.out, /RESULT: READY/);
const missing = await run((file) => (file === "nightly-v4-build.yml" ? null : real(file)));
assert.equal(missing.code, 1);
assert.match(missing.out, /nightly-v4-build\.yml: MISSING at the cut source/);
});
test("the real dormant workflows on this base activate as RELEASE_STRATEGY.md promises", () => {
const read = (file: string) =>
fs.readFileSync(path.join(repoRoot, ".github/workflows", file), "utf8");
const fp = analyzeDormantWorkflow("forward-port.yml", read("forward-port.yml"));
assert.ok(fp.triggers.includes("push → stable/v3"));
assert.ok(fp.forkCaveats.length > 0, "forward-port is pinned to the canonical repository");
const stable = analyzeDormantWorkflow("validate-stable-pr.yml", read("validate-stable-pr.yml"));
assert.ok(stable.triggers.includes("pull_request → stable/v3"));
const nightly = analyzeDormantWorkflow("nightly-v4-build.yml", read("nightly-v4-build.yml"));
assert.ok(nightly.activates.some((l) => /develop exists/.test(l)));
assert.ok(nightly.stillGated.some((l) => /vars\.NIGHTLY_PUBLISH/.test(l)));
});
test("CLI usage errors exit 2 without touching anything", () => {
for (const args of [["--execute"], ["--execute", "--remote", "origin"], ["--bogus"]]) {
const res = spawnSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8" });
assert.equal(res.status, 2, args.join(" "));
assert.match(res.stderr, /dry-run-lts-cut:/);
}
});

View file

@ -0,0 +1,177 @@
/**
* Contract test for .github/workflows/preview-artifact.yml — the PR preview
* artifact / build-once lane of #8084 slice (a), rehearsed in 3.8.58.
*
* What must hold (and what a "small cleanup" of the YAML must not break):
* - triggers: workflow_dispatch(pr_number) or the `preview-artifact` label only;
* - fork PRs never build (label path guarded in the job `if:`, dispatch path
* re-checked from the API before checkout);
* - one build, validated by check:pack-artifact + check:pack-boot with fake
* secrets, then identity + upload of THOSE bytes (re-pack digest check);
* - OIDC/attestation permissions only in the `attest` job, which never checks
* out or runs repository code, and runs on a GitHub-hosted runner;
* - nothing publishes; every external action is pinned to a full SHA.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import * as yaml from "js-yaml";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const FILE = path.join(repoRoot, ".github/workflows/preview-artifact.yml");
type Step = {
name?: string;
uses?: string;
run?: string;
with?: Record<string, unknown>;
env?: Record<string, string>;
};
type Job = {
if?: string;
needs?: string | string[];
"runs-on"?: unknown;
permissions?: Record<string, string>;
steps?: Step[];
env?: Record<string, string>;
};
type Workflow = {
on: {
workflow_dispatch?: { inputs: { pr_number: { required: boolean } } };
pull_request?: { types: string[] };
[trigger: string]: unknown;
};
permissions?: Record<string, string>;
jobs: Record<string, Job>;
};
const raw = fs.readFileSync(FILE, "utf8");
const wf = yaml.load(raw) as Workflow;
function stepIndex(job: Job, predicate: (s: Step) => boolean, label: string): number {
const idx = (job.steps ?? []).findIndex(predicate);
assert.ok(idx >= 0, `step ${label} must exist`);
return idx;
}
const runs = (cmd: string) => (s: Step) => typeof s.run === "string" && s.run.includes(cmd);
const uses = (action: string) => (s: Step) =>
typeof s.uses === "string" && s.uses.startsWith(`${action}@`);
test("triggers: manual dispatch with pr_number, or the preview-artifact label", () => {
assert.deepEqual(Object.keys(wf.on).sort(), ["pull_request", "workflow_dispatch"]);
assert.equal(wf.on.workflow_dispatch?.inputs.pr_number.required, true);
assert.deepEqual(wf.on.pull_request?.types, ["labeled"]);
assert.ok(!("pull_request_target" in wf.on), "never pull_request_target");
assert.match(wf.jobs.resolve.if ?? "", /github\.event\.label\.name == 'preview-artifact'/);
});
test("fork PRs never build: job guard + API re-check before checkout", () => {
assert.match(
wf.jobs.resolve.if ?? "",
/github\.event\.pull_request\.head\.repo\.full_name == github\.repository/
);
const resolveScript = (wf.jobs.resolve.steps ?? []).map((s) => s.run ?? "").join("\n");
assert.match(resolveScript, /\.head\.repo\.full_name/);
assert.match(resolveScript, /"\$HEAD_REPO" != "\$REPO"/);
assert.match(resolveScript, /\^\[0-9\]\+\$/, "pr_number validated as an integer");
const needs = ([] as string[]).concat(wf.jobs.build.needs ?? []);
assert.ok(needs.includes("resolve"), "build must depend on the fork check");
});
test("build runs the full validation chain once, in order", () => {
const job = wf.jobs.build;
const checkout = stepIndex(job, uses("actions/checkout"), "checkout");
const install = stepIndex(job, (s) => s.uses === "./.github/actions/npm-ci-retry", "npm ci");
const build = stepIndex(job, runs("npm run build:release"), "build:release");
const packArtifact = stepIndex(job, runs("npm run check:pack-artifact"), "check:pack-artifact");
const pack = stepIndex(job, runs("npm pack --json"), "npm pack");
const boot = stepIndex(job, runs("npm run check:pack-boot"), "check:pack-boot");
const repack = stepIndex(job, runs("re-pack"), "re-pack digest check");
const identity = stepIndex(job, runs("scripts/release/artifact-identity.mjs"), "identity");
const upload = stepIndex(job, uses("actions/upload-artifact"), "upload");
const order = [checkout, install, build, packArtifact, pack, boot, repack, identity, upload];
assert.deepEqual(
[...order].sort((a, b) => a - b),
order,
"checkout → npm ci → build → pack-artifact → pack → pack-boot → re-pack → identity → upload"
);
assert.equal(
(job.steps ?? []).filter(runs("npm run build")).length,
1,
"exactly one build: promotion consumes the uploaded bytes, never a rebuild"
);
assert.equal(job.steps?.[checkout].with?.ref, "${{ needs.resolve.outputs.head_sha }}");
assert.equal(job.steps?.[checkout].with?.["persist-credentials"], false);
assert.equal(String(job.steps?.[install].with?.cache), "false");
});
test("boot-smoke uses fake secrets and an ephemeral DATA_DIR", () => {
const job = wf.jobs.build;
assert.match(job.env?.JWT_SECRET ?? "", /fake/);
assert.match(job.env?.API_KEY_SECRET ?? "", /fake/);
assert.ok(!/secrets\./.test(JSON.stringify(job)), "the build job reads no repository secret");
const boot = job.steps?.find(runs("npm run check:pack-boot"));
assert.match(boot?.env?.DATA_DIR ?? "", /runner\.temp/);
});
test("identity records head + base SHA from resolve and hashes the uploaded tarball", () => {
const step = wf.jobs.build.steps?.find(runs("scripts/release/artifact-identity.mjs"));
assert.equal(step?.env?.HEAD_SHA, "${{ needs.resolve.outputs.head_sha }}");
assert.equal(step?.env?.BASE_SHA, "${{ needs.resolve.outputs.base_sha }}");
for (const flag of ["--head", "--base", "--lockfile", "--tarball", "--out"]) {
assert.ok(step?.run?.includes(flag), `identity step passes ${flag}`);
}
assert.match(step?.run ?? "", /SHA256SUMS/);
});
test("OIDC + attestation permissions live ONLY in the attest job", () => {
assert.deepEqual(Object.keys(wf.permissions ?? {}), ["contents"]);
assert.equal(wf.permissions?.contents, "read");
for (const [id, job] of Object.entries(wf.jobs)) {
assert.ok(job.permissions, `job ${id} declares explicit permissions`);
const writes = Object.entries(job.permissions ?? {})
.filter(([, v]) => v === "write")
.map(([k]) => k)
.sort();
if (id === "attest") {
assert.deepEqual(writes, ["attestations", "id-token"]);
} else {
assert.deepEqual(writes, [], `job ${id} must not hold write permissions`);
}
}
});
test("attest never runs repository code and runs on a GitHub-hosted runner", () => {
const job = wf.jobs.attest;
assert.equal(job["runs-on"], "ubuntu-latest");
for (const step of job.steps ?? []) {
assert.ok(!step.uses?.startsWith("actions/checkout"), "attest must not check out the PR");
assert.ok(!step.uses?.startsWith("./"), "attest must not use a repository-local action");
assert.ok(!/\bnpm\b|\bnode\b/.test(step.run ?? ""), "attest must not execute repo scripts");
}
const attest = job.steps?.find(uses("actions/attest-build-provenance"));
assert.ok(attest, "attest-build-provenance step");
assert.match(String(attest?.with?.["subject-path"]), /\.tgz$/);
const verify = stepIndex(job, runs("sha256sum -c SHA256SUMS"), "checksum verify");
const sign = stepIndex(job, uses("actions/attest-build-provenance"), "attest");
assert.ok(verify < sign, "checksums are verified before signing");
});
test("every external action is pinned to a full commit SHA", () => {
for (const job of Object.values(wf.jobs)) {
for (const step of job.steps ?? []) {
if (!step.uses || step.uses.startsWith("./")) continue;
assert.match(step.uses, /^[\w.-]+\/[\w.-]+@[0-9a-f]{40}$/, `unpinned action: ${step.uses}`);
}
}
});
test("nothing publishes", () => {
assert.doesNotMatch(
raw,
/npm (stage )?publish|npm dist-tag|docker push|gh release (create|upload)/
);
assert.doesNotMatch(raw, /NPM_TOKEN|NODE_AUTH_TOKEN/);
});