Skip to content

Commit 041fe19

Browse files
committed
fix(release): avoid post-publish registry checks
1 parent 8e06dfc commit 041fe19

4 files changed

Lines changed: 156 additions & 92 deletions

File tree

‎.agents/docs/release.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ TaffyJS has two independently dispatched release groups. Core publishes `@taffyj
66

77
Dispatch `Publish Core` or `Publish Yoga` from `main`. The default `auto` bump reads Conventional Commits that changed the selected group's owned paths since its previous tag. A feature or breaking change selects a minor bump; a fix, performance change, or revert selects a patch bump. An explicit patch or minor override exists for an intentional release whose commit type does not carry a bump. A group's first stable plan is always `0.0.1`.
88

9-
The dispatch is the sole human authorization. Build jobs have read-only repository access and no npm identity. Ordinary pull requests retain the Linux x64 GNU and Windows x64 MSVC native runtime jobs and the complete Wasm verification graph; they do not build macOS or any other publication artifact. The publication workflows run the complete Wasm package verification graph, and Core publication is the only place that builds all 13 supported native release targets. The assembly job downloads every required build artifact, requires the exact target or package set, writes final metadata, packs the public packages, records each tarball integrity, and installs those same tarballs in a fresh consumer. Only the final job receives `id-token: write`; it publishes the verified tarballs in dependency order, verifies each published version against its assembled integrity, then pushes the group tag and writes the release notes into the workflow summary. The workflow creates no GitHub Release.
9+
The dispatch is the sole human authorization. Build jobs have read-only repository access and no npm identity. Ordinary pull requests retain the Linux x64 GNU and Windows x64 MSVC native runtime jobs and the complete Wasm verification graph; they do not build macOS or any other publication artifact. The publication workflows run the complete Wasm package verification graph, and Core publication is the only place that builds all 13 supported native release targets. The assembly job downloads every required build artifact, requires the exact target or package set, writes final metadata, packs the public packages, records each tarball integrity, and installs those same tarballs in a fresh consumer. Only the final job receives `id-token: write`; it preflights the complete package group, publishes the missing verified tarballs in dependency order, then pushes the group tag and writes the release notes into the workflow summary. The workflow creates no GitHub Release.
1010

11-
Publishing is not transactional. A retry uses the retained bundle: a package version already present with the same integrity is skipped, a different integrity stops the release, and missing packages continue in dependency order. Before the first npm write, the workflow requires the group tag to be absent or already point to the bundle commit; it verifies that identity again after pushing the tag, and the tag is pushed only after the complete npm group reaches the registry. Nothing is installed from the registry afterwards: npm serves a new version from its exact-version endpoint immediately but refreshes the package index that installers read a few minutes later, and a check placed after an irreversible publication cannot prevent a bad release. The assembly job's consumer install is the test that gates publication. Workflow concurrency never cancels an in-progress publication.
11+
Publishing is not transactional. Every attempt first checks the complete retained bundle against npm: a package version already present with the same integrity is skipped, a different integrity stops the attempt before any new package is written, and every missing package is then published in dependency order. A successful `pnpm publish` command is the write boundary. Registry reads are eventually consistent, so the workflow neither re-reads nor installs a package after a successful publication; a command failure stops the attempt, and rerunning the same failed job repeats the complete preflight before resuming from the retained bundle. Before the first npm write, the workflow requires the group tag to be absent or already point to the bundle commit; it verifies that identity again after pushing the tag, and the tag is pushed only after every required publish command succeeds. The assembly job's consumer install is the test that gates publication. [Core publish job 97179548726](https://github.com/hyfdev/taffyjs/actions/runs/32633281695/job/97179548726) is durable evidence for the eventual-consistency trap: npm accepted a package while the exact-version read remained unavailable beyond the workflow's retry window and later exposed the expected integrity. Workflow concurrency never cancels an in-progress publication.
1212

1313
## One-time npm bootstrap
1414

‎tools/release/lib.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,6 @@ export async function sha512Integrity(path: string): Promise<string> {
6969
return `sha512-${createHash("sha512").update(contents).digest("base64")}`;
7070
}
7171

72-
export async function wait(milliseconds: number): Promise<void> {
73-
await new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
74-
}
75-
7672
export function packageSlug(name: string): string {
7773
return name.replace(/^@/, "").replaceAll("/", "-");
7874
}

‎tools/release/publish.ts‎

Lines changed: 72 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -5,42 +5,49 @@ import { resolve, sep } from "node:path";
55
import { npmRegistry, releaseGroups } from "./config.ts";
66
import {
77
capture,
8+
isMainModule,
89
parseRemoteTagCommit,
910
pnpmCommand,
1011
readJson,
1112
root,
1213
run,
1314
sha512Integrity,
14-
wait,
1515
} from "./lib.ts";
1616
import type { ReleaseBundleManifest, ReleaseBundlePackage } from "./assemble.ts";
1717

18-
const options = parseOptions(process.argv.slice(2));
19-
const bundleDirectory = safePath(options.bundle);
20-
const manifest = await readJson<ReleaseBundleManifest>(
21-
resolve(bundleDirectory, "release-manifest.json"),
22-
);
23-
await verifyBundle(bundleDirectory, manifest);
18+
if (isMainModule(import.meta.url)) {
19+
await main();
20+
}
2421

25-
if (!options.publish) {
26-
console.log(
27-
`Validated ${manifest.packages.length} ${manifest.group} tarballs for ${manifest.tag}; pass --publish only from the trusted workflow`,
22+
async function main(): Promise<void> {
23+
const options = parseOptions(process.argv.slice(2));
24+
const bundleDirectory = safePath(options.bundle);
25+
const manifest = await readJson<ReleaseBundleManifest>(
26+
resolve(bundleDirectory, "release-manifest.json"),
2827
);
29-
process.exit(0);
30-
}
28+
await verifyBundle(bundleDirectory, manifest);
3129

32-
const githubSha = process.env.GITHUB_SHA;
33-
if (!githubSha) throw new Error("Publishing requires GITHUB_SHA");
34-
assert.equal(githubSha, manifest.commit, "The release bundle must belong to the workflow commit");
35-
assert.equal(process.env.GITHUB_REF, "refs/heads/main", "Publishing is restricted to main");
36-
await assertRemoteTag(manifest.tag, manifest.commit, true);
30+
if (!options.publish) {
31+
console.log(
32+
`Validated ${manifest.packages.length} ${manifest.group} tarballs for ${manifest.tag}; pass --publish only from the trusted workflow`,
33+
);
34+
return;
35+
}
3736

38-
for (const packageArtifact of manifest.packages) {
39-
await publishPackage(bundleDirectory, packageArtifact);
40-
}
37+
const githubSha = process.env.GITHUB_SHA;
38+
if (!githubSha) throw new Error("Publishing requires GITHUB_SHA");
39+
assert.equal(githubSha, manifest.commit, "The release bundle must belong to the workflow commit");
40+
assert.equal(process.env.GITHUB_REF, "refs/heads/main", "Publishing is restricted to main");
41+
await assertRemoteTag(manifest.tag, manifest.commit, true);
42+
43+
await publishReleasePackages(manifest.packages, {
44+
registryIntegrity,
45+
publish: async (packageArtifact) => publishPackage(bundleDirectory, packageArtifact),
46+
});
4147

42-
await pushReleaseTag(manifest);
43-
await writeSummary(bundleDirectory, manifest);
48+
await pushReleaseTag(manifest);
49+
await writeSummary(bundleDirectory, manifest);
50+
}
4451

4552
async function verifyBundle(
4653
bundleDirectory: string,
@@ -64,47 +71,51 @@ async function verifyBundle(
6471
}
6572
}
6673

67-
async function publishPackage(
68-
bundleDirectory: string,
69-
packageArtifact: ReleaseBundlePackage,
74+
// A successful publish command is the write boundary. Registry reads are
75+
// eventually consistent, so they are used only by an attempt's initial preflight.
76+
export async function publishReleasePackages(
77+
packages: readonly ReleaseBundlePackage[],
78+
operations: {
79+
readonly registryIntegrity: (name: string, version: string) => Promise<string | null>;
80+
readonly publish: (packageArtifact: ReleaseBundlePackage) => Promise<void>;
81+
},
7082
): Promise<void> {
71-
const existingIntegrity = await registryIntegrity(packageArtifact.name, packageArtifact.version);
72-
if (existingIntegrity !== null) {
73-
assert.equal(
74-
existingIntegrity,
75-
packageArtifact.integrity,
76-
`${packageArtifact.name}@${packageArtifact.version} already exists with different bytes`,
83+
const missing: ReleaseBundlePackage[] = [];
84+
for (const packageArtifact of packages) {
85+
const existingIntegrity = await operations.registryIntegrity(
86+
packageArtifact.name,
87+
packageArtifact.version,
7788
);
78-
console.log(`Skipping verified ${packageArtifact.name}@${packageArtifact.version}`);
79-
return;
89+
if (existingIntegrity === null) {
90+
missing.push(packageArtifact);
91+
} else {
92+
assert.equal(
93+
existingIntegrity,
94+
packageArtifact.integrity,
95+
`${packageArtifact.name}@${packageArtifact.version} already exists with different bytes`,
96+
);
97+
console.log(`Skipping verified ${packageArtifact.name}@${packageArtifact.version}`);
98+
}
8099
}
81100

82-
await retry(`publish ${packageArtifact.name}@${packageArtifact.version}`, 3, async () => {
83-
try {
84-
await run(pnpmCommand, [
85-
"publish",
86-
resolve(bundleDirectory, packageArtifact.tarball),
87-
"--access",
88-
"public",
89-
"--no-git-checks",
90-
"--ignore-scripts",
91-
"--provenance",
92-
"--registry",
93-
npmRegistry,
94-
]);
95-
} catch (error) {
96-
const integrity = await registryIntegrity(packageArtifact.name, packageArtifact.version);
97-
if (integrity === packageArtifact.integrity) return;
98-
throw error;
99-
}
100-
});
101+
for (const packageArtifact of missing) await operations.publish(packageArtifact);
102+
}
101103

102-
await retry(`verify ${packageArtifact.name}@${packageArtifact.version}`, 5, async () => {
103-
assert.equal(
104-
await registryIntegrity(packageArtifact.name, packageArtifact.version),
105-
packageArtifact.integrity,
106-
);
107-
});
104+
async function publishPackage(
105+
bundleDirectory: string,
106+
packageArtifact: ReleaseBundlePackage,
107+
): Promise<void> {
108+
await run(pnpmCommand, [
109+
"publish",
110+
resolve(bundleDirectory, packageArtifact.tarball),
111+
"--access",
112+
"public",
113+
"--no-git-checks",
114+
"--ignore-scripts",
115+
"--provenance",
116+
"--registry",
117+
npmRegistry,
118+
]);
108119
}
109120

110121
async function registryIntegrity(name: string, version: string): Promise<string | null> {
@@ -121,17 +132,14 @@ async function registryIntegrity(name: string, version: string): Promise<string
121132
return integrity;
122133
}
123134

124-
// npm publication cannot be undone, so the tag is recorded as soon as the
125-
// complete group reached the registry.
135+
// The tag marks completion only after every required publish command succeeds.
126136
async function pushReleaseTag(manifest: ReleaseBundleManifest): Promise<void> {
127137
if (await assertRemoteTag(manifest.tag, manifest.commit, true)) {
128138
console.log(`Verified existing release tag ${manifest.tag}`);
129139
return;
130140
}
131-
await run("git", ["tag", "--force", manifest.tag, manifest.commit]);
132-
await retry(`push release tag ${manifest.tag}`, 3, async () => {
133-
await run("git", ["push", "origin", `refs/tags/${manifest.tag}`]);
134-
});
141+
await run("git", ["tag", manifest.tag, manifest.commit]);
142+
await run("git", ["push", "origin", `refs/tags/${manifest.tag}`]);
135143
await assertRemoteTag(manifest.tag, manifest.commit, false);
136144
}
137145

@@ -161,27 +169,6 @@ async function remoteTagCommit(tag: string): Promise<string | null> {
161169
return parseRemoteTagCommit(output, tag);
162170
}
163171

164-
async function retry(
165-
label: string,
166-
attempts: number,
167-
operation: () => Promise<void>,
168-
): Promise<void> {
169-
let lastError: unknown;
170-
for (let attempt = 1; attempt <= attempts; attempt += 1) {
171-
try {
172-
await operation();
173-
return;
174-
} catch (error) {
175-
lastError = error;
176-
if (attempt < attempts) {
177-
console.warn(`${label} failed on attempt ${attempt}; retrying`);
178-
await wait(2_000 * attempt);
179-
}
180-
}
181-
}
182-
throw lastError;
183-
}
184-
185172
async function writeSummary(
186173
bundleDirectory: string,
187174
manifest: ReleaseBundleManifest,

‎tools/release/release.test.ts‎

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import assert from "node:assert/strict";
22
import { resolve } from "node:path";
33
import test from "node:test";
44

5-
import { rewriteNodeLoaderVersion, shouldCopyStagePath } from "./assemble.ts";
5+
import {
6+
rewriteNodeLoaderVersion,
7+
shouldCopyStagePath,
8+
type ReleaseBundlePackage,
9+
} from "./assemble.ts";
610
import { bootstrapState } from "./bootstrap-registry.ts";
711
import { bootstrapVersion, isReleasePath, npmRegistry } from "./config.ts";
812
import { parseRemoteTagCommit, root } from "./lib.ts";
@@ -12,6 +16,7 @@ import {
1216
revokeTemporaryAuthentication,
1317
type CommandRunner,
1418
} from "./npm-trust.ts";
19+
import { publishReleasePackages } from "./publish.ts";
1520
import {
1621
automaticBump,
1722
incrementVersion,
@@ -70,6 +75,71 @@ void test("remote release tags resolve lightweight and annotated commits", () =>
7075
assert.equal(parseRemoteTagCommit("", "v0.0.1"), null);
7176
});
7277

78+
void test("publication preflights every package before the first registry write", async () => {
79+
const missing = releasePackage("@taffyjs/missing", "sha512-missing");
80+
const conflicting = releasePackage("@taffyjs/conflicting", "sha512-expected");
81+
const published: string[] = [];
82+
83+
await assert.rejects(
84+
() =>
85+
publishReleasePackages([missing, conflicting], {
86+
registryIntegrity: async (name) => (name === missing.name ? null : "sha512-unexpected"),
87+
publish: async ({ name }) => {
88+
published.push(name);
89+
},
90+
}),
91+
/already exists with different bytes/,
92+
);
93+
94+
assert.deepEqual(published, []);
95+
});
96+
97+
void test("publication skips matching versions and does not read them again after writing", async () => {
98+
const existing = releasePackage("@taffyjs/existing", "sha512-existing");
99+
const firstMissing = releasePackage("@taffyjs/first-missing", "sha512-first-missing");
100+
const secondMissing = releasePackage("@taffyjs/second-missing", "sha512-second-missing");
101+
const events: string[] = [];
102+
103+
await publishReleasePackages([existing, firstMissing, secondMissing], {
104+
registryIntegrity: async (name) => {
105+
events.push(`read ${name}`);
106+
return name === existing.name ? existing.integrity : null;
107+
},
108+
publish: async ({ name }) => {
109+
events.push(`publish ${name}`);
110+
},
111+
});
112+
113+
assert.deepEqual(events, [
114+
`read ${existing.name}`,
115+
`read ${firstMissing.name}`,
116+
`read ${secondMissing.name}`,
117+
`publish ${firstMissing.name}`,
118+
`publish ${secondMissing.name}`,
119+
]);
120+
});
121+
122+
void test("publication stops at a command failure without retrying or continuing", async () => {
123+
const first = releasePackage("@taffyjs/first", "sha512-first");
124+
const failing = releasePackage("@taffyjs/failing", "sha512-failing");
125+
const unattempted = releasePackage("@taffyjs/unattempted", "sha512-unattempted");
126+
const attempts: string[] = [];
127+
128+
await assert.rejects(
129+
() =>
130+
publishReleasePackages([first, failing, unattempted], {
131+
registryIntegrity: async () => null,
132+
publish: async ({ name }) => {
133+
attempts.push(name);
134+
if (name === failing.name) throw new Error("publish failed");
135+
},
136+
}),
137+
/publish failed/,
138+
);
139+
140+
assert.deepEqual(attempts, [first.name, failing.name]);
141+
});
142+
73143
void test("bootstrap revokes only the npm login it creates", async () => {
74144
const events: string[] = [];
75145
let authenticated = false;
@@ -213,6 +283,17 @@ function commit(subject: string, paths: readonly string[]): ConventionalCommit {
213283
return { hash: "1234567890abcdef", subject, body: "", paths };
214284
}
215285

286+
function releasePackage(name: string, integrity: string): ReleaseBundlePackage {
287+
return {
288+
name,
289+
kind: "binding",
290+
version: "0.0.2",
291+
tarball: `${name.replaceAll("/", "-")}.tgz`,
292+
integrity,
293+
files: ["package.json"],
294+
};
295+
}
296+
216297
function parsed(subject: string) {
217298
return parseConventionalCommit(commit(subject, ["packages/taffyjs-node/src/index.ts"]));
218299
}

0 commit comments

Comments
 (0)