diff --git a/.github/scripts/release.mjs b/.github/scripts/release.mjs index 96b28ab..a07b907 100644 --- a/.github/scripts/release.mjs +++ b/.github/scripts/release.mjs @@ -64,6 +64,38 @@ export function validatePackage(pkg, version) { assert.equal(pkg.version, version, 'tarball version disagrees with release'); } +// A successful lookup means the release is visible; Go still verifies the signed checksums in CI. +// Only the exact missing-tag response is a propagation delay. Inspect both modules so an unrelated +// failure cannot be mistaken for a delay in the other module. +export async function goReleaseReady(version, fetcher = fetch) { + releaseTags(version); + const states = await Promise.all(['gateway', 'gateway/kube'].map(async path => { + const module = `github.com/ConfigButler/krm-stream/${path}`; + const response = await fetcher(`https://sum.golang.org/lookup/github.com/!config!butler/krm-stream/${path}@v${version}`, { + signal: AbortSignal.timeout(30_000), + }); + const body = (await response.text()).trim(); + if (response.ok) return true; + const missing = `not found: ${module}@v${version}: invalid version: unknown revision ${path}/v${version}`; + assert.ok((response.status === 404 || response.status === 410) && body === missing, + `Go checksum lookup for ${module}@v${version} failed: HTTP ${response.status}: ${body}`); + return false; + })); + return states.every(Boolean); +} + +export async function waitForGoRelease(version, { fetcher = fetch, + wait = ms => new Promise(resolve => setTimeout(resolve, ms)), now = Date.now } = {}) { + const deadline = now() + 30 * 60_000; + while (!await goReleaseReady(version, fetcher)) { + const remaining = deadline - now(); + assert.ok(remaining > 0, `Go checksum records for v${version} are still missing after 30 minutes`); + console.log(`Waiting for Go checksum records for v${version}; checking again in ${Math.min(60_000, remaining) / 1_000} seconds.`); + await wait(Math.min(60_000, remaining)); + } + console.log(`Go checksum records for v${version} are ready.`); +} + // A registry read may lag a successful upload. Only recover npm's explicit immutable-version // rejection, and only when the registry confirms exactly the validated artifact's bytes. export async function publishWithRecovery({ version, integrity, publish, fetcher = fetch, @@ -94,6 +126,13 @@ const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); const output = (key, value) => appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`); async function main() { + // Readiness uses the version already resolved and validated by release preparation. + // Require it explicitly so local manifest contents cannot flow into an outbound request. + if (process.argv[2] === 'go-ready') { + assert.ok(process.env.VERSION, 'go-ready requires an explicit VERSION from release preparation'); + await waitForGoRelease(process.env.VERSION); + return; + } const version = process.env.VERSION || JSON.parse(readFileSync('.release-please-manifest.json'))['packages/krm-stream']; const tags = releaseTags(version); if (process.argv[2] === 'resolve') { @@ -139,7 +178,7 @@ async function main() { } }); console.log(`${packageName}@${version}: ${result}.`); } else { - throw new Error('usage: release.mjs resolve|verify|publish'); + throw new Error('usage: release.mjs resolve|go-ready|verify|publish'); } } diff --git a/.github/scripts/release.test.mjs b/.github/scripts/release.test.mjs index f1e9c9e..ac48d01 100644 --- a/.github/scripts/release.test.mjs +++ b/.github/scripts/release.test.mjs @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; import { test } from 'node:test'; -import { needsPublish, packageName, publishWithRecovery, registryMetadata, releaseTags, validatePackage, validateRelease } from './release.mjs'; +import { fileURLToPath } from 'node:url'; +import { goReleaseReady, needsPublish, packageName, publishWithRecovery, registryMetadata, releaseTags, validatePackage, validateRelease, waitForGoRelease } from './release.mjs'; const sha = 'a'.repeat(40); const manifest = { 'packages/krm-stream': '0.3.0', gateway: '0.3.0', 'gateway/kube': '0.3.0' }; @@ -112,3 +115,91 @@ test('registry outage during duplicate confirmation remains a failure', async () await assert.rejects(publishWithRecovery({ version: '0.3.0', integrity, publish: async () => { throw duplicate(); }, fetcher: async () => response(503) }), /HTTP 503/); }); + +const goResponse = (status, body = '') => ({ status, ok: status === 200, text: async () => body }); +const missingGoTag = (path = 'gateway', version = '0.3.0') => + `not found: github.com/ConfigButler/krm-stream/${path}@v${version}: invalid version: unknown revision ${path}/v${version}`; + +test('Go readiness CLI requires an explicit release version before reading any local manifest', () => { + const env = { ...process.env }; + delete env.VERSION; + const result = spawnSync(process.execPath, [fileURLToPath(new URL('./release.mjs', import.meta.url)), 'go-ready'], { + env, cwd: tmpdir(), encoding: 'utf8', timeout: 5_000, + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /go-ready requires an explicit VERSION from release preparation/); +}); + +test('Go readiness checks both release modules and validates the version before any request', async () => { + const urls = []; + const fetcher = async url => { urls.push(url); return goResponse(200); }; + assert.equal(await goReleaseReady('0.3.0', fetcher), true); + assert.deepEqual(urls, [ + 'https://sum.golang.org/lookup/github.com/!config!butler/krm-stream/gateway@v0.3.0', + 'https://sum.golang.org/lookup/github.com/!config!butler/krm-stream/gateway/kube@v0.3.0', + ]); + await assert.rejects(goReleaseReady('0.3.0/../../main', async () => assert.fail('unexpected request'))); +}); + +test('only an exact missing-tag response for the requested module is a propagation delay', async () => { + for (const path of ['gateway', 'gateway/kube']) { + for (const status of [404, 410]) { + assert.equal(await goReleaseReady('0.3.0', async url => url.endsWith(`/${path}@v0.3.0`) + ? goResponse(status, missingGoTag(path)) : goResponse(200)), false); + } + } + for (const [status, body] of [ + [404, ''], [404, missingGoTag('gateway', '0.2.1')], [404, missingGoTag('unrelated')], + [404, `${missingGoTag()}\nchecksum mismatch`], [403, missingGoTag()], [503, missingGoTag()], + ]) { + await assert.rejects(goReleaseReady('0.3.0', async () => goResponse(status, body)), /Go checksum lookup/); + } +}); + +test('a missing tag in one module cannot hide an unrelated error in the other', async () => { + for (const missingPath of ['gateway', 'gateway/kube']) { + await assert.rejects(waitForGoRelease('0.3.0', { + fetcher: async url => url.endsWith(`/${missingPath}@v0.3.0`) + ? goResponse(404, missingGoTag(missingPath)) : goResponse(503, 'service unavailable'), + wait: async () => assert.fail('unrelated errors must not wait'), + }), /HTTP 503/); + } + await assert.rejects(waitForGoRelease('0.3.0', { + fetcher: async () => { throw new Error('network down'); }, + wait: async () => assert.fail('network errors must not wait'), + }), /network down/); +}); + +test('release preparation proceeds as soon as both checksum records are visible', async () => { + let reads = 0; + const waits = []; + await waitForGoRelease('0.3.0', { + fetcher: async () => ++reads === 1 ? goResponse(404, missingGoTag()) : goResponse(200), + wait: async ms => waits.push(ms), + }); + assert.equal(reads, 4); + assert.deepEqual(waits, [60_000]); +}); + +test('ready releases do not wait', async () => { + await waitForGoRelease('0.3.0', { + fetcher: async () => goResponse(200), + wait: async () => assert.fail('ready records must not wait'), + }); +}); + +test('the readiness deadline includes request time and never waits beyond the remaining budget', async () => { + let time = 0; + const waits = []; + await assert.rejects(waitForGoRelease('0.3.0', { + now: () => time, + fetcher: async url => { + time += 7_000; + return goResponse(404, missingGoTag(url.includes('/gateway/kube@') ? 'gateway/kube' : 'gateway')); + }, + wait: async ms => { waits.push(ms); time += ms; }, + }), /still missing after 30 minutes/); + assert.ok(waits.every(ms => ms > 0 && ms <= 60_000)); + assert.ok(waits.at(-1) < 60_000); + assert.ok(waits.reduce((sum, ms) => sum + ms, 0) < 30 * 60_000); +}); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bcdc0e..565904d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: name: prepare the release if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 40 permissions: contents: write pull-requests: write @@ -57,6 +57,12 @@ jobs: VERSION: ${{ inputs.version || steps.release.outputs['packages/krm-stream--version'] }} run: node .github/scripts/release.mjs resolve + - name: wait for Go release checksum records + if: steps.target.outputs.needed == 'true' + env: + VERSION: ${{ steps.target.outputs.version }} + run: node .github/scripts/release.mjs go-ready + ci: name: ci needs: prepare diff --git a/docs/releasing.md b/docs/releasing.md index 0f4f529..133c6f0 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -20,8 +20,9 @@ Nothing in it needs editing by hand. `@configbutler/krm-stream`. 3. Merge the release pull request. `release.yml` then: 1. lets Release Please create the tags and select the release commit; - 2. validates and packs that commit once in CI, including a clean consumer of the public Go tag; - 3. publishes to npm the tarball produced by that CI run. + 2. waits for both Go modules' checksum records to become available; + 3. validates and packs that commit once in CI, including a clean consumer of the public Go tag; + 4. publishes to npm the tarball produced by that CI run. If a step fails, see [recovering a failed release](#recovering-a-failed-release). @@ -99,7 +100,11 @@ npm version is published. Then: SHA-512 integrity matches the validated tarball. Other publish failures, unavailable metadata and mismatched artifacts still fail. - **The Go checksum database does not know the new tag yet** (`unknown revision gateway/vX.Y.Z` - shortly after tagging). Wait and rerun; do not bypass checksum verification. + shortly after tagging). Release preparation checks both modules' checksum lookups once a minute + for up to 30 minutes, covering the [Go module mirror's cache window](https://sum.golang.org/). + Only the exact response identifying a missing release tag is treated as a propagation delay; + other lookup errors fail immediately. The consumer check runs once with checksum verification + enabled. If the delay outlasts that window, wait and rerun the failed job. - **The tarball has expired.** Tarballs are kept for 30 days, matching [GitHub's job retry window](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs). An explicit recovery run rebuilds and validates once: