diff --git a/.github/justfile b/.github/justfile index f559fba..fd5e324 100644 --- a/.github/justfile +++ b/.github/justfile @@ -44,7 +44,12 @@ _step-tolerated recipe: fi # The required per-push/PR matrix job (ci.yml `core`). +# `version-guard-pr` runs FIRST and costs seconds: a versioning mistake is +# cheap to hear about before a 40-minute matrix, and the recipe no-ops +# outside pull_request runs (no PR_NUMBER), so pushes and `just ci` are +# unaffected. ci.yml supplies PR_NUMBER / PR_BASE_SHA / GH_TOKEN. core: + @just gha::_step version-guard-pr @just gha::_step build @just gha::_step test-rust @just gha::_step shim @@ -57,6 +62,7 @@ core: @just gha::_step test-sockets-node @just gha::_step test-ct-runner @just gha::_step test-bundle + @just gha::_step test-version-guard @just gha::_step publish-check @just gha::_step examples @just gha::_step test-translate diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8951c16..81face4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,19 @@ jobs: # lives on the root-justfile recipes it composes. Suite order and # arch-conditional lanes (jsc-pinned is x64-only) are all encoded # there. + # + # The env below is the only thing the version guard's `pr` mode needs + # (its first step): it reads the PR's LIVE labels and diffs against the + # base. All three are empty on push runs, where the guard skips itself — + # deliberately, since there is no PR to have labels. Note that label + # edits do NOT re-trigger this workflow (no `types:` addition above, on + # purpose): the PR pass is an early warning, release.yml's `cut` mode is + # the enforcement point, and a re-run picks up corrected labels. - run: just gha::core + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GH_TOKEN: ${{ github.token }} browser: runs-on: ubuntu-24.04 @@ -90,19 +102,31 @@ jobs: # required lanes + the best-effort webkit lane, per the recipes. - run: just gha::browser - # Every GREEN main ref update ships a prerelease (issue #16's interim - # scheme): tag pre-, shim artifacts + SHA256SUMS. Gated on this - # same run's core matrix + browser job, so "green" is the release - # criterion by construction. Notes: (a) the concurrency group above - # cancels superseded runs, so rapid consecutive pushes release only the - # surviving tip — a cancelled run was never green; (b) continue-on-error - # steps (the webkit lane) do not block a release, by the same policy - # that keeps them non-blocking in CI. + # Every GREEN main ref update ships a GitHub PRERELEASE: tag + # pre-, shim artifacts + SHA256SUMS, and nothing on a registry + # — JSR and npm carry cut releases only (#223). Gated on this same run's + # core matrix + browser job, so "green" is the release criterion by + # construction, and the prerelease doubles as the proof a cut later + # requires ("a pre- release exists for this commit"). Notes: + # (a) the concurrency group above cancels superseded runs, so rapid + # consecutive pushes release only the surviving tip — a cancelled run was + # never green; (b) continue-on-error steps (the webkit lane) do not block + # a release, by the same policy that keeps them non-blocking in CI. release: needs: [core, browser] if: github.event_name == 'push' && github.ref == 'refs/heads/main' + # The prerelease path only ever uses `contents: write` (create a GitHub + # release, upload assets) — no registry is published from a prerelease, + # so id-token (JSR OIDC) and actions (the npm dispatch) are dead weight + # here. They are granted anyway BECAUSE THEY MUST BE: a reusable-workflow + # call fails at STARTUP unless the caller grants everything the called + # workflow's `permissions` block requests, if-skipped steps included + # (observed on #223: "requesting 'actions: write, id-token: write', but + # is only allowed 'none'"), and release.yml requests all three for its + # cut mode. The unused tokens are inert: the steps that would spend them + # are `if: inputs.release`, which a workflow_call run cannot set. permissions: contents: write - id-token: write # JSR OIDC publishing (the called workflow requests it) - actions: write # ditto: it dispatches npm-publish.yml + id-token: write # unused here; required by release.yml's request (cut mode) + actions: write # ditto uses: ./.github/workflows/release.yml diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 061d748..75eb7c4 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -3,11 +3,18 @@ name: Publish to npm # The npm half of the release scheme. JSR is published inline by release.yml; # npm is published HERE, for the release that release.yml has just created. # -# How this is reached: release.yml DISPATCHES it explicitly (its last step). -# The `release: published` trigger below is a backstop for a release created by -# hand — it cannot serve the automated path, because GitHub does not start -# workflow runs from events raised with GITHUB_TOKEN, and `workflow_dispatch` -# is the documented exception to that rule. +# CUT RELEASES ONLY (#223): the registries carry `v` cuts and +# nothing else. A `pre-` tag is REFUSED by the version step +# below rather than ignored, which also gives the `release: published` +# backstop teeth — a hand-made GitHub prerelease can start this workflow, +# but it cannot publish anything. +# +# How this is reached: release.yml DISPATCHES it explicitly (its last step, +# on cut releases only). The `release: published` trigger below is a +# backstop for a release created by hand — it cannot serve the automated +# path, because GitHub does not start workflow runs from events raised with +# GITHUB_TOKEN, and `workflow_dispatch` is the documented exception to that +# rule. # # Why not a step inside release.yml, next to the JSR publish: npm's trusted # publishing (OIDC) validates the CALLING workflow's filename, and release.yml @@ -32,7 +39,7 @@ on: workflow_dispatch: inputs: tag: - description: The release tag to publish to npm (e.g. v0.1.0 or pre-abc1234). + description: The release tag to publish to npm (a cut tag, e.g. v0.1.0). required: true type: string @@ -69,16 +76,16 @@ jobs: - name: compute the version id: ver # Mirrors release.yml's scheme exactly, derived from the tag so the two - # cannot drift: a cut release tags `v`; a prerelease tags - # `pre-` and takes from the committed manifest, which - # release.yml stamps only in its own working tree. This VERSION is the - # build stamp for the lockstep four (runtime, translator, wasi, - # ct-runner) only — build.ts passes it as `--version` and it does NOT - # affect @polyengine/protocol, which always emits at its own manifest - # version (A10). The dist-tag is no longer computed here: each - # package's actual built version decides its own tag in the publish - # step below (a hyphen means prerelease), since protocol's version can - # differ from the stamp. + # cannot drift: a cut release tags `v` and publishes at + # exactly that version. `pre-` tags are refused, not + # skipped: prereleases are GitHub releases carrying artifacts and are + # published to NO registry (#223), so reaching this workflow with one + # means something dispatched or triggered it in error, and a clear + # failure beats a surprise publish. This VERSION is the build stamp + # for the lockstep four (runtime, translator, wasi, ct-runner) only — + # build.ts passes it as `--version` and it does NOT affect + # @polyengine/protocol, which always emits at its own manifest + # version (A10). run: | TAG='${{ steps.rel.outputs.tag }}' case "$TAG" in @@ -86,10 +93,11 @@ jobs: VERSION="${TAG#v}" ;; pre-*) - VERSION="$(jq -r '.version' runtime/deno.json)-pre.g${TAG#pre-}" + echo "tag '$TAG' is a prerelease: prereleases are not published to npm or JSR (policy as of #223) — only cut releases (v) are" >&2 + exit 1 ;; *) - echo "tag '$TAG' is neither v nor pre-" >&2 + echo "tag '$TAG' is not a cut tag (v)" >&2 exit 1 ;; esac @@ -118,28 +126,18 @@ jobs: - name: publish # Dependency order — protocol first: it is not part of the lockstep # (A10), so it publishes once per manifest bump, at its own version, - # and is skipped as already-published on every other run (JSR parity - # with release.yml's "compute tag and version" step). Publishing it - # before its dependents guarantees the version they reference (via a - # caret dependency — see build.ts) already exists on the registry the - # first time it is needed. Already-published versions are skipped - # rather than failed for every package — same convergence property as - # the JSR step, so a re-run after a partial failure completes the set - # instead of stranding it. + # and is skipped as already-published at every cut after that (npm + # parity with release.yml's JSR step). Publishing it before its + # dependents guarantees the version they reference (via a caret + # dependency — see build.ts) already exists on the registry the first + # time it is needed. Already-published versions are skipped rather + # than failed for every package — a re-run after a partial failure + # completes the set instead of stranding it. # - # Dist-tag is derived per package from its OWN built version, not the - # lockstep stamp: a hyphen (prerelease, e.g. 0.3.1-pre.gabc1234) means - # `--tag pre`; a bare version means `--tag latest`. Note the one case - # this cannot control: npm points `latest` at a package's FIRST - # published version regardless of --tag, and refuses to remove the tag - # (403) — the bootstrap publish that created these packages was a - # prerelease, so `latest` named it until the first cut release - # overwrote it. protocol's next publish (its manifest is 0.1.0) will - # move npm `latest` for @polyengine/protocol BACKWARD from whatever - # lockstep-stamped prerelease last got tagged `latest` there (e.g. an - # orphaned 0.3.0) — this is intended, not a bug: the orphan stays - # published and reachable, because runtime@0.3.0 (etc.) pins it - # exactly rather than through `latest`. + # Every version reaching this loop is a bare cut version (the step + # above refuses anything else), so `latest` is the only dist-tag in + # play; the `pre` tag belongs to the retired prerelease-publishing + # flow and stays frozen wherever it last pointed. # # Provenance attestations are generated automatically for trusted # publishing from a public repository; no --provenance flag needed. @@ -152,10 +150,6 @@ jobs: echo "$name@$V already published — skipping" continue fi - case "$V" in - *-*) TAG=pre ;; - *) TAG=latest ;; - esac - echo "publishing $name@$V (dist-tag $TAG)" - npm publish --tag "$TAG" --workspaces=false ./npm/"$p" + echo "publishing $name@$V (dist-tag latest)" + npm publish --tag latest --workspaces=false ./npm/"$p" done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b6d91b..d9e3d59 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,21 +6,23 @@ name: Release # # PRERELEASE (default; invoked automatically from ci.yml's `release` job on # every GREEN main ref update via workflow_call, and manually via -# workflow_dispatch as the escape hatch): tag `pre-`, JSR -# versions `-pre.g` where is the lockstep version -# in the package manifests. The manifests always carry the NEXT release — -# bumped to the next patch right after each cut, or to the next minor by -# the PR that lands a breaking change — so prereleases order after the -# last release and never collide with a cut version. +# workflow_dispatch as the escape hatch): a GitHub release tagged +# `pre-` carrying the artifact set below. It publishes NOTHING +# to a registry (policy as of #223 — JSR and npm are published by CUT +# releases only), so there is no version stamping either: the manifests +# are left exactly as committed, and a prerelease names a commit rather +# than a version. Consumers tracking `main` between cuts take these +# release assets or a git reference (README §Consuming). # # RELEASE (workflow_dispatch with release=true): cuts the manifest version -# — JSR publish at exactly , tag `v`, GitHub release without -# the prerelease mark, same artifact set. Guards: the four manifests agree -# (lockstep), the tag does not exist, and the commit already carries its -# `pre-` release — proof this exact commit went green through -# the full core+browser pipeline (the automatic path only fires on green -# main). Cut, then land the manifest-bump PR; until it lands, further -# prereleases stamp `-pre.*` (unordered but exact-pin-only, harmless). +# — JSR publish at exactly (the manifests always carry the NEXT +# release: bumped to the next patch right after each cut, or to the next +# minor by the PR that lands a breaking change), tag `v`, GitHub +# release without the prerelease mark, same artifact set. Guards: the four +# manifests agree (lockstep), the tag does not exist, and the commit +# already carries its `pre-` release — proof this exact commit +# went green through the full core+browser pipeline (the automatic path +# only fires on green main). Cut, then land the manifest-bump PR. # # Artifacts (both modes): the prebuilt translator wasm (standard release # build — the exact artifact every test suite runs against — with the @@ -28,6 +30,35 @@ name: Release # embedder bundle, plus SHA256SUMS, so consumers do not need a Rust # toolchain. The tag-exists guard makes re-runs no-ops-by-failure rather # than duplicate releases. +# +# VERSION GUARDS (tools/version-guard/check.ts; unit-tested by `just +# test-version-guard`). Two of its three modes run here — the third is +# ci.yml's per-PR early warning: +# +# `publish` (BOTH modes, before the JSR publish step) — the protocol +# identity guard. @polyengine/protocol is outside the lockstep and is +# SKIPPED as already-published whenever its manifest version already +# exists on JSR; if the in-tree protocol has moved since that version was +# published, a publish ships every dependent against the registry's older +# copy. That is the #219 tear: protocol/src changed without a manifest +# bump (its merge assumed 0.2.0 was unpublished; under the pre-#223 flow +# every green main published, and one had published 0.2.0 hours earlier), +# so runtime@0.4.0-pre.* went out importing exports protocol@0.2.0 did +# not have — an import-time failure for consumers, repaired by #221. The +# guard compares in-tree protocol to the published manifest byte for +# byte, both directions. It runs in BOTH modes for different reasons: on +# the cut path it is authoritative, standing between a tear and the only +# registry publish there is; on the prerelease path nothing publishes, so +# it is pure early detection — a red there means the NEXT CUT would tear, +# and main hears about it on the first green run instead of at cut time. +# +# `cut` (RELEASE=true only) — reads the breaking/* labels of every PR +# merged since the last cut tag, LIVE from the API (so a label corrected +# after the merge still counts, which is why the enforcement point is the +# cut), and requires a minor bump when any of them declares a break. It +# also emits the release notes' Breaking/Changes sections, spliced into +# the notes below. Prerelease notes are unchanged: a prerelease is one +# commit, not a window. on: workflow_dispatch: @@ -67,12 +98,13 @@ jobs: id: tag # The manifest version is the single source of truth (the NEXT # release); the lockstep guard keeps a half-bumped workspace from - # publishing a torn version set. @polyengine/protocol is deliberately - # outside the lockstep: it versions independently (embedder-api - # A10), publishes as-is on the first green run after its manifest - # bumps (prerelease cross-deps rewrite to caret of the manifest - # version, so it must exist on JSR), and is skipped as - # already-published on every other run. + # publishing a torn version set. VERSION is the manifest version in + # both modes — no stamping since #223 — but only the cut path + # consumes it, since a prerelease publishes to no registry and names + # its commit instead. @polyengine/protocol is deliberately outside + # the lockstep: it versions independently (embedder-api A10), + # publishes at the next cut after its manifest bumps, and is skipped + # as already-published at every cut after that. run: | VERSION=$(jq -r '.version' runtime/deno.json) for p in translator wasi ct-runner; do @@ -91,7 +123,6 @@ jobs: fi else TAG="pre-$SHORT" - VERSION="$VERSION-pre.g$SHORT" fi if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then echo "release $TAG already exists" >&2 @@ -103,6 +134,43 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE: ${{ inputs.release }} + - name: protocol identity guard + # Both modes, and BEFORE the JSR publish step — see the header. If + # protocol/deno.json's version is unpublished this passes trivially + # (the next cut creates it); if it is already published, the in-tree + # protocol must be byte-identical to what is on JSR under that + # version, because `deno publish` will skip it and every dependent + # published alongside will resolve to the registry's copy. + # + # On the CUT path that makes it the authoritative guard: it is the + # last thing between a tear and the only registry publish there is. + # On the PRERELEASE path nothing publishes at all since #223, so it + # is pure early detection — a red means the next cut would tear, and + # main hears about it on the first green run after the mistake + # lands rather than at cut time. Cheap either way (two HTTP GETs). + # + # Exact identity is deliberate: a "meaningful difference" heuristic + # is what talks a torn release into shipping (softer variants are + # parked in issue #222). + run: >- + deno run --allow-net=jsr.io --allow-read=. + tools/version-guard/check.ts publish + + - name: release window guard and notes + # RELEASE=true only. Enumerates the PRs merged since the last cut + # tag with their CURRENT labels and fails if a breaking/* label is + # not matched by a minor bump; writes the Breaking/Changes sections + # to changes.md for the "create release" step below. A prerelease + # has no window to enumerate, and its notes stay as they were. + if: inputs.release + run: >- + deno run --allow-net=jsr.io --allow-run --allow-read=. --allow-write=changes.md + --allow-env=GITHUB_REPOSITORY,GITHUB_SHA,VERSION + tools/version-guard/check.ts cut --out changes.md + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.tag.outputs.version }} + - name: build the release artifacts # `just release-artifacts` (root justfile): the standard shim, the # size-tuned shim (flags per crates/translator-shim/README.md), the @@ -111,22 +179,11 @@ jobs: run: just release-artifacts - name: publish to JSR - # Prerelease version scheme (issue #16): -pre.g — - # correlates 1:1 with this run's `pre-` release tag. The - # `g` prefix (git-describe's convention) is load-bearing: a bare - # short hash can be all-digits-with-leading-zero, which is INVALID - # semver (numeric identifiers forbid leading zeros) and would fail - # ~0.4% of publishes at random. Hash versions are unique per commit - # (JSR refuses duplicates, making re-runs no-ops-by-failure like - # the tag guard) but deliberately NOT monotonic under semver - # ordering: unstable prereleases are pinned exactly, never ranged. - # The stamped versions are CI-working-tree-only (never committed); - # cross-package workspace deps are rewritten by `deno publish`. - # Release mode publishes the manifests exactly as committed — no - # stamping. --allow-dirty in both modes: the working tree holds the - # untracked release artifacts (and, in prerelease mode, the stamped - # manifests); checkout-at-sha plus the tag guard carry the actual - # integrity. + # CUT RELEASES ONLY (#223): the registries carry cut versions and + # nothing else, so there is no prerelease publish and no version + # stamping — the manifests publish exactly as committed. Consumers + # tracking `main` between cuts use the prerelease's artifacts or a + # git reference (README §Consuming). # Auth: OIDC (permissions.id-token) — the @polyengine packages must # exist on jsr.io and be linked to this repository (one-time # manual setup; see the #16 checklist). The scope's "actor must be @@ -139,20 +196,23 @@ jobs: # publish` skips already-published versions, so it converges # instead of stranding a release that claims unpublished JSR # versions. + # --allow-dirty: the working tree holds the untracked release + # artifacts built above; checkout-at-sha plus the tag guard carry + # the actual integrity. + if: inputs.release run: | echo "publishing @polyengine/* ${VERSION}" - if [ "$RELEASE" != "true" ]; then - for p in runtime translator wasi ct-runner; do - jq --arg v "$VERSION" '.version = $v' "$p/deno.json" > "$p/deno.json.tmp" - mv "$p/deno.json.tmp" "$p/deno.json" - done - fi deno publish --allow-dirty env: VERSION: ${{ steps.tag.outputs.version }} - RELEASE: ${{ inputs.release }} - name: create release + # RELEASE=true splices the guard's changes.md (Breaking / Changes, + # empty sections omitted) between the intro and the artifacts + # paragraph. A prerelease has no window to describe (it is one + # commit; the next cut describes the whole span) and, since #223, + # nothing on a registry to point at — so its notes say what it + # actually is: artifacts for a green main commit. run: | { if [ "$RELEASE" = "true" ]; then @@ -162,13 +222,17 @@ jobs: echo "and to npm under the same names (npm-publish.yml, triggered by this release)." echo "Still 0.x/unstable, but caret-honest: releases within a minor line stay" echo "compatible; breaking changes bump the minor. See README.md §Consuming." + if [ -s changes.md ]; then + echo + cat changes.md + fi else echo "Prerelease \`${TAG}\` at ${GITHUB_SHA}." echo - echo "The same commit is published to JSR as an unstable prerelease" - echo "of \`@polyengine/{runtime,translator,wasi,ct-runner}\` (\`${VERSION}\`," - echo "exact-pin-only), and to npm under the \`pre\` dist-tag; see README.md" - echo "for usage." + echo "Artifacts only: nothing here is published to JSR or npm — the" + echo "registries carry cut releases (\`v*\`) exclusively. To track \`main\`" + echo "between cuts, use the assets below or a git reference; see" + echo "README.md §Consuming." fi echo echo "Artifacts: the prebuilt translator shim wasm (size-tuned — the" @@ -198,6 +262,9 @@ jobs: RELEASE: ${{ inputs.release }} - name: publish to npm + # CUT RELEASES ONLY (#223), matching the JSR step: prereleases + # publish to no registry, so there is nothing to dispatch for them. + # # An explicit dispatch, not npm-publish.yml's `release: published` # trigger: GitHub deliberately does NOT start workflow runs from events # raised with GITHUB_TOKEN, so the release created above fires nothing. @@ -212,6 +279,7 @@ jobs: # second registry, and a failure there must not retroactively fail a # release whose JSR publish and GitHub release already succeeded. The # dispatched run is re-runnable on its own. + if: inputs.release run: | gh workflow run npm-publish.yml \ --repo "$GITHUB_REPOSITORY" \ diff --git a/AGENTS.md b/AGENTS.md index 4010727..0e770bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,8 +128,51 @@ Standing rules: plus runtime's A9 copy-identity constant `RUNTIME_VERSION` (runtime/src/embedder/copy.ts; `just test-runtime` pins the sync). `@polyengine/protocol` - versions independently; bumping its manifest publishes it for real on - the next green run. + versions independently; bumping its manifest publishes it for real at the + next cut. Prereleases (`pre-`, every green `main`) are GitHub + releases carrying artifacts only — JSR and npm are published by cut + releases exclusively (#223). +- Breaking changes are declared by PR **label**, one per package: + `breaking/{runtime,translator,wasi,ct-runner,protocol}`. A label asserts + that the PR breaks that package's published surface (caret-incompatible); + no label means compatible. The labels are read LIVE from the API wherever + they are consulted — never from an event payload — because retroactive + edits are expected and load-bearing: noticing at cut time that a merged PR + was mislabelled and fixing the label there is a supported workflow, and + the cut re-reads the whole window. `tools/version-guard/check.ts` + enforces them in three places (`just test-version-guard` covers its + logic): `pr` mode in `gha::core` (lockstep agreement, monotonicity, + label ↔ minor-bump agreement both ways, protocol-tear warning — an early + warning only, since label edits deliberately do not re-trigger CI); + `publish` mode in release.yml's publish step, both modes (in-tree + protocol must be byte-identical to the published version its manifest + names — the authoritative guard against the #219 tear, which PR-time + checks cannot own because they miss post-run label edits, direct pushes + to main, and their own staleness at cut time; on the prerelease path + nothing publishes, so it is early detection of a tear the next cut would + hit); and `cut` mode on + `release=true`, which turns the window's labels into the minor-bump + requirement and renders the release notes. +- **Cutting a release.** (1) Sanity pass, the step no machine can do: + enumerate the window — `gh pr list --search "base:main merged:>="` (or `gh api repos/$R/compare/v...main --jq + '.commits[].sha'`) plus `git log v..origin/main --first-parent + --oneline` for direct pushes — and read titles and diffs against the + labels. Fix labels retroactively NOW; a MISSING breaking label is the one + failure mode every mechanical check here is blind to. (2) Verify the + manifests against the final label set (breaking ⇒ the lockstep minor must + already be ahead of the last cut) and that `RUNTIME_VERSION` + (runtime/src/embedder/copy.ts) matches; check protocol's manifest too if + protocol moved. (3) Confirm the sha you are cutting has its green + `pre-` release — release.yml refuses otherwise, and that + refusal is the green-pipeline proof. (4) Dispatch release.yml with + `release=true` (`gh workflow run release.yml -f release=true --ref main`). + (5) Land the post-cut manifest-bump PR to the next patch immediately: the + four lockstep manifests + `RUNTIME_VERSION`. (6) Confirm the + npm-publish.yml run release.yml dispatched, and spot-check the dist-tags + (`npm view @polyengine/runtime dist-tags`) — `latest` must name the cut. + (The `pre` dist-tag is retired with the prerelease-publishing flow and + stays frozen wherever it last pointed.) - Two registries, one version (protocol rides its own manifest version on both — A10). JSR is published inline by release.yml; npm is published by npm-publish.yml, triggered by the GitHub release, from diff --git a/README.md b/README.md index adfa385..f06f741 100644 --- a/README.md +++ b/README.md @@ -122,21 +122,20 @@ not an alias: `@polyengine/*` starts a fresh `0.1.0` line, the `deltic.*` ones, and the `POLYENGINE_*` environment variables replace their `DELTIC_*` spellings. Nothing bridges the two — port in one step. -Between releases, every green `main` commit still publishes -`-pre.g` prereleases to JSR — the same short hash as the -corresponding `pre-` GitHub release, so a version names an -exact commit. Hash versions are not ordered, and semver ranges never -resolve to prereleases: **pin prereleases exactly and bump deliberately**. -The same prereleases go to npm under the `pre` dist-tag, leaving `latest` -to track cut releases. (One wrinkle, self-correcting: npm pins `latest` to -a package's first-ever publish whatever `--tag` says, so until the first -release is cut `latest` names the bootstrap prerelease. Pin explicitly -until then.) +Between releases, every green `main` commit still gets a +`pre-` [GitHub release](https://github.com/polymorph-components/polyengine/releases) +carrying the same artifacts a cut carries — the translator shim wasm, the +embedder bundle, `SHA256SUMS` — but **nothing is published to JSR or npm +between cuts**: the registries carry `vX.Y.Z` releases only. To track +`main`, use those release assets or a git reference and upgrade +deliberately. (The `-pre.g` versions on JSR and the npm +`pre` dist-tag are frozen leftovers of the earlier flow, which published +every green commit; nothing new lands there.) Deno's [minimum-dependency-age](https://docs.deno.com/runtime/packages/supply_chain/#minimum-dependency-age) -gate (24 h by default) applies to releases and prereleases alike, so a -fresh publish won't resolve on day zero. To consume same-day publishes -while keeping the gate for the rest of your graph, exempt the scope +gate (24 h by default) applies to every publish, so a freshly cut release +won't resolve on day zero. To consume same-day releases while keeping the +gate for the rest of your graph, exempt the scope (wildcard excludes work as of Deno 2.9): ```jsonc diff --git a/docs/consumers.md b/docs/consumers.md index 581d7a0..2280d80 100644 --- a/docs/consumers.md +++ b/docs/consumers.md @@ -32,8 +32,8 @@ Their jco blockers map one-for-one onto this project's proven strengths ([#16](https://github.com/polymorph-components/polyengine/issues/16), 2026-08-16) and are caret-honest — still 0.x/unstable, compatible within a minor line, breaking changes bump the minor — so consumers couple via caret - constraints (`jsr:@polyengine/*@^0.4.0`), with `pre-` prerelease - artifacts (exact pins) and git references for tracking `main` between + constraints (`jsr:@polyengine/*@^0.4.0`), with `pre-` GitHub + release artifacts and git references for tracking `main` between releases. That first caret line ran under the project's former name, in the `@deltic` scope, through `0.2.1`; the `@polyengine` line begins at `0.3.0` — the deltic-era `v*` tags survived the transfer, so lower diff --git a/justfile b/justfile index 5ee1c18..54254c1 100644 --- a/justfile +++ b/justfile @@ -16,7 +16,7 @@ ci: (gha::core) (gha::browser) # Includes the consumer smokes CI cannot run (they need the polymorph # checkouts; docs/consumers.md). # The full pre-commit pass (AGENTS.md "Gates"): everything. -gates: build test-rust test-protocol test-runtime test-wasi test-sockets-node test-ct-runner test-bundle publish-check test-npm examples test-translate conformance sched-seeds shells browsers smoke-tls smoke-c0 +gates: build test-rust test-protocol test-runtime test-wasi test-sockets-node test-ct-runner test-bundle test-version-guard publish-check test-npm examples test-translate conformance sched-seeds shells browsers smoke-tls smoke-c0 # Fast sanity: builds + native tests + type-checks, no suites. check: build test-rust @@ -110,6 +110,34 @@ test-ct-runner: shim fixtures test-bundle: shim deno test -A tools/release-bundle/ +# The release version guard's own unit tests (tools/version-guard/): semver +# ordering, and every pr/publish/cut check firing and passing against +# injected fixtures — no network, no `gh`, no repository state, because the +# guard's effects are injected. The guard itself runs in CI as gha::core's +# first step (`pr` mode, a no-op outside pull_request runs) and inside +# release.yml (`publish` and `cut` modes); this recipe is what keeps its +# decision logic honest before either of those sees it. +test-version-guard: + deno test -A tools/version-guard/ + +# The release version guard's early-warning pass (tools/version-guard/check.ts +# `pr`): lockstep agreement, monotonicity against the last cut, breaking/* +# label ↔ minor-bump agreement in both directions, and the protocol-tear +# warning (protocol/src moving without a protocol/deno.json bump — the #219 +# incident). Runs first in `gha::core` so a versioning mistake is the first +# thing a PR hears about, and exits 0 immediately when PR_NUMBER is unset, so +# push runs and a local `just ci` stay green without GitHub. Labels are read +# LIVE from the API (never the event payload) — but label edits deliberately +# do not re-trigger CI, so this is a warning: the enforcement point is +# release.yml's `cut` mode. +# Explicit permissions rather than -A: net is jsr.io only. --allow-run is +# NOT narrowed to `gh,git` — Deno refuses an allowlisted spawn whenever a +# dynamic-linker variable (LD_LIBRARY_PATH) is set in the environment, which +# is exactly the shape a nix-ish shell or a CI runner image can have, so the +# narrow form fails by environment rather than by policy. +version-guard-pr: + deno run --allow-net=jsr.io --allow-run --allow-read=. --allow-env tools/version-guard/check.ts pr + # The JSR publish checks (public-API type check, slow types, export and # import analyzability, config validation) — `deno task check` covers # none of them, so they only fired at publish time on main before this diff --git a/tools/version-guard/check.ts b/tools/version-guard/check.ts new file mode 100644 index 0000000..729fb34 --- /dev/null +++ b/tools/version-guard/check.ts @@ -0,0 +1,749 @@ +#!/usr/bin/env -S deno run -A +// The release version guard (three modes: `pr`, `publish`, `cut`). +// +// What it defends. The five packages publish under two rules (AGENTS.md +// §Versioning, README §Consuming): @polyengine/{runtime,translator,wasi, +// ct-runner} version in LOCKSTEP and their manifests always carry the NEXT +// release; @polyengine/protocol versions independently and publishes at the +// next cut after its manifest bumps. Breaking changes are +// declared per package by PR labels `breaking/`; no label means +// caret-compatible. Labels are MUTABLE and read live from the GitHub API +// every time — a label corrected after the merge still steers the cut, +// which is the point of reading them at cut time rather than trusting an +// event payload. +// +// The three modes, and why the enforcement point is where it is: +// +// pr — early warning, inside `gha::core` on PR runs. Lockstep +// agreement, monotonicity against the last cut, label/version +// agreement in both directions, and the protocol-tear warning. +// Advisory in the sense that matters: label edits deliberately +// do NOT re-trigger CI, so a PR-time verdict can be stale by +// merge time. Cheap to be wrong here; a re-run picks up fixes. +// publish — the AUTHORITATIVE tear guard, in release.yml's publish step, +// in BOTH modes. Registry publishes happen only at explicit +// cuts (#223), so the window between a PR-time verdict and a +// publish is no longer a race — but a PR-time verdict is still +// the wrong thing to trust: it misses label edits made after +// the run, commits pushed straight to main, and any run stale +// by the time the cut happens. This check runs at the publish +// itself, reads the tree being published, and cannot be stale. +// On the prerelease path it publishes nothing and is instead +// early detection: a red means the next CUT would tear. +// cut — label/version consistency for the whole release window, plus +// the release-notes fragment, in release.yml on release=true +// only. This is where a breaking label becomes a minor bump. +// +// The tear this exists for (the concrete incident): PR #219 changed +// protocol/src without bumping protocol/deno.json, because its merge +// resolution assumed 0.2.0 was still unpublished — under the pre-#223 flow +// every green main published, and one such run had published 0.2.0 hours +// earlier. Every publish after that skipped protocol as already-published, +// so runtime@0.4.0-pre.* shipped importing exports the published +// protocol@0.2.0 did not have: an import-time failure for anyone consuming +// the pair. #221 (protocol 0.2.1) repaired it. `publish` mode is the check +// that would have made that red, loudly, at the first publish after the +// merge. + +import { compareSemver, isMinorBumped, parseSemver } from "./semver.ts"; +import { + type Effects, + ghApi, + realEffects, + sha256Hex, +} from "./effects.ts"; + +/** The four packages that release as one version. */ +export const LOCKSTEP = ["runtime", "translator", "wasi", "ct-runner"]; +/** Canonical package order for label rendering (lockstep, then protocol). */ +export const PACKAGES = [...LOCKSTEP, "protocol"]; + +const JSR_PROTOCOL = "https://jsr.io/@polyengine/protocol"; + +export type Check = { name: string; ok: boolean; detail: string }; + +const pass = (name: string, detail: string): Check => ({ + name, + ok: true, + detail, +}); +const fail = (name: string, detail: string): Check => ({ + name, + ok: false, + detail, +}); + +// ----- shared helpers --------------------------------------------------------- + +const dec = new TextDecoder(); + +export async function readManifestVersion( + fx: Effects, + pkg: string, +): Promise { + const bytes = await fx.readFile(`${pkg}/deno.json`); + if (!bytes) throw new Error(`missing ${pkg}/deno.json`); + const version = JSON.parse(dec.decode(bytes))?.version; + if (typeof version !== "string") { + throw new Error(`${pkg}/deno.json has no string "version"`); + } + return version; +} + +/** The label names attached to a PR, as `breaking/` package names. */ +export function breakingPackages(labels: string[]): string[] { + const named = new Set( + labels + .filter((l) => l.startsWith("breaking/")) + .map((l) => l.slice("breaking/".length)), + ); + return PACKAGES.filter((p) => named.has(p)); +} + +/** JSR package metadata: `latest` plus the published version set. */ +export async function jsrProtocolLatest(fx: Effects): Promise { + const res = await fx.fetchText(`${JSR_PROTOCOL}/meta.json`); + if (res.status === 404) return null; + if (res.status !== 200) { + throw new Error(`jsr.io meta.json: HTTP ${res.status}`); + } + const latest = JSON.parse(res.body)?.latest; + return typeof latest === "string" ? latest : null; +} + +export type JsrManifest = Record; + +/** The published file manifest for one version, or null when unpublished. */ +export async function jsrProtocolManifest( + fx: Effects, + version: string, +): Promise { + const res = await fx.fetchText(`${JSR_PROTOCOL}/${version}_meta.json`); + if (res.status === 404) return null; + if (res.status !== 200) { + throw new Error(`jsr.io ${version}_meta.json: HTTP ${res.status}`); + } + const manifest = JSON.parse(res.body)?.manifest; + if (!manifest || typeof manifest !== "object") { + throw new Error(`jsr.io ${version}_meta.json has no manifest object`); + } + return manifest as JsrManifest; +} + +/** The version of the most recent CUT release, from `releases/latest` — + * which excludes prereleases by definition, so the automatic + * `pre-` stream never answers this question. null before the + * first cut. */ +export async function latestCutVersion( + fx: Effects, + repo: string, +): Promise<{ tag: string; version: string } | null> { + const { status, json } = await ghApi(fx, `repos/${repo}/releases/latest`); + if (status === 404) return null; + const tag = (json as { tag_name?: string })?.tag_name; + if (typeof tag !== "string" || !tag.startsWith("v")) { + throw new Error(`latest release tag ${JSON.stringify(tag)} is not v`); + } + return { tag, version: tag.slice(1) }; +} + +// ----- pr mode ---------------------------------------------------------------- + +export type PrEnv = { + prNumber: string; + baseSha: string; + repo: string; +}; + +/** Files that changed in the PR, and the base revision's runtime manifest. + * + * The PR checkout is SHALLOW, so the base commit has to be fetched before + * either question can be asked. `A...B` is the diff the PR actually + * proposes; a depth-1 fetch may leave no common ancestor for git to find, + * in which case we fall back to the two-dot diff (a superset that can + * include base-side changes — for these checks a false positive costs a + * re-read, a false negative costs a torn publish). + */ +export async function fetchBase( + fx: Effects, + baseSha: string, +): Promise<{ changed: string[]; baseRuntimeVersion: string | null }> { + await fx.run("git", ["fetch", "origin", baseSha, "--depth=1"]); + let diff = await fx.run("git", [ + "diff", + "--name-only", + `${baseSha}...HEAD`, + ]); + if (diff.code !== 0) { + diff = await fx.run("git", ["diff", "--name-only", baseSha, "HEAD"]); + } + if (diff.code !== 0) { + throw new Error( + `cannot diff against the PR base ${baseSha}:\n${diff.stderr.trim()}`, + ); + } + const show = await fx.run("git", ["show", `${baseSha}:runtime/deno.json`]); + const baseRuntimeVersion = show.code === 0 + ? JSON.parse(show.stdout)?.version ?? null + : null; + return { + changed: diff.stdout.split("\n").map((l) => l.trim()).filter(Boolean), + baseRuntimeVersion, + }; +} + +export async function prChecks(fx: Effects, env: PrEnv): Promise { + const checks: Check[] = []; + + // 1. Lockstep agreement. + const versions = new Map(); + for (const pkg of LOCKSTEP) { + versions.set(pkg, await readManifestVersion(fx, pkg)); + } + const lockstep = versions.get("runtime")!; + const disagreeing = [...versions].filter(([, v]) => v !== lockstep); + if (disagreeing.length > 0) { + checks.push(fail( + "lockstep", + `the lockstep manifests disagree: ${ + [...versions].map(([p, v]) => `${p}=${v}`).join(" ") + } — all four of ${LOCKSTEP.join(", ")} must carry the same NEXT version`, + )); + } else { + checks.push(pass("lockstep", `${LOCKSTEP.join(", ")} all at ${lockstep}`)); + } + + const protocolVersion = await readManifestVersion(fx, "protocol"); + const labels = await prLabels(fx, env); + const breaking = breakingPackages(labels); + const lockstepBreaking = breaking.filter((p) => p !== "protocol"); + + // 2. The last cut. Only checks 3 and 4 have a referent in it: both ask + // "where is this version relative to the last RELEASED one?". Checks 5-7 + // do not — 5 compares the PR's base to its head, 6 and 7 compare against + // the protocol version JSR has published — so they run unconditionally, + // below and outside this block. That placement is deliberate: the tear + // warning is the check that must never be dead, and a repo with no cut + // release yet is exactly the state in which skipping it would be least + // noticed. + const cut = await latestCutVersion(fx, env.repo); + if (!cut) { + checks.push(pass( + "last-cut", + "no cut release yet — the monotonicity and breaking-label-bump checks have no referent and are skipped", + )); + } else { + checks.push(pass("last-cut", `latest cut release is ${cut.tag}`)); + + // 3. Monotonic: the manifests carry the NEXT release, so they must be + // ahead of the last one — otherwise a cut would republish it. + if (compareSemver(lockstep, cut.version) > 0) { + checks.push(pass("monotonic", `lockstep ${lockstep} > last cut ${cut.version}`)); + } else { + checks.push(fail( + "monotonic", + `lockstep manifests are at ${lockstep}, not ahead of the last cut ${cut.version} — the manifests must carry the NEXT release; bump the four ${LOCKSTEP.join("/")} manifests (and RUNTIME_VERSION in runtime/src/embedder/copy.ts)`, + )); + } + + // 4. A breaking label asserts the published surface breaks, and + // caret-honesty makes that a minor bump within THIS release cycle. + if (lockstepBreaking.length > 0) { + if (isMinorBumped(lockstep, cut.version)) { + checks.push(pass( + "breaking-label-bumped", + `breaking/${lockstepBreaking.join(", breaking/")} with ${lockstep} > minor of last cut ${cut.version}`, + )); + } else { + checks.push(fail( + "breaking-label-bumped", + `this PR is labelled breaking/${lockstepBreaking.join(", breaking/")} but the lockstep manifests are still on the ${lockstep} minor line, which the last cut ${cut.version} already occupies — bump the lockstep MINOR (all four manifests + RUNTIME_VERSION), or drop the label if the change is caret-compatible`, + )); + } + } + } + + // 5. The converse of 4, answered entirely from the PR itself (base vs + // head), so it holds before the first cut too. A minor bump without a + // label is either a missing label or an unintended bump; a PATCH bump + // needs no label (that is the routine post-cut manifest-bump PR). + const { changed, baseRuntimeVersion } = await fetchBase(fx, env.baseSha); + if (baseRuntimeVersion === null) { + checks.push(fail( + "minor-bump-labelled", + `cannot read runtime/deno.json at the PR base ${env.baseSha} — the base commit did not fetch, so the label/bump agreement cannot be checked`, + )); + } else { + const bumpedHere = isMinorBumped(lockstep, baseRuntimeVersion); + if (bumpedHere && lockstepBreaking.length === 0) { + checks.push(fail( + "minor-bump-labelled", + `this PR bumps the lockstep minor (${baseRuntimeVersion} -> ${lockstep}) but carries no breaking/{${LOCKSTEP.join(",")}} label — label it with the package(s) whose published surface breaks, or make the bump a patch`, + )); + } else { + checks.push(pass( + "minor-bump-labelled", + bumpedHere + ? `minor bump ${baseRuntimeVersion} -> ${lockstep} is labelled` + : `no lockstep minor bump (base ${baseRuntimeVersion}, head ${lockstep})`, + )); + } + } + + // 6/7 are measured against the PUBLISHED protocol, not the last cut: + // protocol is outside the lockstep (embedder-api A10), publishes on its + // own manifest, and can therefore tear whether or not this repo has ever + // cut a release. + const publishedProtocol = await jsrProtocolLatest(fx); + checks.push(...protocolPrChecks({ + protocolVersion, + publishedProtocol, + protocolBreaking: breaking.includes("protocol"), + changed, + })); + + return checks; +} + +async function prLabels(fx: Effects, env: PrEnv): Promise { + // Live labels, never the event payload: a retroactive label edit must + // count, and the payload is frozen at the event that started the run. + const { status, json } = await ghApi( + fx, + `repos/${env.repo}/issues/${env.prNumber}/labels`, + ); + if (status !== 200) { + throw new Error(`cannot read labels for PR #${env.prNumber}: HTTP ${status}`); + } + return (json as { name?: string }[]).map((l) => l.name ?? ""); +} + +/** Checks 6 and 7, split out so the tear warning is testable on its own. */ +export function protocolPrChecks(input: { + protocolVersion: string; + publishedProtocol: string | null; + protocolBreaking: boolean; + changed: string[]; +}): Check[] { + const { protocolVersion, publishedProtocol, protocolBreaking, changed } = + input; + const checks: Check[] = []; + if (publishedProtocol === null) { + checks.push(pass( + "protocol", + "@polyengine/protocol is not published yet — protocol checks skipped", + )); + return checks; + } + + // 6. + if (protocolBreaking) { + if (isMinorBumped(protocolVersion, publishedProtocol)) { + checks.push(pass( + "protocol-breaking-label", + `breaking/protocol with manifest ${protocolVersion} > minor of published ${publishedProtocol}`, + )); + } else { + checks.push(fail( + "protocol-breaking-label", + `this PR is labelled breaking/protocol but protocol/deno.json is at ${protocolVersion}, on the same minor line as the published ${publishedProtocol} — bump protocol's MINOR, or drop the label if the change is caret-compatible`, + )); + } + } + + // 7. The tear, caught early: protocol source moving without a version + // move means the next publish silently skips protocol as + // already-published and its dependents ship against stale exports. + // The reference point is JSR's CURRENT latest, which is a cut version: + // since #223 a merged bump stays unpublished until the next cut, so + // replaying this check on an already-merged protocol PR still passes. + // The question it asks is always the live one — "would publishing from + // this tree tear?". + const touched = changed.filter((f) => + f.startsWith("protocol/src/") || f === "protocol/deno.json" + ); + if (touched.length > 0) { + if (compareSemver(protocolVersion, publishedProtocol) > 0) { + checks.push(pass( + "protocol-tear", + `protocol changed and its manifest ${protocolVersion} is ahead of the published ${publishedProtocol}`, + )); + } else { + checks.push(fail( + "protocol-tear", + `this PR changes ${touched.join(", ")} but protocol/deno.json is at ${protocolVersion}, which is already published on JSR (latest ${publishedProtocol}) — the next publish would skip protocol as already-published and ship its dependents against the OLD protocol. Bump protocol/deno.json or revert the protocol/src change.`, + )); + } + } else { + checks.push(pass("protocol-tear", "no protocol/src or protocol/deno.json change")); + } + return checks; +} + +// ----- publish mode ----------------------------------------------------------- + +/** Every in-tree file that `deno publish` would upload for protocol: + * protocol/src/**\/* plus the manifest (protocol/deno.json excludes tests/). + * Returned as JSR manifest paths — package-root-relative, leading slash. */ +export async function inTreeProtocolFiles(fx: Effects): Promise { + const src = await fx.listFiles("protocol/src"); + return [...src.map((f) => `/src/${f}`), "/deno.json"].sort(); +} + +/** + * The authoritative tear guard: if protocol's manifest version is ALREADY + * published, the in-tree protocol must be byte-identical to what was + * published under that version — because `deno publish` will skip it, and + * every dependent published in the same run will resolve to the registry's + * copy, not this tree's. + * + * Identity is exact and bidirectional (every published path matches in + * tree, every publishable in-tree file appears in the manifest) BY DESIGN: + * this check's red must be unarguable, and a "meaningful difference" + * heuristic is exactly the kind of thing that talks a release into + * shipping. Softer variants are parked in issue #222 — do not implement + * them here. + */ +export async function publishChecks( + fx: Effects, + version: string, +): Promise { + const manifest = await jsrProtocolManifest(fx, version); + if (manifest === null) { + return [pass( + "protocol-identity", + `@polyengine/protocol@${version} is not published — this run publishes it`, + )]; + } + + const problems: string[] = []; + const publishedPaths = Object.keys(manifest).sort(); + for (const path of publishedPaths) { + const bytes = await fx.readFile(`protocol${path}`); + if (!bytes) { + problems.push(`missing in tree: protocol${path}`); + continue; + } + const want = manifest[path].checksum; + const got = `sha256-${await sha256Hex(bytes)}`; + if (got !== want) { + problems.push( + `content differs: protocol${path} (published ${want}, in tree ${got})`, + ); + } + } + const published = new Set(publishedPaths); + for (const path of await inTreeProtocolFiles(fx)) { + if (!published.has(path)) { + problems.push(`not in the published version: protocol${path}`); + } + } + + if (problems.length === 0) { + return [pass( + "protocol-identity", + `in-tree protocol is byte-identical to the published @polyengine/protocol@${version} (${publishedPaths.length} files)`, + )]; + } + return [fail( + "protocol-identity", + `in-tree protocol differs from the published @polyengine/protocol@${version} — bump protocol/deno.json (or revert the protocol change). This run would SKIP protocol as already-published and publish its dependents against the registry's older copy:\n ${ + problems.join("\n ") + }`, + )]; +} + +// ----- cut mode --------------------------------------------------------------- + +export type CutPr = { + number: number; + title: string; + labels: string[]; +}; + +export type DirectCommit = { sha: string; subject: string }; + +export type ReleaseWindow = { + prs: CutPr[]; + direct: DirectCommit[]; +}; + +/** The PRs and direct commits between the last cut tag and the cut sha, in + * commit order, deduped by PR number. + * + * Labels come back on the association response, which means CURRENT labels + * — a label corrected after the merge feeds the guard here, which is the + * whole reason the enforcement point is the cut and not the PR. + */ +export async function releaseWindow( + fx: Effects, + repo: string, + lastTag: string, + sha: string, +): Promise { + const { status, json } = await ghApi( + fx, + `repos/${repo}/compare/${lastTag}...${sha}`, + ); + if (status !== 200) { + throw new Error(`cannot compare ${lastTag}...${sha}: HTTP ${status}`); + } + const cmp = json as { + total_commits?: number; + commits?: { sha: string; commit: { message: string } }[]; + }; + const commits = cmp.commits ?? []; + // The compare endpoint returns at most 250 commits. At this cadence a + // release window is far below that, so the cap is not paginated around — + // it is refused, loudly, rather than silently truncating the notes and + // the label scan. + if (typeof cmp.total_commits === "number" && cmp.total_commits > commits.length) { + throw new Error( + `${lastTag}...${sha} spans ${cmp.total_commits} commits but the compare endpoint returned ${commits.length} (250-commit cap) — the label scan would be incomplete; cut a release more often or paginate this call`, + ); + } + + const prs: CutPr[] = []; + const seen = new Set(); + const direct: DirectCommit[] = []; + for (const c of commits) { + const res = await ghApi(fx, `repos/${repo}/commits/${c.sha}/pulls`); + if (res.status !== 200) { + throw new Error(`cannot list PRs for ${c.sha}: HTTP ${res.status}`); + } + const associated = res.json as { + number: number; + title: string; + labels?: { name?: string }[]; + }[]; + if (associated.length === 0) { + direct.push({ + sha: c.sha, + subject: c.commit.message.split("\n")[0], + }); + continue; + } + for (const pr of associated) { + if (seen.has(pr.number)) continue; + seen.add(pr.number); + prs.push({ + number: pr.number, + title: pr.title, + labels: (pr.labels ?? []).map((l) => l.name ?? ""), + }); + } + } + return { prs, direct }; +} + +/** protocol/deno.json's version at a given ref, via the contents API (the + * release runner's checkout is shallow and has no history at the tag). */ +export async function protocolVersionAtRef( + fx: Effects, + repo: string, + ref: string, +): Promise { + const { status, json } = await ghApi( + fx, + `repos/${repo}/contents/protocol/deno.json?ref=${ref}`, + ); + if (status !== 200) { + throw new Error(`cannot read protocol/deno.json at ${ref}: HTTP ${status}`); + } + const content = (json as { content?: string }).content ?? ""; + return JSON.parse(atob(content.replace(/\n/g, ""))).version; +} + +export function cutGuards(input: { + version: string; + lastCutVersion: string; + protocolVersion: string; + protocolAtLastCut: string; + window: ReleaseWindow; +}): Check[] { + const checks: Check[] = []; + const { version, lastCutVersion, window } = input; + + const lockstepBreaking = window.prs.filter((pr) => + breakingPackages(pr.labels).some((p) => p !== "protocol") + ); + if (lockstepBreaking.length === 0) { + checks.push(pass( + "cut-lockstep-labels", + `no breaking/{${LOCKSTEP.join(",")}} label in this window (${window.prs.length} PRs)`, + )); + } else if (isMinorBumped(version, lastCutVersion)) { + checks.push(pass( + "cut-lockstep-labels", + `breaking PRs ${lockstepBreaking.map((p) => `#${p.number}`).join(", ")}; ${version} bumps the minor over ${lastCutVersion}`, + )); + } else { + checks.push(fail( + "cut-lockstep-labels", + `this window contains breaking changes (${ + lockstepBreaking + .map((p) => `#${p.number} [${breakingPackages(p.labels).join(",")}]`) + .join(", ") + }) but ${version} is on the same minor line as the last cut ${lastCutVersion} — caret-honesty requires a MINOR bump; bump the four lockstep manifests + RUNTIME_VERSION, or correct the labels if they are wrong`, + )); + } + + const protocolBreaking = window.prs.filter((pr) => + pr.labels.includes("breaking/protocol") + ); + if (protocolBreaking.length === 0) { + checks.push(pass("cut-protocol-labels", "no breaking/protocol label in this window")); + } else if (isMinorBumped(input.protocolVersion, input.protocolAtLastCut)) { + checks.push(pass( + "cut-protocol-labels", + `breaking PRs ${protocolBreaking.map((p) => `#${p.number}`).join(", ")}; protocol ${input.protocolVersion} bumps the minor over ${input.protocolAtLastCut}`, + )); + } else { + checks.push(fail( + "cut-protocol-labels", + `this window contains breaking/protocol changes (${ + protocolBreaking.map((p) => `#${p.number}`).join(", ") + }) but protocol is at ${input.protocolVersion}, on the same minor line as ${input.protocolAtLastCut} at the last cut — bump protocol's MINOR, or correct the labels`, + )); + } + + return checks; +} + +/** The release-notes fragment: GitHub-flavoured markdown, breaking changes + * first (with the packages they break), then everything else, then commits + * that landed without a PR. Empty sections are omitted entirely. */ +export function renderNotes(window: ReleaseWindow): string { + const breaking: string[] = []; + const changes: string[] = []; + for (const pr of window.prs) { + const pkgs = breakingPackages(pr.labels); + if (pkgs.length > 0) { + breaking.push(`- ${pr.title} (#${pr.number}) — breaks: ${pkgs.join(", ")}`); + } else { + changes.push(`- ${pr.title} (#${pr.number})`); + } + } + for (const c of window.direct) { + changes.push(`- ${c.subject} (${c.sha.slice(0, 7)})`); + } + + const out: string[] = []; + if (breaking.length > 0) out.push("## Breaking", "", ...breaking, ""); + if (changes.length > 0) out.push("## Changes", "", ...changes, ""); + return out.join("\n"); +} + +export async function cutChecks( + fx: Effects, + input: { repo: string; sha: string; version: string; out: string | null }, +): Promise { + const cut = await latestCutVersion(fx, input.repo); + if (!cut) { + // First cut ever: no window to scan, no prior version to be ahead of. + if (input.out) await fx.writeFile(input.out, ""); + return [pass("cut", "no previous cut release — nothing to compare against")]; + } + + const window = await releaseWindow(fx, input.repo, cut.tag, input.sha); + const checks = cutGuards({ + version: input.version, + lastCutVersion: cut.version, + protocolVersion: await readManifestVersion(fx, "protocol"), + protocolAtLastCut: await protocolVersionAtRef(fx, input.repo, cut.tag), + window, + }); + if (input.out) await fx.writeFile(input.out, renderNotes(window)); + checks.push(pass( + "cut-notes", + `${window.prs.length} PRs and ${window.direct.length} direct commits since ${cut.tag}${ + input.out ? ` -> ${input.out}` : "" + }`, + )); + return checks; +} + +// ----- main ------------------------------------------------------------------- + +function report(fx: Effects, mode: string, checks: Check[]): number { + let failed = 0; + for (const c of checks) { + if (c.ok) { + fx.log(`version-guard ${mode}: PASS ${c.name}: ${c.detail}`); + } else { + failed++; + fx.log(`version-guard ${mode}: FAIL ${c.name}: ${c.detail}`); + } + } + return failed === 0 ? 0 : 1; +} + +export async function main(fx: Effects, argv: string[]): Promise { + const mode = argv[0]; + const flag = (name: string): string | null => { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null; + }; + const required = (name: string): string => { + const v = fx.env(name); + if (!v) throw new Error(`${name} is required in ${mode} mode`); + return v; + }; + + switch (mode) { + case "pr": { + const prNumber = fx.env("PR_NUMBER"); + if (!prNumber) { + // Push runs and local `just ci` have no PR context. Skipping keeps + // the guard as gha::core's first step without making the local + // gate depend on GitHub. + fx.log("version-guard pr: SKIP — no PR_NUMBER (not a pull_request run)"); + return 0; + } + return report(fx, "pr", await prChecks(fx, { + prNumber, + baseSha: required("PR_BASE_SHA"), + repo: required("GITHUB_REPOSITORY"), + })); + } + case "publish": { + // The override exists for rehearsing the failure path against the + // real registry (point it at an older published version and watch + // the mismatch fire); release.yml never passes it. + const version = flag("--protocol-version") ?? + await readManifestVersion(fx, "protocol"); + return report(fx, "publish", await publishChecks(fx, version)); + } + case "cut": { + return report( + fx, + "cut", + await cutChecks(fx, { + repo: required("GITHUB_REPOSITORY"), + sha: required("GITHUB_SHA"), + version: required("VERSION"), + out: flag("--out"), + }), + ); + } + default: + fx.log(`usage: check.ts [--out ] [--protocol-version ]`); + return 2; + } +} + +if (import.meta.main) { + const fx = realEffects(); + let code: number; + try { + code = await main(fx, Deno.args); + } catch (e) { + fx.log(`version-guard: ERROR ${e instanceof Error ? e.message : e}`); + code = 1; + } + Deno.exit(code); +} + +// parseSemver is re-exported so the tests (and any future caller) get the +// whole surface from one module. +export { compareSemver, isMinorBumped, parseSemver }; diff --git a/tools/version-guard/check_test.ts b/tools/version-guard/check_test.ts new file mode 100644 index 0000000..af5f606 --- /dev/null +++ b/tools/version-guard/check_test.ts @@ -0,0 +1,666 @@ +// Unit tests for the release version guard. No network, no `gh`, no +// repository state: every check runs against a fake Effects, so the tests +// exercise the real decision logic (the fixtures below are shaped from real +// jsr.io and GitHub API responses). + +// Assertions are local, matching the rest of tools/ (bundle_test.ts): no +// test-only dependency enters the workspace lockfile for a guard whose +// whole point is that it cannot be knocked over by a registry. +import type { Effects, HttpResponse } from "./effects.ts"; +import { sha256Hex } from "./effects.ts"; +import { + compareSemver, + cutChecks, + cutGuards, + isMinorBumped, + main, + parseSemver, + prChecks, + protocolPrChecks, + publishChecks, + renderNotes, +} from "./check.ts"; + +function assert(cond: boolean, msg = "assertion failed"): void { + if (!cond) throw new Error(msg); +} + +function assertEquals(got: T, want: T, msg?: string): void { + const g = JSON.stringify(got); + const w = JSON.stringify(want); + if (g !== w) throw new Error(`${msg ?? "mismatch"}: got ${g}, want ${w}`); +} + +function assertStringIncludes(got: string, needle: string): void { + if (!got.includes(needle)) { + throw new Error(`expected to contain ${JSON.stringify(needle)}, got ${JSON.stringify(got)}`); + } +} + +// ----- fake effects ----------------------------------------------------------- + +type FakeSpec = { + http?: Record; + gh?: Record; + git?: Record; + files?: Record; + env?: Record; +}; + +type Fake = Effects & { logs: string[]; written: Record }; + +function fake(spec: FakeSpec): Fake { + const logs: string[] = []; + const written: Record = {}; + const files = spec.files ?? {}; + const enc = new TextEncoder(); + return { + logs, + written, + fetchText(url) { + const res = spec.http?.[url]; + return Promise.resolve(res ?? { status: 404, body: "not found" }); + }, + run(cmd, args) { + const key = args.join(" "); + const table = cmd === "gh" ? spec.gh : spec.git; + const hit = table?.[key]; + if (!hit) { + return Promise.resolve({ + code: 1, + stdout: "", + stderr: `unstubbed: ${cmd} ${key}`, + }); + } + return Promise.resolve({ + code: hit.code ?? 0, + stdout: hit.stdout ?? "", + stderr: hit.stderr ?? "", + }); + }, + readFile(path) { + const body = files[path]; + return Promise.resolve(body === undefined ? null : enc.encode(body)); + }, + listFiles(dir) { + const prefix = `${dir}/`; + return Promise.resolve( + Object.keys(files) + .filter((f) => f.startsWith(prefix)) + .map((f) => f.slice(prefix.length)) + .sort(), + ); + }, + writeFile(path, text) { + written[path] = text; + return Promise.resolve(); + }, + env: (name) => spec.env?.[name], + log: (m) => void logs.push(m), + }; +} + +const manifest = (pkg: string, version: string) => + JSON.stringify({ name: `@polyengine/${pkg}`, version }); + +const lockstepFiles = (v: string) => + Object.fromEntries( + ["runtime", "translator", "wasi", "ct-runner"].map(( + p, + ) => [`${p}/deno.json`, manifest(p, v)]), + ); + +const ghJson = (value: unknown) => ({ stdout: JSON.stringify(value) }); +const gh404 = { code: 1, stderr: "gh: Not Found (HTTP 404)" }; + +const failed = (checks: { ok: boolean; name: string }[]) => + checks.filter((c) => !c.ok).map((c) => c.name); +const detail = (checks: { name: string; detail: string }[], name: string) => + checks.find((c) => c.name === name)!.detail; + +// ----- semver ----------------------------------------------------------------- + +Deno.test("semver: parse rejects non-versions", () => { + for (const bad of ["", "1.2", "v1.2.3", "1.2.3.4", "1.2.x", "1.2.3-"]) { + let threw = false; + try { + parseSemver(bad); + } catch { + threw = true; + } + assert(threw, `expected ${JSON.stringify(bad)} to be rejected`); + } + assertEquals(parseSemver("0.4.0"), { + major: 0, + minor: 4, + patch: 0, + pre: [], + }); + assertEquals(parseSemver("0.4.0-pre.g1a2b3c4+meta").pre, ["pre", "g1a2b3c4"]); +}); + +Deno.test("semver: compare covers the ordering edges", () => { + const lt = (a: string, b: string) => { + assertEquals(compareSemver(a, b), -1, `${a} < ${b}`); + assertEquals(compareSemver(b, a), 1, `${b} > ${a}`); + }; + lt("0.3.9", "0.4.0"); + lt("0.4.0", "0.4.1"); + lt("0.9.0", "1.0.0"); + // A prerelease sorts BEFORE its release — the property the retired + // `-pre.g` registry scheme rested on; its versions remain + // published, so the ordering edge stays pinned. + lt("0.4.0-pre.g1a2b3c4", "0.4.0"); + // Numeric identifiers compare numerically, not as strings. + lt("0.4.0-pre.2", "0.4.0-pre.10"); + // Numeric sorts before alphanumeric; more identifiers win ties. + lt("0.4.0-pre.2", "0.4.0-pre.g2"); + lt("0.4.0-pre", "0.4.0-pre.1"); + assertEquals(compareSemver("0.4.0", "0.4.0+build"), 0); +}); + +Deno.test("semver: isMinorBumped is a minor-LINE question", () => { + assert(isMinorBumped("0.5.0", "0.4.3")); + assert(!isMinorBumped("0.4.3", "0.4.0")); + assert(!isMinorBumped("0.4.0", "0.4.0")); + assert(isMinorBumped("1.0.0", "0.9.9")); + assert(!isMinorBumped("0.9.9", "1.0.0")); +}); + +// ----- pr mode ---------------------------------------------------------------- + +const JSR_META = "https://jsr.io/@polyengine/protocol/meta.json"; + +// Shaped from the real https://jsr.io/@polyengine/protocol/meta.json. +const jsrMeta = (latest: string): HttpResponse => ({ + status: 200, + body: JSON.stringify({ + scope: "polyengine", + name: "protocol", + latest, + versions: { "0.1.0": {}, "0.2.0": {}, [latest]: {} }, + }), +}); + +function prFake(over: { + lockstep?: string; + perPackage?: Record; + protocol?: string; + labels?: string[]; + latestTag?: string | null; + baseRuntime?: string | null; + changed?: string[]; + published?: string; +}) { + const lockstep = over.lockstep ?? "0.4.0"; + const files: Record = { + ...lockstepFiles(lockstep), + "protocol/deno.json": manifest("protocol", over.protocol ?? "0.2.1"), + }; + for (const [p, v] of Object.entries(over.perPackage ?? {})) { + files[`${p}/deno.json`] = manifest(p, v); + } + const latestTag = over.latestTag === undefined ? "v0.3.1" : over.latestTag; + return fake({ + files, + env: { + PR_NUMBER: "223", + PR_BASE_SHA: "base0000", + GITHUB_REPOSITORY: "polymorph-components/polyengine", + }, + http: { [JSR_META]: jsrMeta(over.published ?? "0.2.1") }, + gh: { + "api repos/polymorph-components/polyengine/releases/latest": + latestTag === null ? gh404 : ghJson({ tag_name: latestTag }), + "api repos/polymorph-components/polyengine/issues/223/labels": ghJson( + (over.labels ?? []).map((name) => ({ name })), + ), + }, + git: { + "fetch origin base0000 --depth=1": {}, + "diff --name-only base0000...HEAD": { + stdout: (over.changed ?? ["runtime/src/x.ts"]).join("\n"), + }, + "show base0000:runtime/deno.json": over.baseRuntime === null + ? { code: 1, stderr: "fatal: path does not exist" } + : { stdout: manifest("runtime", over.baseRuntime ?? "0.4.0") }, + }, + }); +} + +const PR_ENV = { + prNumber: "223", + baseSha: "base0000", + repo: "polymorph-components/polyengine", +}; + +Deno.test("pr: a clean PR passes every check", async () => { + const checks = await prChecks(prFake({}), PR_ENV); + assertEquals(failed(checks), []); +}); + +Deno.test("pr: a half-bumped workspace fails lockstep", async () => { + const checks = await prChecks( + prFake({ perPackage: { wasi: "0.3.1" } }), + PR_ENV, + ); + assertEquals(failed(checks), ["lockstep"]); + assertStringIncludes(detail(checks, "lockstep"), "wasi=0.3.1"); +}); + +Deno.test("pr: manifests not ahead of the last cut fail monotonicity", async () => { + const checks = await prChecks( + prFake({ lockstep: "0.3.1", baseRuntime: "0.3.1" }), + PR_ENV, + ); + assert(failed(checks).includes("monotonic")); + assertStringIncludes(detail(checks, "monotonic"), "NEXT release"); +}); + +Deno.test("pr: before the first cut, ONLY monotonicity and the label bump are skipped", async () => { + const checks = await prChecks(prFake({ latestTag: null }), PR_ENV); + assertEquals(failed(checks), []); + assertStringIncludes(detail(checks, "last-cut"), "no cut release yet"); + // 3 and 4 have no referent without a cut... + assertEquals(checks.find((c) => c.name === "monotonic"), undefined); + assertEquals(checks.find((c) => c.name === "breaking-label-bumped"), undefined); + // ...but 5-7 do not depend on one and must still be alive. + assert(checks.some((c) => c.name === "minor-bump-labelled")); + assert(checks.some((c) => c.name === "protocol-tear")); +}); + +Deno.test("pr: the tear warning is NOT dead before the first cut", async () => { + // The regression this pins: 5-7 were once nested inside the "a cut + // exists" branch, so a repo that had never cut a release skipped the one + // check that must never be silent. + const checks = await prChecks( + prFake({ + latestTag: null, + protocol: "0.2.0", + published: "0.2.0", + changed: ["protocol/src/cloneable.ts"], + }), + PR_ENV, + ); + assertEquals(failed(checks), ["protocol-tear"]); +}); + +Deno.test("pr: an unlabelled minor bump fails before the first cut too", async () => { + const checks = await prChecks( + prFake({ latestTag: null, lockstep: "0.5.0", baseRuntime: "0.4.1" }), + PR_ENV, + ); + assertEquals(failed(checks), ["minor-bump-labelled"]); +}); + +Deno.test("pr: a breaking label without a minor bump fails", async () => { + const checks = await prChecks( + // last cut v0.4.0, manifests 0.4.1 — a patch line, not a new minor. + prFake({ + lockstep: "0.4.1", + baseRuntime: "0.4.1", + latestTag: "v0.4.0", + labels: ["breaking/runtime"], + }), + PR_ENV, + ); + assertEquals(failed(checks), ["breaking-label-bumped"]); + assertStringIncludes(detail(checks, "breaking-label-bumped"), "breaking/runtime"); +}); + +Deno.test("pr: a breaking label with the minor bumped passes", async () => { + const checks = await prChecks( + prFake({ + lockstep: "0.5.0", + baseRuntime: "0.4.1", + latestTag: "v0.4.0", + labels: ["breaking/wasi", "breaking/ct-runner"], + }), + PR_ENV, + ); + assertEquals(failed(checks), []); +}); + +Deno.test("pr: an unlabelled minor bump fails the converse check", async () => { + const checks = await prChecks( + prFake({ lockstep: "0.5.0", baseRuntime: "0.4.1", latestTag: "v0.4.0" }), + PR_ENV, + ); + assertEquals(failed(checks), ["minor-bump-labelled"]); + assertStringIncludes(detail(checks, "minor-bump-labelled"), "0.4.1 -> 0.5.0"); +}); + +Deno.test("pr: the post-cut patch bump needs no label", async () => { + const checks = await prChecks( + prFake({ lockstep: "0.4.1", baseRuntime: "0.4.0", latestTag: "v0.4.0" }), + PR_ENV, + ); + assertEquals(failed(checks), []); +}); + +Deno.test("pr: an unfetchable base is a failure, not a silent skip", async () => { + const checks = await prChecks(prFake({ baseRuntime: null }), PR_ENV); + assertEquals(failed(checks), ["minor-bump-labelled"]); + assertStringIncludes(detail(checks, "minor-bump-labelled"), "did not fetch"); +}); + +Deno.test("pr: breaking/protocol needs protocol's minor bumped", async () => { + const bad = protocolPrChecks({ + protocolVersion: "0.2.2", + publishedProtocol: "0.2.1", + protocolBreaking: true, + changed: ["protocol/src/mod.ts", "protocol/deno.json"], + }); + assertEquals(failed(bad), ["protocol-breaking-label"]); + const good = protocolPrChecks({ + protocolVersion: "0.3.0", + publishedProtocol: "0.2.1", + protocolBreaking: true, + changed: ["protocol/src/mod.ts", "protocol/deno.json"], + }); + assertEquals(failed(good), []); +}); + +Deno.test("pr: the tear, caught early — protocol/src moves, manifest does not", async () => { + // Exactly the #219 shape: protocol source changed, protocol/deno.json + // left at a version JSR has already published. + const checks = await prChecks( + prFake({ + protocol: "0.2.0", + published: "0.2.0", + changed: ["protocol/src/cloneable.ts", "runtime/src/x.ts"], + }), + PR_ENV, + ); + assertEquals(failed(checks), ["protocol-tear"]); + const d = detail(checks, "protocol-tear"); + assertStringIncludes(d, "protocol/src/cloneable.ts"); + assertStringIncludes(d, "Bump protocol/deno.json or revert"); +}); + +Deno.test("pr: a protocol change WITH a bump passes the tear check", async () => { + const checks = await prChecks( + prFake({ + protocol: "0.2.2", + published: "0.2.1", + changed: ["protocol/src/cloneable.ts"], + }), + PR_ENV, + ); + assertEquals(failed(checks), []); +}); + +Deno.test("pr: no PR_NUMBER is a skip, not a failure (push runs, local `just ci`)", async () => { + const fx = fake({ env: {} }); + assertEquals(await main(fx, ["pr"]), 0); + assertStringIncludes(fx.logs.join("\n"), "SKIP"); +}); + +// ----- publish mode ----------------------------------------------------------- + +const PROTOCOL_SRC = { + "protocol/src/mod.ts": "export * from './cloneable.ts';\n", + "protocol/src/cloneable.ts": "export const cloneable = 1;\n", + "protocol/deno.json": manifest("protocol", "0.2.1"), +}; + +/** A published-version manifest matching the given in-tree files, in the + * real `_meta.json` shape. */ +async function metaFor( + files: Record, + omit: string[] = [], +): Promise { + const enc = new TextEncoder(); + const m: Record = {}; + for (const [path, body] of Object.entries(files)) { + if (omit.includes(path)) continue; + const bytes = enc.encode(body); + m[path.replace(/^protocol/, "")] = { + size: bytes.length, + checksum: `sha256-${await sha256Hex(bytes)}`, + }; + } + return { + status: 200, + body: JSON.stringify({ manifest: m, moduleGraph2: {} }), + }; +} + +const META_URL = (v: string) => `https://jsr.io/@polyengine/protocol/${v}_meta.json`; + +Deno.test("publish: an unpublished version passes — this run publishes it", async () => { + const fx = fake({ files: PROTOCOL_SRC, http: {} }); + const checks = await publishChecks(fx, "0.2.1"); + assertEquals(failed(checks), []); + assertStringIncludes(detail(checks, "protocol-identity"), "not published"); +}); + +Deno.test("publish: byte-identical in-tree protocol passes", async () => { + const fx = fake({ + files: PROTOCOL_SRC, + http: { [META_URL("0.2.1")]: await metaFor(PROTOCOL_SRC) }, + }); + assertEquals(failed(await publishChecks(fx, "0.2.1")), []); +}); + +Deno.test("publish: THE TEAR — a published version missing an in-tree file fails", async () => { + // The #219 state exactly: protocol/src gained cloneable.ts after 0.2.0 + // was already on JSR, so a publish would skip protocol and ship its + // dependents against a copy without those exports. + const fx = fake({ + files: PROTOCOL_SRC, + http: { + [META_URL("0.2.0")]: await metaFor(PROTOCOL_SRC, [ + "protocol/src/cloneable.ts", + ]), + }, + }); + const checks = await publishChecks(fx, "0.2.0"); + assertEquals(failed(checks), ["protocol-identity"]); + const d = detail(checks, "protocol-identity"); + assertStringIncludes(d, "not in the published version: protocol/src/cloneable.ts"); + assertStringIncludes(d, "bump protocol/deno.json"); +}); + +Deno.test("publish: edited content under an already-published version fails", async () => { + const published = await metaFor({ + ...PROTOCOL_SRC, + "protocol/src/mod.ts": "export const old = 1;\n", + }); + const fx = fake({ files: PROTOCOL_SRC, http: { [META_URL("0.2.1")]: published } }); + const checks = await publishChecks(fx, "0.2.1"); + assertEquals(failed(checks), ["protocol-identity"]); + assertStringIncludes(detail(checks, "protocol-identity"), "content differs: protocol/src/mod.ts"); +}); + +Deno.test("publish: a file published but deleted in tree fails", async () => { + const fx = fake({ + files: { + "protocol/src/mod.ts": PROTOCOL_SRC["protocol/src/mod.ts"], + "protocol/deno.json": PROTOCOL_SRC["protocol/deno.json"], + }, + http: { [META_URL("0.2.1")]: await metaFor(PROTOCOL_SRC) }, + }); + const checks = await publishChecks(fx, "0.2.1"); + assertEquals(failed(checks), ["protocol-identity"]); + assertStringIncludes(detail(checks, "protocol-identity"), "missing in tree: protocol/src/cloneable.ts"); +}); + +// ----- cut mode --------------------------------------------------------------- + +const win = ( + prs: { number: number; title: string; labels?: string[] }[], + direct: { sha: string; subject: string }[] = [], +) => ({ + prs: prs.map((p) => ({ ...p, labels: p.labels ?? [] })), + direct, +}); + +Deno.test("cut: a breaking label in the window forces a minor bump", async () => { + const window = win([ + { number: 219, title: "cloneable forms", labels: ["breaking/runtime"] }, + { number: 220, title: "realloc window" }, + ]); + const bad = cutGuards({ + version: "0.4.1", + lastCutVersion: "0.4.0", + protocolVersion: "0.2.1", + protocolAtLastCut: "0.2.1", + window, + }); + assertEquals(failed(bad), ["cut-lockstep-labels"]); + assertStringIncludes(detail(bad, "cut-lockstep-labels"), "#219"); + + const good = cutGuards({ + version: "0.5.0", + lastCutVersion: "0.4.0", + protocolVersion: "0.2.1", + protocolAtLastCut: "0.2.1", + window, + }); + assertEquals(failed(good), []); +}); + +Deno.test("cut: breaking/protocol is judged against protocol at the last cut", async () => { + const window = win([{ number: 219, title: "A20", labels: ["breaking/protocol"] }]); + const bad = cutGuards({ + version: "0.5.0", + lastCutVersion: "0.4.0", + protocolVersion: "0.2.1", + protocolAtLastCut: "0.2.0", + window, + }); + assertEquals(failed(bad), ["cut-protocol-labels"]); + const good = cutGuards({ + version: "0.5.0", + lastCutVersion: "0.4.0", + protocolVersion: "0.3.0", + protocolAtLastCut: "0.2.0", + window, + }); + assertEquals(failed(good), []); +}); + +Deno.test("cut: notes render breaking, changes, and direct commits", () => { + const notes = renderNotes(win( + [ + { + number: 219, + title: "cloneable forms", + labels: ["breaking/runtime", "breaking/protocol", "area/cabi"], + }, + { number: 220, title: "realloc may leave the window" }, + ], + [{ sha: "f9052bbdeadbeef", subject: "Merge origin/main: renumber to A20" }], + )); + assertEquals(notes.split("\n"), [ + "## Breaking", + "", + "- cloneable forms (#219) — breaks: runtime, protocol", + "", + "## Changes", + "", + "- realloc may leave the window (#220)", + "- Merge origin/main: renumber to A20 (f9052bb)", + "", + ]); +}); + +Deno.test("cut: empty sections are omitted", () => { + assertEquals( + renderNotes(win([{ number: 1, title: "only a change" }])), + "## Changes\n\n- only a change (#1)\n", + ); + assertEquals( + renderNotes(win([{ number: 1, title: "only breakage", labels: ["breaking/wasi"] }])), + "## Breaking\n\n- only breakage (#1) — breaks: wasi\n", + ); + assertEquals(renderNotes(win([])), ""); +}); + +Deno.test("cut: end to end — window scan, guards, and the notes fragment", async () => { + const R = "polymorph-components/polyengine"; + const fx = fake({ + files: { "protocol/deno.json": manifest("protocol", "0.2.1") }, + env: { + GITHUB_REPOSITORY: R, + GITHUB_SHA: "cut12345", + VERSION: "0.5.0", + }, + gh: { + [`api repos/${R}/releases/latest`]: ghJson({ tag_name: "v0.4.0" }), + [`api repos/${R}/compare/v0.4.0...cut12345`]: ghJson({ + total_commits: 3, + commits: [ + { sha: "aaa1111", commit: { message: "Merge PR #219\n\nbody" } }, + { sha: "bbb2222", commit: { message: "Merge PR #219 again\n" } }, + { sha: "ccc3333", commit: { message: "direct push: fix typo\nbody" } }, + ], + }), + [`api repos/${R}/commits/aaa1111/pulls`]: ghJson([ + { number: 219, title: "cloneable forms", labels: [{ name: "breaking/runtime" }] }, + ]), + // Same PR on a second commit: deduped, not double-counted. + [`api repos/${R}/commits/bbb2222/pulls`]: ghJson([ + { number: 219, title: "cloneable forms", labels: [{ name: "breaking/runtime" }] }, + ]), + [`api repos/${R}/commits/ccc3333/pulls`]: ghJson([]), + [`api repos/${R}/contents/protocol/deno.json?ref=v0.4.0`]: ghJson({ + content: btoa(manifest("protocol", "0.2.1")), + }), + }, + }); + assertEquals(await main(fx, ["cut", "--out", "changes.md"]), 0); + assertEquals(fx.written["changes.md"].split("\n"), [ + "## Breaking", + "", + "- cloneable forms (#219) — breaks: runtime", + "", + "## Changes", + "", + "- direct push: fix typo (ccc3333)", + "", + ]); +}); + +Deno.test("cut: a truncated compare window is refused, not silently scanned", async () => { + const R = "polymorph-components/polyengine"; + const fx = fake({ + files: { "protocol/deno.json": manifest("protocol", "0.2.1") }, + gh: { + [`api repos/${R}/releases/latest`]: ghJson({ tag_name: "v0.4.0" }), + [`api repos/${R}/compare/v0.4.0...cut12345`]: ghJson({ + total_commits: 300, + commits: [{ sha: "aaa1111", commit: { message: "x" } }], + }), + }, + }); + let message = ""; + try { + await cutChecks(fx, { + repo: R, + sha: "cut12345", + version: "0.5.0", + out: null, + }); + } catch (e) { + message = (e as Error).message; + } + assertStringIncludes(message, "250-commit cap"); +}); + +Deno.test("cut: the first cut ever has no window", async () => { + const R = "polymorph-components/polyengine"; + const fx = fake({ + files: { "protocol/deno.json": manifest("protocol", "0.1.0") }, + gh: { [`api repos/${R}/releases/latest`]: gh404 }, + }); + const checks = await cutChecks(fx, { + repo: R, + sha: "cut12345", + version: "0.1.0", + out: "changes.md", + }); + assertEquals(failed(checks), []); + assertEquals(fx.written["changes.md"], ""); +}); diff --git a/tools/version-guard/effects.ts b/tools/version-guard/effects.ts new file mode 100644 index 0000000..1716f70 --- /dev/null +++ b/tools/version-guard/effects.ts @@ -0,0 +1,111 @@ +// The effects the guard needs from the outside world, as one injectable +// interface: every check below is a pure function over this, so the unit +// tests run the real decision logic against fixtures with no network, no +// `gh`, and no repository state. `realEffects()` is the only place that +// touches Deno APIs. + +export type HttpResponse = { status: number; body: string }; + +export type CommandResult = { + code: number; + stdout: string; + stderr: string; +}; + +export interface Effects { + /** GET a URL as text. A non-2xx is a normal return, not a throw: 404 is a + * meaningful answer to "is this version published?". */ + fetchText(url: string): Promise; + /** Run a command (`gh`, `git`) and capture it. */ + run(cmd: string, args: string[]): Promise; + /** File bytes, or null when the path does not exist. */ + readFile(path: string): Promise; + /** Every file below `dir`, recursively, as paths relative to `dir` with + * `/` separators. Empty when `dir` does not exist. */ + listFiles(dir: string): Promise; + writeFile(path: string, text: string): Promise; + env(name: string): string | undefined; + log(message: string): void; +} + +export function realEffects(): Effects { + return { + async fetchText(url) { + const res = await fetch(url); + return { status: res.status, body: await res.text() }; + }, + async run(cmd, args) { + const out = await new Deno.Command(cmd, { + args, + stdout: "piped", + stderr: "piped", + }).output(); + const dec = new TextDecoder(); + return { + code: out.code, + stdout: dec.decode(out.stdout), + stderr: dec.decode(out.stderr), + }; + }, + async readFile(path) { + try { + return await Deno.readFile(path); + } catch (e) { + if (e instanceof Deno.errors.NotFound) return null; + throw e; + } + }, + async listFiles(dir) { + const out: string[] = []; + const walk = async (rel: string) => { + let entries: Deno.DirEntry[]; + try { + entries = [...Deno.readDirSync(rel === "" ? dir : `${dir}/${rel}`)]; + } catch (e) { + if (e instanceof Deno.errors.NotFound) return; + throw e; + } + for (const entry of entries) { + const child = rel === "" ? entry.name : `${rel}/${entry.name}`; + if (entry.isDirectory) await walk(child); + else out.push(child); + } + }; + await walk(""); + out.sort(); + return out; + }, + writeFile: (path, text) => Deno.writeTextFile(path, text), + env: (name) => Deno.env.get(name), + log: (message) => console.log(message), + }; +} + +/** sha256 of raw bytes as lowercase hex — the digest JSR's version manifests + * carry (`"checksum": "sha256-"`). */ +export async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + bytes as unknown as ArrayBuffer, + ); + return [...new Uint8Array(digest)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** A `gh api` call. `gh` exits non-zero on an error status and prints e.g. + * `gh: Not Found (HTTP 404)`; the status is recovered from that so callers + * can distinguish "no release cut yet" (404, a legitimate state) from a + * token or network failure (everything else, which must fail loudly). */ +export async function ghApi( + fx: Effects, + path: string, +): Promise<{ status: number; json: unknown }> { + const res = await fx.run("gh", ["api", path]); + if (res.code === 0) return { status: 200, json: JSON.parse(res.stdout) }; + const m = /HTTP (\d{3})/.exec(res.stderr); + if (m) return { status: Number(m[1]), json: null }; + throw new Error( + `gh api ${path} failed (exit ${res.code}) with no HTTP status:\n${res.stderr.trim()}`, + ); +} diff --git a/tools/version-guard/semver.ts b/tools/version-guard/semver.ts new file mode 100644 index 0000000..ec6741c --- /dev/null +++ b/tools/version-guard/semver.ts @@ -0,0 +1,81 @@ +// A minimal semver, deliberately dependency-free: the guard runs as the very +// first step of `gha::core` and inside release.yml before `deno publish`, so +// every import it takes is a way for a registry outage to fail a release. +// Only what the guard actually decides is implemented — parse, compare, and +// the minor-level questions the versioning policy asks (AGENTS.md +// §Versioning). + +export type Semver = { + major: number; + minor: number; + patch: number; + /** Dot-separated prerelease identifiers; empty for a release version. */ + pre: string[]; +}; + +const RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; + +/** Parse `X.Y.Z[-pre][+build]`. Throws on anything else — a version this + * guard cannot read is a violation, never a pass. */ +export function parseSemver(v: string): Semver { + const m = RE.exec(v.trim()); + if (!m) throw new Error(`not a semver version: ${JSON.stringify(v)}`); + const pre = m[4] === undefined ? [] : m[4].split("."); + if (pre.some((id) => id.length === 0)) { + throw new Error(`empty prerelease identifier in ${JSON.stringify(v)}`); + } + return { + major: Number(m[1]), + minor: Number(m[2]), + patch: Number(m[3]), + pre, + }; +} + +const NUMERIC = /^\d+$/; + +function comparePre(a: string[], b: string[]): number { + // semver 11: a version WITH a prerelease has lower precedence than the + // same version without one. + if (a.length === 0 && b.length === 0) return 0; + if (a.length === 0) return 1; + if (b.length === 0) return -1; + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const x = a[i], y = b[i]; + // A longer set of identifiers wins when all preceding ones are equal. + if (x === undefined) return -1; + if (y === undefined) return 1; + const xn = NUMERIC.test(x), yn = NUMERIC.test(y); + if (xn && yn) { + // Numeric identifiers compare numerically (and never have leading + // zeros — release.yml's `g` prefix exists for exactly this reason). + const d = Number(x) - Number(y); + if (d !== 0) return d < 0 ? -1 : 1; + } else if (xn !== yn) { + return xn ? -1 : 1; // numeric < alphanumeric + } else if (x !== y) { + return x < y ? -1 : 1; + } + } + return 0; +} + +/** -1 / 0 / 1, semver precedence (build metadata ignored, as the spec says). */ +export function compareSemver(a: string | Semver, b: string | Semver): number { + const x = typeof a === "string" ? parseSemver(a) : a; + const y = typeof b === "string" ? parseSemver(b) : b; + for (const k of ["major", "minor", "patch"] as const) { + if (x[k] !== y[k]) return x[k] < y[k] ? -1 : 1; + } + return comparePre(x.pre, y.pre); +} + +/** True when `a` is a strictly later minor line than `b` — the question the + * breaking-label policy asks ("bumped this cycle"). A major bump counts: + * 1.0.0 is a later line than 0.9.0. */ +export function isMinorBumped(a: string | Semver, b: string | Semver): boolean { + const x = typeof a === "string" ? parseSemver(a) : a; + const y = typeof b === "string" ? parseSemver(b) : b; + if (x.major !== y.major) return x.major > y.major; + return x.minor > y.minor; +}