Skip to content

Commit 245fd9a

Browse files
authored
chore(release): wait for published artifacts (#821)
## Summary - retry npm metadata, provenance, GitHub Release, and installed-CLI proof while newly published artifacts propagate - validate exact version, `gitHead`, workflow provenance, release notes, and CLI behavior on every successful retry boundary - keep the bounded 24-attempt / five-second release assertion window and report the final propagation error ## Evidence The automatic `v6.8.0` release published successfully from exact tested SHA `a5d080236a28952e54b50449bd5f6fbfe300ca4b`. Its assertion queried npm provenance roughly eleven seconds after publication and received a transient 404; the same attestation endpoint returned 200 seconds later with two attestations. This is a `chore:` release-process correction and is intentionally a semantic-release no-op. ## Validation The complete CI, including the integration suite, must run on GitHub-hosted workers. Locally I only ran syntax and diff checks.
1 parent a5d0802 commit 245fd9a

2 files changed

Lines changed: 189 additions & 20 deletions

File tree

scripts/assert-release-published.js

Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -179,28 +179,74 @@ function sleep(ms) {
179179
});
180180
}
181181

182+
function nextRetryDelay(attempt, attempts, delayMs, options) {
183+
if (attempt >= attempts) return null;
184+
if (options.deadline === undefined) return delayMs;
185+
186+
const now = options.now || Date.now;
187+
const remainingMs = options.deadline - now();
188+
if (remainingMs <= 0) return null;
189+
return Math.min(delayMs, remainingMs);
190+
}
191+
182192
async function waitForNpmLatest(name, expectedVersion, options = {}) {
183193
const attempts =
184194
options.attempts || Number(process.env.RELEASE_ASSERT_ATTEMPTS || DEFAULT_ATTEMPTS);
185195
const delayMs =
186196
options.delayMs || Number(process.env.RELEASE_ASSERT_DELAY_MS || DEFAULT_DELAY_MS);
197+
const wait = options.sleep || sleep;
187198

188199
let latest = null;
189200
for (let attempt = 1; attempt <= attempts; attempt += 1) {
190201
latest = npmLatest(name);
191202
if (latest === expectedVersion) return latest;
192203

193-
if (attempt < attempts) {
204+
const retryDelay = nextRetryDelay(attempt, attempts, delayMs, options);
205+
if (retryDelay !== null) {
194206
console.log(
195207
`npm latest for ${name} is ${latest}; waiting for ${expectedVersion} (${attempt}/${attempts})`
196208
);
197-
await sleep(delayMs);
209+
await wait(retryDelay);
210+
} else {
211+
break;
198212
}
199213
}
200214

201215
throw new Error(`expected npm latest for ${name} to be ${expectedVersion}, got ${latest}`);
202216
}
203217

218+
async function waitForPublishedArtifact(label, check, options = {}) {
219+
const attempts =
220+
options.attempts || Number(process.env.RELEASE_ASSERT_ATTEMPTS || DEFAULT_ATTEMPTS);
221+
const delayMs =
222+
options.delayMs || Number(process.env.RELEASE_ASSERT_DELAY_MS || DEFAULT_DELAY_MS);
223+
const wait = options.sleep || sleep;
224+
let lastError = null;
225+
let attemptsMade = 0;
226+
227+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
228+
attemptsMade = attempt;
229+
try {
230+
return await check();
231+
} catch (error) {
232+
lastError = error;
233+
const retryDelay = nextRetryDelay(attempt, attempts, delayMs, options);
234+
if (retryDelay !== null) {
235+
console.log(
236+
`${label} is not ready: ${error.message}; retrying (${attempt}/${attempts})`
237+
);
238+
await wait(retryDelay);
239+
} else {
240+
break;
241+
}
242+
}
243+
}
244+
245+
throw new Error(
246+
`${label} did not become ready after ${attemptsMade} attempts: ${lastError?.message || 'unknown error'}`
247+
);
248+
}
249+
204250
async function main() {
205251
const name = packageName();
206252
const headTags = tagsPointingAtHead();
@@ -218,30 +264,62 @@ async function main() {
218264

219265
console.log(`tags on HEAD: ${headTags.join(', ') || '(none)'}`);
220266
const expectedVersion = expectedTag.slice(1);
221-
const latest = await waitForNpmLatest(name, expectedVersion);
267+
const retryAttempts = Number(process.env.RELEASE_ASSERT_ATTEMPTS || DEFAULT_ATTEMPTS);
268+
const retryDelayMs = Number(process.env.RELEASE_ASSERT_DELAY_MS || DEFAULT_DELAY_MS);
269+
const retryOptions = {
270+
attempts: retryAttempts,
271+
delayMs: retryDelayMs,
272+
deadline: Date.now() + retryAttempts * retryDelayMs,
273+
};
274+
const latest = await waitForNpmLatest(name, expectedVersion, retryOptions);
222275

223276
console.log(`npm latest for ${name}: ${latest}`);
224277

225278
const expectedCommit = run('git', ['rev-parse', 'HEAD']);
226-
const metadata = npmReleaseMetadata(name, expectedVersion);
227-
if (metadata.version !== expectedVersion) {
228-
throw new Error(`npm metadata returned ${metadata.version}; expected ${expectedVersion}`);
229-
}
230-
if (metadata.gitHead !== expectedCommit) {
231-
throw new Error(`npm gitHead ${metadata.gitHead || '(missing)'} does not match HEAD`);
232-
}
279+
const metadata = await waitForPublishedArtifact(
280+
'npm release metadata',
281+
() => {
282+
const result = npmReleaseMetadata(name, expectedVersion);
283+
if (result.version !== expectedVersion) {
284+
throw new Error(`npm metadata returned ${result.version}; expected ${expectedVersion}`);
285+
}
286+
if (result.gitHead !== expectedCommit) {
287+
throw new Error(`npm gitHead ${result.gitHead || '(missing)'} does not match HEAD`);
288+
}
289+
if (!result['dist.attestations']?.url) {
290+
throw new Error('npm attestation URL is missing');
291+
}
292+
return result;
293+
},
294+
retryOptions
295+
);
233296

234-
const attestationUrl = metadata['dist.attestations']?.url;
235-
if (!attestationUrl) throw new Error('npm attestation URL is missing');
236-
const attestations = await httpsJson(attestationUrl);
237-
verifyProvenance(provenanceStatement(attestations), expectedCommit);
297+
await waitForPublishedArtifact(
298+
'npm provenance',
299+
async () => {
300+
const attestations = await httpsJson(metadata['dist.attestations'].url);
301+
verifyProvenance(provenanceStatement(attestations), expectedCommit);
302+
},
303+
retryOptions
304+
);
238305

239-
const release = githubRelease(expectedTag);
240-
if (release.tagName !== expectedTag) {
241-
throw new Error(`GitHub Release tag ${release.tagName} does not match ${expectedTag}`);
242-
}
243-
verifyCuratedNotes(expectedTag, release);
244-
verifyInstalledCli(name, expectedVersion);
306+
await waitForPublishedArtifact(
307+
'GitHub Release',
308+
() => {
309+
const release = githubRelease(expectedTag);
310+
if (release.tagName !== expectedTag) {
311+
throw new Error(`GitHub Release tag ${release.tagName} does not match ${expectedTag}`);
312+
}
313+
verifyCuratedNotes(expectedTag, release);
314+
},
315+
retryOptions
316+
);
317+
318+
await waitForPublishedArtifact(
319+
'installed CLI',
320+
() => verifyInstalledCli(name, expectedVersion),
321+
retryOptions
322+
);
245323

246324
console.log(`Release publication verified: ${name}@${latest}`);
247325
}
@@ -263,4 +341,5 @@ module.exports = {
263341
verifyInstalledCli,
264342
verifyProvenance,
265343
waitForNpmLatest,
344+
waitForPublishedArtifact,
266345
};

tests/assert-release-published.test.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const {
55
provenanceStatement,
66
verifyInstalledCli,
77
verifyProvenance,
8+
waitForPublishedArtifact,
89
} = require('../scripts/assert-release-published');
910
const { releaseTypeForMessages } = require('../scripts/release-preflight');
1011

@@ -132,4 +133,93 @@ describe('release publication assertion', () => {
132133
);
133134
assert.strictEqual(removed, '/tmp/zeroshot-release-proof-failure');
134135
});
136+
137+
it('retries eventually consistent publication artifacts', async () => {
138+
let checks = 0;
139+
const delays = [];
140+
141+
const result = await waitForPublishedArtifact(
142+
'npm provenance',
143+
() => {
144+
checks += 1;
145+
if (checks < 3) throw new Error('HTTP 404: Not found');
146+
return 'ready';
147+
},
148+
{
149+
attempts: 3,
150+
delayMs: 25,
151+
sleep: (delay) => {
152+
delays.push(delay);
153+
return Promise.resolve();
154+
},
155+
}
156+
);
157+
158+
assert.strictEqual(result, 'ready');
159+
assert.strictEqual(checks, 3);
160+
assert.deepStrictEqual(delays, [25, 25]);
161+
});
162+
163+
it('reports the last publication propagation failure after exhausting retries', async () => {
164+
let checks = 0;
165+
166+
await assert.rejects(
167+
waitForPublishedArtifact(
168+
'npm provenance',
169+
() => {
170+
checks += 1;
171+
throw new Error(`not ready ${checks}`);
172+
},
173+
{
174+
attempts: 2,
175+
delayMs: 0,
176+
sleep: () => Promise.resolve(),
177+
}
178+
),
179+
/npm provenance did not become ready after 2 attempts: not ready 2/
180+
);
181+
assert.strictEqual(checks, 2);
182+
});
183+
184+
it('shares one deadline across sequential publication checks', async () => {
185+
let now = 0;
186+
let checks = 0;
187+
const retryOptions = {
188+
attempts: 24,
189+
delayMs: 5,
190+
deadline: 10,
191+
now: () => now,
192+
sleep: (delay) => {
193+
now += delay;
194+
return Promise.resolve();
195+
},
196+
};
197+
198+
await assert.rejects(
199+
waitForPublishedArtifact(
200+
'npm provenance',
201+
() => {
202+
checks += 1;
203+
throw new Error('not ready');
204+
},
205+
retryOptions
206+
),
207+
/after 3 attempts/
208+
);
209+
assert.strictEqual(now, 10);
210+
211+
await assert.rejects(
212+
waitForPublishedArtifact(
213+
'GitHub Release',
214+
() => {
215+
checks += 1;
216+
throw new Error('not ready');
217+
},
218+
retryOptions
219+
),
220+
/after 1 attempts/
221+
);
222+
assert.strictEqual(checks, 4);
223+
assert.strictEqual(now, 10);
224+
});
135225
});

0 commit comments

Comments
 (0)