From 0cf1b1066c6e686be4b4f0b2a91e771f890f29f5 Mon Sep 17 00:00:00 2001 From: Brent Date: Thu, 20 Aug 2026 15:19:44 -0400 Subject: [PATCH 1/3] fix(release): three traps that report success while doing nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three confirmed live in this repo, not theoretical. 1. `go test ./...` served a CACHED pass against a corrupted fixture. Go's build cache does not invalidate on a file read from outside the package directory — exactly the shape of the shared contract fixtures in spec/. Verified by zeroing every sha256 and setting headBytes to 999: still `ok (cached)`. So the Go quarter of the five-port lazy contract was proving nothing. `go:test` now passes `-count=1`; the same corruption fails immediately. 2. release.yml ran `pnpm format` in WRITE mode. Whatever it rewrote was either swept into the release commit unreviewed, or — in publish mode, where the changesets action commits nothing — left the tree dirty for `cargo publish --locked`, which would fail every release now that `--allow-dirty` is gone (dropped in #66). The step is now `format:check`, and the formatting the release genuinely needs moved into `pnpm run version`, before the action commits. Confirmed that changesets' generated CHANGELOG.md is NOT oxfmt-clean, so without that ordering `format:check` would redden every future release PR. 3. PyPI, crates.io, NuGet and the Go tag were gated on `steps.changesets.outputs.published == 'true'` — on npm having published in THAT run. npm succeeds, a later step fails, the retry finds nothing new for npm, all four skip: a GREEN run that published nothing, leaving four ports on the old version indefinitely. Each is now gated on whether its own registry carries package.json's version, so a retry ships exactly what is missing, and a final step fails the run if npm published a version the others did not. `scripts/check-registries.mjs` does the detection, waits out index propagation (reading "missing" during the lag would skip the publish this run just earned, then report success), and doubles as a manual "are we stranded?" command. Verified both directions: all five present on 2.2.14 exits 0; npm present with the Go tag missing exits 1 naming it. Checked and NOT stranded today: npm, PyPI, crates.io, both NuGet packages and the Go tag are all on 2.2.14, so the truncation fix reached every port. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0152bbE1veqfG1SVJdyLCBxC --- .changeset/release-traps.md | 11 ++++ .github/workflows/release.yml | 40 ++++++++++-- package.json | 4 +- scripts/check-registries.mjs | 111 ++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 .changeset/release-traps.md create mode 100644 scripts/check-registries.mjs diff --git a/.changeset/release-traps.md b/.changeset/release-traps.md new file mode 100644 index 0000000..eec4a35 --- /dev/null +++ b/.changeset/release-traps.md @@ -0,0 +1,11 @@ +--- +'@smooai/file': patch +--- + +Three release and test traps that each report success while doing nothing. + +**`go test ./...` served a cached pass against a corrupted fixture.** Go's build cache doesn't invalidate on a file loaded from outside the package directory, which is exactly the shape of the shared contract fixtures under `spec/`. Verified: zeroing every `sha256` and setting `headBytes` to 999 still printed `ok (cached)`. `go:test` now passes `-count=1`, and the same corruption fails immediately. Until this, the Go quarter of the five-port lazy contract proved nothing. + +**`release.yml` ran `pnpm format` in write mode.** Whatever it rewrote was either swept into the release commit unreviewed, or — in publish mode, where the changesets action commits nothing — left the tree dirty for `cargo publish --locked`, which would fail every release now that `--allow-dirty` is gone. The workflow now runs `format:check`, and the formatting the release genuinely needs runs inside `pnpm run version`, before the action commits. Changesets' generated `CHANGELOG.md` is confirmed not oxfmt-clean, so without that ordering `format:check` would redden every future release PR. + +**Publishing to PyPI, crates.io, NuGet and the Go tag was gated on npm having published in the same run.** If npm succeeded and a later step failed, the retry found nothing new for npm, so all four skipped — a green run that published nothing, stranding four ports on the previous release indefinitely. Each step is now gated on whether its own registry carries `package.json`'s version, so a retry ships exactly what is missing, and a final step fails the run if npm published a version the others didn't. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2006d0c..3db96a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,8 +88,15 @@ jobs: - name: Build run: pnpm build - - name: Format - run: pnpm format + # CHECK, never write. `pnpm format` here would rewrite files that then + # either get swept into the release commit unreviewed, or — in publish + # mode, where the changesets action commits nothing — leave the tree + # dirty when `cargo publish --locked` runs, failing every release. + # The formatting the release itself needs (changesets writes a + # CHANGELOG.md that oxfmt rewrites) happens inside `pnpm run version`, + # before the action commits. + - name: Format check + run: pnpm format:check - name: Version Update 🦋 id: changesets @@ -107,8 +114,22 @@ jobs: GITHUB_TOKEN: ${{ secrets.GH_PAT }} NPM_TOKEN: ${{ secrets.SMOOAI_NPM_TOKEN }} + # Which registries still lack package.json's version. Gating each publish + # on its OWN registry, rather than on npm having published in THIS run, + # is what makes a retry ship exactly what is missing. The old gate + # (`steps.changesets.outputs.published == 'true'`) meant a run where npm + # succeeded and a later step failed could never be retried: the retry + # found nothing new for npm, so all four remaining steps skipped and the + # run went GREEN having published nothing. + - name: Check registries + id: registries + # --expect-npm when changesets just published, so the npm index has a + # moment to catch up; reading "missing" during that window would skip + # the very publish this run earned and then report success. + run: node scripts/check-registries.mjs --github ${{ steps.changesets.outputs.published == 'true' && '--expect-npm' || '' }} + - name: Publish smooai-file to PyPI - if: steps.changesets.outputs.published == 'true' + if: steps.registries.outputs.pypi_missing == 'true' working-directory: python env: UV_PUBLISH_TOKEN: ${{ secrets.SMOOAI_PYPI_TOKEN }} @@ -120,7 +141,7 @@ jobs: uv run poe publish - name: Publish smooai-file to crates.io - if: steps.changesets.outputs.published == 'true' + if: steps.registries.outputs.crates_missing == 'true' # No --allow-dirty: sync-versions.mjs now runs in the `version` lifecycle, # so Cargo.toml and Cargo.lock are already committed at the synced version # and the tree is clean here. --locked keeps the publish build reproducible. @@ -129,7 +150,7 @@ jobs: CARGO_REGISTRY_TOKEN: ${{ secrets.SMOOAI_CARGO_REGISTRY_TOKEN }} - name: Tag and publish Go module - if: steps.changesets.outputs.published == 'true' + if: steps.registries.outputs.go_missing == 'true' run: | VERSION=$(node -p "require('./package.json').version") TAG="go/file/v${VERSION}" @@ -161,7 +182,7 @@ jobs: working-directory: dotnet - name: Pack and publish SmooAI.File + SmooAI.File.S3 to NuGet - if: steps.changesets.outputs.published == 'true' + if: steps.registries.outputs.nuget_missing == 'true' run: | # Version is sourced from csproj , which sync-versions.mjs # kept in lockstep with package.json before changeset publish ran. @@ -184,6 +205,13 @@ jobs: env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + # Fails the run if npm published this version and any of the other four + # did not. Without it, a partial publish is indistinguishable from a + # version-PR run: both are green, and four ports quietly keep shipping + # the previous release. + - name: Verify no registry was stranded + run: node scripts/check-registries.mjs --assert + - name: Auto-Merge Changeset PR run: | PR_NUMBER=$(gh pr list --state open --head changeset-release/main --json number --jq '.[0].number') diff --git a/package.json b/package.json index b75b0a5..156a040 100644 --- a/package.json +++ b/package.json @@ -82,12 +82,12 @@ "go:fmt": "(cd go/file && gofmt -w .)", "go:fmt:check": "(cd go/file && test -z \"$(gofmt -l .)\")", "go:lint": "(cd go/file && go vet ./...)", - "go:test": "(cd go/file && go test -v ./...)", + "go:test": "(cd go/file && go test -count=1 -v ./...)", "dotnet:build": "(cd dotnet && dotnet build --configuration Release)", "dotnet:format": "(cd dotnet && dotnet format)", "dotnet:format:check": "(cd dotnet && dotnet format --verify-no-changes)", "dotnet:test": "(cd dotnet && dotnet test --configuration Release --logger \"console;verbosity=minimal\")", - "version": "changeset version && node scripts/sync-versions.mjs", + "version": "changeset version && node scripts/sync-versions.mjs && pnpm run format", "version:check": "node scripts/check-versions.mjs", "version:sync": "node scripts/sync-versions.mjs" }, diff --git a/scripts/check-registries.mjs b/scripts/check-registries.mjs new file mode 100644 index 0000000..4a3f098 --- /dev/null +++ b/scripts/check-registries.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node + +/** + * Reports which of the five registries already carry package.json's version. + * + * The release used to gate PyPI, crates.io, the Go tag and NuGet on + * `steps.changesets.outputs.published == 'true'` — on npm having published in + * THAT run. If npm succeeded and a later step failed, the retry found nothing + * new to publish on npm, so `published` was false, so all four skipped: a GREEN + * run that published nothing, leaving four ports stranded on the old version + * indefinitely. Gating each registry on what that registry actually has instead + * makes a retry publish exactly what is missing. + * + * npm is the source of truth for "is this version released". The other four + * never run ahead of it, so a version bumped on main but not yet published + * can't leak out to PyPI while npm still shows the previous release. + * + * Usage: + * node scripts/check-registries.mjs # human-readable report + * node scripts/check-registries.mjs --github # also write GITHUB_OUTPUT flags + * node scripts/check-registries.mjs --expect-npm # wait for npm's index to catch up first + * node scripts/check-registries.mjs --assert # exit 1 if npm has it and others don't + */ + +import { execFileSync } from 'child_process'; +import { appendFileSync, readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const version = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8')).version; + +const ok = async (url) => { + try { + // `smooai-parity-check` because crates.io rejects requests with no User-Agent. + const response = await fetch(url, { headers: { 'User-Agent': 'smooai-release-check' } }); + return response.ok; + } catch { + return false; + } +}; + +const goTagExists = () => { + try { + const out = execFileSync('git', ['ls-remote', '--tags', 'origin', `refs/tags/go/file/v${version}`], { + cwd: rootDir, + encoding: 'utf8', + }); + return out.trim().length > 0; + } catch { + return false; + } +}; + +const registries = { + npm: () => ok(`https://registry.npmjs.org/@smooai/file/${version}`), + pypi: () => ok(`https://pypi.org/pypi/smooai-file/${version}/json`), + crates: () => ok(`https://crates.io/api/v1/crates/smooai-file/${version}`), + nuget: () => ok(`https://api.nuget.org/v3-flatcontainer/smooai.file/${version}/smooai.file.${version}.nupkg`), + go: async () => goTagExists(), +}; + +const probe = async () => Object.fromEntries(await Promise.all(Object.entries(registries).map(async ([name, check]) => [name, await check()]))); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Registry indexes lag their upload by seconds to a couple of minutes. Reading + * "missing" during that window would skip the very publish this run just earned, + * and then report success — the same silent no-op, moved one step later. + */ +const until = async (satisfied, timeoutMs) => { + const deadline = Date.now() + timeoutMs; + let result = await probe(); + while (!satisfied(result) && Date.now() < deadline) { + await sleep(10_000); + result = await probe(); + } + return result; +}; + +let present; +if (process.argv.includes('--expect-npm')) { + present = await until((r) => r.npm, 120_000); +} else if (process.argv.includes('--assert')) { + present = await until((r) => !r.npm || Object.values(r).every(Boolean), 180_000); +} else { + present = await probe(); +} + +console.log(`@smooai/file ${version}:`); +for (const [name, has] of Object.entries(present)) { + console.log(` ${has ? '✓' : '·'} ${name.padEnd(7)} ${has ? 'published' : 'missing'}`); +} + +if (process.argv.includes('--github') && process.env.GITHUB_OUTPUT) { + const lines = Object.entries(present).map(([name, has]) => `${name}_missing=${!has && present.npm}`); + appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\nnpm_published=${present.npm}\n`); +} + +if (process.argv.includes('--assert')) { + // Only meaningful once npm carries the version — before that, nothing is + // "stranded", the release simply hasn't happened. + const stranded = present.npm ? Object.entries(present).filter(([, has]) => !has) : []; + if (stranded.length > 0) { + console.error(`\n✗ npm published ${version} but ${stranded.map(([n]) => n).join(', ')} did not.`); + console.error(` Those ports are still shipping the previous release. Re-run this workflow — each`); + console.error(` publish step is gated on its own registry, so a retry ships exactly what is missing.`); + process.exit(1); + } +} From c87630786f3b74c0f2ccc0934ca3330e56d7202e Mon Sep 17 00:00:00 2001 From: Brent Date: Thu, 20 Aug 2026 15:35:16 -0400 Subject: [PATCH 2/3] fix(release): close a fail-open in the registry check, and stop it crying wolf on NuGet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections found while verifying the previous commit rather than assuming it. A botched edit had left the npm-probe result being overwritten by a later branch, so `--expect-npm` returned exit 0 on a version npm did not have. Every downstream gate is `!has && present.npm`, so a false npm reading would have switched all four publishes off and let the run go green having shipped nothing — the exact fail-open this script exists to remove, reintroduced one level up. Now exits 1 with a message naming the disagreement. Verified: exit 1 on an unpublished version, exit 0 once npm has it. NuGet is no longer asserted on. Its index takes minutes to tens of minutes to show a package it has already accepted — 2.2.19 logged "Your package was pushed" for both packages and the flat-container index still read 2.2.14 twenty minutes later — so the guard would have reddened every successful release, and a guard that cries wolf gets deleted. npm, PyPI, crates.io and the Go tag index in seconds and stay strict. NuGet keeps its own protection: `dotnet nuget push` exits non-zero on a real failure, and the per-registry gate skips it only when the version is genuinely already there. Its state is still reported, just not enforced, and the reason is in the code so it does not read as an oversight. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0152bbE1veqfG1SVJdyLCBxC --- scripts/check-registries.mjs | 38 ++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/scripts/check-registries.mjs b/scripts/check-registries.mjs index 4a3f098..d5cee74 100644 --- a/scripts/check-registries.mjs +++ b/scripts/check-registries.mjs @@ -52,6 +52,17 @@ const goTagExists = () => { } }; +/** + * Registries the --assert guard holds strictly. NuGet is deliberately absent: its + * index takes minutes to tens of minutes to show a package that has already been + * accepted ("Your package was pushed"), so asserting on it would redden a release + * that succeeded — and a guard that cries wolf gets deleted. NuGet keeps its own + * protection: `dotnet nuget push` exits non-zero on a real failure, and with the + * per-registry gate it is skipped only when the version is genuinely already there. + * Its state is still reported below, just not enforced. + */ +const ASSERTED = ['npm', 'pypi', 'crates', 'go']; + const registries = { npm: () => ok(`https://registry.npmjs.org/@smooai/file/${version}`), pypi: () => ok(`https://pypi.org/pypi/smooai-file/${version}/json`), @@ -79,11 +90,14 @@ const until = async (satisfied, timeoutMs) => { return result; }; +const expectNpm = process.argv.includes('--expect-npm'); +const assert = process.argv.includes('--assert'); + let present; -if (process.argv.includes('--expect-npm')) { +if (expectNpm) { present = await until((r) => r.npm, 120_000); -} else if (process.argv.includes('--assert')) { - present = await until((r) => !r.npm || Object.values(r).every(Boolean), 180_000); +} else if (assert) { + present = await until((r) => !r.npm || ASSERTED.every((name) => r[name]), 300_000); } else { present = await probe(); } @@ -93,17 +107,29 @@ for (const [name, has] of Object.entries(present)) { console.log(` ${has ? '✓' : '·'} ${name.padEnd(7)} ${has ? 'published' : 'missing'}`); } +// --expect-npm is passed only when changesets reported a successful npm publish. +// Still reading "missing" after the wait means the PROBE is wrong, not that npm is +// empty — and every downstream gate is `!has && present.npm`, so a false npm +// reading would switch all four publishes off and let the run go green having +// shipped nothing. That is the same fail-open this script exists to remove, so +// fail instead of guessing. +if (expectNpm && !present.npm) { + console.error(`\n✗ changesets reported publishing ${version}, but npm still does not show it after 2 minutes.`); + console.error(` Refusing to continue: the other four registries are gated on this reading.`); + process.exit(1); +} + if (process.argv.includes('--github') && process.env.GITHUB_OUTPUT) { const lines = Object.entries(present).map(([name, has]) => `${name}_missing=${!has && present.npm}`); appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\nnpm_published=${present.npm}\n`); } -if (process.argv.includes('--assert')) { +if (assert) { // Only meaningful once npm carries the version — before that, nothing is // "stranded", the release simply hasn't happened. - const stranded = present.npm ? Object.entries(present).filter(([, has]) => !has) : []; + const stranded = present.npm ? ASSERTED.filter((name) => !present[name]) : []; if (stranded.length > 0) { - console.error(`\n✗ npm published ${version} but ${stranded.map(([n]) => n).join(', ')} did not.`); + console.error(`\n✗ npm published ${version} but ${stranded.join(', ')} did not.`); console.error(` Those ports are still shipping the previous release. Re-run this workflow — each`); console.error(` publish step is gated on its own registry, so a retry ships exactly what is missing.`); process.exit(1); From 18b8e9a8c65fe548df45906e4a48ba46223d75d8 Mon Sep 17 00:00:00 2001 From: Brent Date: Thu, 20 Aug 2026 15:37:46 -0400 Subject: [PATCH 3/3] docs(release): correct two workflow comments my own changes made stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stranding guard's comment still said "any of the other four" after NuGet was dropped from the strict set, and the PyPI step still called the wheels it cleans "pre-sync version" — which stopped being true when sync-versions moved into the `version` lifecycle. A comment that describes behaviour the code no longer has is worse than none, because the next reader trusts it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0152bbE1veqfG1SVJdyLCBxC --- .github/workflows/release.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3db96a3..05fca99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,9 +134,10 @@ jobs: env: UV_PUBLISH_TOKEN: ${{ secrets.SMOOAI_PYPI_TOKEN }} run: | - # Clean stale build artifacts from the earlier `pnpm build` run - # (which produced wheels at the pre-sync version) so `uv publish` - # doesn't try to upload the stale files alongside the fresh ones. + # Clean artifacts from the earlier `pnpm build` step so `uv publish` + # uploads only what it just built. (The version they carry is no + # longer stale — sync-versions runs in the `version` lifecycle now — + # but a leftover wheel from a previous version still would be.) rm -rf dist/ uv run poe publish @@ -205,10 +206,13 @@ jobs: env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} - # Fails the run if npm published this version and any of the other four - # did not. Without it, a partial publish is indistinguishable from a - # version-PR run: both are green, and four ports quietly keep shipping - # the previous release. + # Fails the run if npm published this version and PyPI, crates.io or the + # Go tag did not. Without it a partial publish is indistinguishable from + # a version-PR run — both are green — and those ports quietly keep + # shipping the previous release. NuGet is reported but not asserted: its + # index lags an accepted push by minutes to tens of minutes, so holding + # it strictly would redden every successful release. See + # scripts/check-registries.mjs. - name: Verify no registry was stranded run: node scripts/check-registries.mjs --assert