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..05fca99 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,20 +114,35 @@ 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 }} 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 - 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 +151,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 +183,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 +206,16 @@ jobs: env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + # 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 + - 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..d5cee74 --- /dev/null +++ b/scripts/check-registries.mjs @@ -0,0 +1,137 @@ +#!/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; + } +}; + +/** + * 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`), + 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; +}; + +const expectNpm = process.argv.includes('--expect-npm'); +const assert = process.argv.includes('--assert'); + +let present; +if (expectNpm) { + present = await until((r) => r.npm, 120_000); +} else if (assert) { + present = await until((r) => !r.npm || ASSERTED.every((name) => r[name]), 300_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'}`); +} + +// --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 (assert) { + // Only meaningful once npm carries the version — before that, nothing is + // "stranded", the release simply hasn't happened. + const stranded = present.npm ? ASSERTED.filter((name) => !present[name]) : []; + if (stranded.length > 0) { + 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); + } +}