Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/release-traps.md
Original file line number Diff line number Diff line change
@@ -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.
50 changes: 41 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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}"
Expand Down Expand Up @@ -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 <Version>, which sync-versions.mjs
# kept in lockstep with package.json before changeset publish ran.
Expand All @@ -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')
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
137 changes: 137 additions & 0 deletions scripts/check-registries.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading