diff --git a/.github/justfile b/.github/justfile index fd5e324..0a4a4a6 100644 --- a/.github/justfile +++ b/.github/justfile @@ -44,11 +44,17 @@ _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. +# `version-guard-local` runs first, unconditionally — label-free/event-free +# tree checks (lockstep agreement, monotonicity, the protocol byte-identity +# tear check) that need no PR context, so a direct push to main is covered +# too (the #232 gap: `version-guard-pr` no-ops without PR_NUMBER, and #232 +# only got caught because it happened to go through a `pull_request` run). +# `version-guard-pr` runs right after 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-local @just gha::_step version-guard-pr @just gha::_step build @just gha::_step test-rust diff --git a/AGENTS.md b/AGENTS.md index 235dd2e..922cf73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,8 +140,12 @@ Standing rules: 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, + enforces them in four places (`just test-version-guard` covers its + logic): `local` mode, first in `just gates` and an unconditional + `gha::core` step (label-free tree checks — lockstep agreement, + monotonicity, the protocol byte-identity tear check — so pre-push runs + and direct pushes are covered without PR context; the #232 lesson); + `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 diff --git a/justfile b/justfile index 009f877..6de4155 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 test-version-guard publish-check test-npm examples test-translate conformance sched-seeds shells browsers smoke-tls smoke-c0 +gates: version-guard-local 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 @@ -130,6 +130,26 @@ test-bundle: shim test-version-guard: deno test -A tools/version-guard/ +# The release version guard's label-free, event-free pass +# (tools/version-guard/check.ts `local`): lockstep agreement, monotonicity +# against a locally-derivable last cut (git tags, falling back to jsr.io's +# published `runtime` latest — no GitHub API, no PR context), and the +# protocol byte-identity tear check (the #219/#232 incidents) run fatally +# against the WORKING TREE, plus an advisory reminder if committed +# conventions goldens changed. It exists because `pr` mode no-ops the +# instant PR_NUMBER is unset (see below) — so neither a push run nor a +# pre-push `just gates` ever asked "would this tear protocol?" before #232 +# shipped one straight through. `local` is the split: anyone can run it +# with no GitHub context at all, so it goes first in `gates` — cheap, and +# catches a versioning mistake before the expensive suites run at all. +# Same permission shape as `version-guard-pr` (see its comment for why +# --allow-run is not narrowed to `gh,git`) minus --allow-env: `local` reads +# no environment variables at all (no PR_NUMBER/PR_BASE_SHA/GITHUB_*), by +# design — that is what makes it the label-free/event-free half of the +# split. +version-guard-local: + deno run --allow-net=jsr.io --allow-run --allow-read=. tools/version-guard/check.ts local + # 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 diff --git a/tools/version-guard/check.ts b/tools/version-guard/check.ts index 29df5d1..bf5bdf0 100644 --- a/tools/version-guard/check.ts +++ b/tools/version-guard/check.ts @@ -525,12 +525,26 @@ export async function publishChecks( fx: Effects, version: string, ): Promise { + return [await protocolIdentityCheck(fx, version, "protocol-identity")]; +} + +/** The shared core of the byte-identity tear guard, parameterized on the + * check name so `publish` mode (name "protocol-identity") and `local` mode + * (name "protocol-tear-identity", wrapped with network-error handling + * below) share one implementation rather than drifting. */ +async function protocolIdentityCheck( + fx: Effects, + version: string, + name: 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`, - )]; + return pass( + name, + `@polyengine/protocol@${version} is not published — a pending bump${ + name === "protocol-identity" ? "; this run publishes it" : "" + }`, + ); } const problems: string[] = []; @@ -557,17 +571,17 @@ export async function publishChecks( } if (problems.length === 0) { - return [pass( - "protocol-identity", + return pass( + name, `in-tree protocol is byte-identical to the published @polyengine/protocol@${version} (${publishedPaths.length} files)`, - )]; + ); } - return [fail( - "protocol-identity", + return fail( + name, `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 --------------------------------------------------------------- @@ -864,6 +878,193 @@ export async function cutChecks( return checks; } +// ----- local mode --------------------------------------------------------------- +// +// The gap `local` closes (the #232 incident): `pr` mode exits 0 the instant +// PR_NUMBER is unset, so neither a push run nor a developer's pre-push `just +// gates` ever asked "would this tear protocol?" — only the CI `pull_request` +// run does, and PR #232 only heard about its own tear from that run. `local` +// is label-free and event-free by construction (no PR labels exist to read, +// no PR base to diff against) so every check here answers a question +// nothing outside the working tree + (optionally) the network is needed +// for. It is advisory only on the one thing labels genuinely own (goldens); +// everything else it can decide alone, it enforces. + +/** The offline-capable "last cut" answer for local monotonicity: local git + * tags first (no network at all — a normal non-shallow clone carries them), + * falling back to JSR's published `runtime` `latest` (network to jsr.io, + * which `local` already has permission for, but no GitHub token) when no + * `v*` tags are reachable, e.g. a shallow checkout that never fetched tags. + * Returns null — not a throw — when neither source answers, so + * monotonicity can skip loudly instead of failing on an environment + * question `pr`/`cut` mode (which read `releases/latest` from the GitHub + * API) already answer authoritatively in CI. */ +export async function localLastCutVersion( + fx: Effects, +): Promise<{ version: string; source: string } | null> { + const tags = await fx.run("git", ["tag", "--list", "v*"]); + if (tags.code === 0) { + const versions = tags.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .map((t) => t.slice(1)) + .filter((v) => { + try { + parseSemver(v); + return true; + } catch { + return false; + } + }); + if (versions.length > 0) { + versions.sort(compareSemver); + const version = versions[versions.length - 1]; + return { version, source: `local git tag v${version}` }; + } + } + try { + const latest = await jsrProtocolLatestFor(fx, "runtime"); + if (latest) { + return { version: latest, source: "jsr.io @polyengine/runtime latest" }; + } + } catch { + // Genuinely unavailable (no network, or jsr.io down) — fall through to + // null so the caller SKIPs rather than fails: an environment question, + // not a versioning mistake. + } + return null; +} + +/** `jsrProtocolLatest` generalized to any JSR package under @polyengine — + * `runtime` publishes on every cut (unlike `protocol`, which can lag), so + * its `latest` is exactly the last-cut version when local tags are + * unavailable. */ +async function jsrProtocolLatestFor( + fx: Effects, + pkg: string, +): Promise { + const res = await fx.fetchText(`https://jsr.io/@polyengine/${pkg}/meta.json`); + if (res.status === 404) return null; + if (res.status !== 200) { + throw new Error(`jsr.io @polyengine/${pkg} meta.json: HTTP ${res.status}`); + } + const latest = JSON.parse(res.body)?.latest; + return typeof latest === "string" ? latest : null; +} + +/** Check 3 (fatal, the #232 catch), wrapping `protocolIdentityCheck` so a + * network failure reaching jsr.io reports as a named, explained FAIL + * instead of an uncaught exception: this check exists specifically to + * catch a tear before it reaches CI, so "can't tell" must read as "didn't + * pass", not as a silent skip that defeats the point of running it + * locally. */ +async function protocolTearLocalCheck( + fx: Effects, + protocolVersion: string, +): Promise { + try { + return await protocolIdentityCheck(fx, protocolVersion, "protocol-tear-identity"); + } catch (e) { + return fail( + "protocol-tear-identity", + `cannot reach jsr.io to check whether @polyengine/protocol@${protocolVersion} is already published: ${ + e instanceof Error ? e.message : String(e) + } — this check exists to catch a protocol/src change shipping against a stale already-published copy before it reaches CI (the #219/#232 tear); a network failure means "can't tell", which this local gate treats as fatal rather than silently passing. Re-run once jsr.io is reachable`, + ); + } +} + +/** Check 4 (advisory, never fatal): local can see the diff but not the + * labels a reviewer will attach, so it can only remind, not enforce — the + * authoritative gate is `cut` mode's `cut-conventions-goldens`. Skips + * silently (returns null) when `origin/main` cannot be diffed against + * (e.g. no `origin` remote, or it hasn't been fetched) rather than + * guessing at a merge-base that may not exist locally. */ +async function conventionsGoldensAdvisory(fx: Effects): Promise { + const diff = await fx.run("git", [ + "diff", + "--name-status", + "origin/main...HEAD", + "--", + LOCKED_GOLDEN_DIR, + ]); + if (diff.code !== 0) return null; + const touched = parseGoldenNameStatus(diff.stdout).filter((c) => + c.status === "M" || c.status === "D" + ); + if (touched.length === 0) { + return pass( + "conventions-goldens-advisory", + `no modified/deleted goldens under ${LOCKED_GOLDEN_DIR} vs origin/main`, + ); + } + // Never fatal: `ok: true` with a WARNING-prefixed detail, so the run + // still exits 0 but the reminder is loud in the log. + return pass( + "conventions-goldens-advisory", + `WARNING: this branch modifies or deletes committed goldens (${ + touched.map((c) => c.path).join(", ") + }) under ${LOCKED_GOLDEN_DIR} — the PR must carry breaking/protocol (with protocol's minor bumped) or conventions-fix; local mode cannot see labels, so it can only remind, not enforce`, + ); +} + +export async function localChecks(fx: Effects): Promise { + const checks: Check[] = []; + + // 1. Lockstep agreement — same rule as `pr` mode check 1. + 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}`)); + } + + // 2. Monotonicity against the last cut, from a label-free source. + const cut = await localLastCutVersion(fx); + if (!cut) { + checks.push(pass( + "monotonic", + "SKIP — no locally-derivable last-cut version (no v* git tags, and jsr.io @polyengine/runtime latest is unreachable or unpublished); `pr`/`cut` mode in CI answer this authoritatively via the GitHub API", + )); + } else if (compareSemver(lockstep, cut.version) > 0) { + checks.push(pass( + "monotonic", + `lockstep ${lockstep} > last cut ${cut.version} (source: ${cut.source})`, + )); + } else { + checks.push(fail( + "monotonic", + `lockstep manifests are at ${lockstep}, not ahead of the last cut ${cut.version} (source: ${cut.source}) — the manifests must carry the NEXT release; bump the four ${ + LOCKSTEP.join("/") + } manifests (and RUNTIME_VERSION in runtime/src/embedder/copy.ts)`, + )); + } + + // 3. The #232 catch: protocol-tear by byte-identity, fatal here (unlike + // `pr` mode's softer heuristic, which only fires when protocol/src is in + // the PR's diff — `local` has no PR diff to consult, so it always checks + // identity directly, exactly like `publish` mode does at the real gate). + const protocolVersion = await readManifestVersion(fx, "protocol"); + checks.push(await protocolTearLocalCheck(fx, protocolVersion)); + + // 4. Conventions-goldens reminder — advisory, never fatal. + const advisory = await conventionsGoldensAdvisory(fx); + if (advisory) checks.push(advisory); + + return checks; +} + // ----- main ------------------------------------------------------------------- function report(fx: Effects, mode: string, checks: Check[]): number { @@ -907,6 +1108,9 @@ export async function main(fx: Effects, argv: string[]): Promise { repo: required("GITHUB_REPOSITORY"), })); } + case "local": { + return report(fx, "local", await localChecks(fx)); + } case "publish": { // The override exists for rehearsing the failure path against the // real registry (point it at an older published version and watch @@ -928,7 +1132,7 @@ export async function main(fx: Effects, argv: string[]): Promise { ); } default: - fx.log(`usage: check.ts [--out ] [--protocol-version ]`); + fx.log(`usage: check.ts [--out ] [--protocol-version ]`); return 2; } } diff --git a/tools/version-guard/check_test.ts b/tools/version-guard/check_test.ts index 66a4a59..c5af6c2 100644 --- a/tools/version-guard/check_test.ts +++ b/tools/version-guard/check_test.ts @@ -13,6 +13,7 @@ import { cutChecks, cutGuards, isMinorBumped, + localChecks, main, parseSemver, prChecks, @@ -41,6 +42,11 @@ function assertStringIncludes(got: string, needle: string): void { type FakeSpec = { http?: Record; + // A URL mapped here rejects fetchText's promise instead of resolving — + // simulating a real network failure (DNS, connection refused), which + // `realEffects().fetchText` would surface as a thrown error from + // `fetch()` itself rather than as any HTTP status. + httpError?: Record; gh?: Record; git?: Record; files?: Record; @@ -58,6 +64,8 @@ function fake(spec: FakeSpec): Fake { logs, written, fetchText(url) { + const err = spec.httpError?.[url]; + if (err) return Promise.reject(new Error(err)); const res = spec.http?.[url]; return Promise.resolve(res ?? { status: 404, body: "not found" }); }, @@ -864,3 +872,133 @@ Deno.test("cut: a modified golden in the window excused by conventions-fix on a "excused by conventions-fix on #300", ); }); + +// ----- local mode --------------------------------------------------------------- + +function localFiles(over: { + lockstep?: string; + perPackage?: Record; + protocolFiles?: Record; +}): Record { + const lockstep = over.lockstep ?? "0.5.0"; + const files: Record = { + ...lockstepFiles(lockstep), + ...(over.protocolFiles ?? PROTOCOL_SRC), + }; + for (const [p, v] of Object.entries(over.perPackage ?? {})) { + files[`${p}/deno.json`] = manifest(p, v); + } + return files; +} + +function localFake(over: { + files?: Record; + tags?: string; + tagsCode?: number; + jsrRuntime?: HttpResponse; + protocolMeta?: HttpResponse; + protocolMetaError?: string; + goldenDiff?: string; + goldenDiffCode?: number; +}) { + const files = over.files ?? localFiles({}); + const protocolVersion = JSON.parse(files["protocol/deno.json"]).version; + const http: Record = {}; + const httpError: Record = {}; + if (over.jsrRuntime) { + http["https://jsr.io/@polyengine/runtime/meta.json"] = over.jsrRuntime; + } + const metaUrl = `https://jsr.io/@polyengine/protocol/${protocolVersion}_meta.json`; + if (over.protocolMetaError) { + httpError[metaUrl] = over.protocolMetaError; + } else if (over.protocolMeta) { + http[metaUrl] = over.protocolMeta; + } + return fake({ + files, + http, + httpError, + git: { + "tag --list v*": { code: over.tagsCode ?? 0, stdout: over.tags ?? "v0.4.0\n" }, + "diff --name-status origin/main...HEAD -- runtime/tests/conventions/golden/": { + code: over.goldenDiffCode ?? 0, + stdout: over.goldenDiff ?? "", + }, + }, + }); +} + +Deno.test("local: lockstep disagreement fails", async () => { + const fx = localFake({ files: localFiles({ perPackage: { wasi: "0.4.1" } }) }); + const checks = await localChecks(fx); + assertEquals(failed(checks), ["lockstep"]); +}); + +Deno.test("local: monotonicity regression against a local git tag fails", async () => { + const fx = localFake({ files: localFiles({ lockstep: "0.3.0" }), tags: "v0.4.0\n" }); + const checks = await localChecks(fx); + assertEquals(failed(checks), ["monotonic"]); + assertStringIncludes(detail(checks, "monotonic"), "local git tag v0.4.0"); +}); + +Deno.test("local: protocol tear — published version with differing bytes fails", async () => { + const published = await metaFor({ + ...PROTOCOL_SRC, + "protocol/src/mod.ts": "export const old = 1;\n", + }); + const fx = localFake({ protocolMeta: published }); + const checks = await localChecks(fx); + assertEquals(failed(checks), ["protocol-tear-identity"]); + assertStringIncludes(detail(checks, "protocol-tear-identity"), "content differs"); +}); + +Deno.test("local: protocol tear — published version byte-identical passes", async () => { + const fx = localFake({ protocolMeta: await metaFor(PROTOCOL_SRC) }); + const checks = await localChecks(fx); + assertEquals(failed(checks), []); + assertStringIncludes(detail(checks, "protocol-tear-identity"), "byte-identical"); +}); + +Deno.test("local: protocol tear — unpublished version passes (pending bump)", async () => { + const fx = localFake({}); + const checks = await localChecks(fx); + assertEquals(failed(checks), []); + assertStringIncludes(detail(checks, "protocol-tear-identity"), "not published"); +}); + +Deno.test("local: a jsr.io network failure fails the tear check loudly, not silently", async () => { + const fx = localFake({ protocolMetaError: "getaddrinfo ENOTFOUND jsr.io" }); + const checks = await localChecks(fx); + assertEquals(failed(checks), ["protocol-tear-identity"]); + const d = detail(checks, "protocol-tear-identity"); + assertStringIncludes(d, "getaddrinfo ENOTFOUND jsr.io"); + assertStringIncludes(d, "#219/#232"); +}); + +Deno.test("local: a modified golden warns but never fails the local gate", async () => { + const fx = localFake({ goldenDiff: `M\t${GOLDEN_DIR}error-model.json\n` }); + const checks = await localChecks(fx); + assertEquals(failed(checks), []); + const advisory = checks.find((c) => c.name === "conventions-goldens-advisory")!; + assert(advisory.ok); + assertStringIncludes(advisory.detail, "WARNING"); + assertStringIncludes(advisory.detail, `${GOLDEN_DIR}error-model.json`); +}); + +Deno.test("local: origin/main unavailable skips the goldens advisory silently", async () => { + const fx = localFake({ goldenDiffCode: 1 }); + const checks = await localChecks(fx); + assertEquals(checks.find((c) => c.name === "conventions-goldens-advisory"), undefined); +}); + +Deno.test("local: monotonicity SKIPs loudly when no local source is derivable", async () => { + const fx = localFake({ tagsCode: 1, tags: "" }); + const checks = await localChecks(fx); + assertEquals(failed(checks), []); + assertStringIncludes(detail(checks, "monotonic"), "SKIP"); +}); + +Deno.test("local: a clean tree passes end-to-end via main()", async () => { + const fx = localFake({}); + assertEquals(await main(fx, ["local"]), 0); +});