From 430426b415e03e305cb7aaa6bfd4a137ab9934ad Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sun, 4 Oct 2026 18:04:11 +0000 Subject: [PATCH 1/3] fix(ci): retry Go checksum propagation after release tagging --- .github/workflows/ci.yml | 18 +++++++++++++++++- docs/releasing.md | 4 +++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 918f6dd..0dc3181 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,6 +185,7 @@ jobs: consumable: name: a stranger can `go get` this runs-on: ubuntu-latest + timeout-minutes: 40 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -298,7 +299,22 @@ jobs: func main() {} GO - go get "${modules[@]}" + # The checksum service can briefly report a new tag as unknown even though GitHub has it. + # Allow its documented 30-minute cache window, only for this release's missing revisions. + attempt=0 + until go get "${modules[@]}" 2>go-get.log; do + cat go-get.log >&2 + if [ -z "$RELEASE_VERSION" ] || [ "$attempt" -ge 30 ] \ + || ! grep -Fq 'reading https://sum.golang.org/lookup/' go-get.log \ + || ! grep -Fq -e "unknown revision gateway/v${RELEASE_VERSION}" \ + -e "unknown revision gateway/kube/v${RELEASE_VERSION}" go-get.log; then + exit 1 + fi + attempt=$((attempt + 1)) + echo "::notice::waiting for Go checksum service to see v${RELEASE_VERSION} (${attempt}/30); retrying in 60 seconds" + sleep 60 + done + cat go-get.log >&2 go build ./... go list -m github.com/ConfigButler/krm-stream/gateway github.com/ConfigButler/krm-stream/gateway/kube echo "a stranger can go get and build the Kubernetes adapter at ${revision}." diff --git a/docs/releasing.md b/docs/releasing.md index 0f4f529..b5ac4c1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -99,7 +99,9 @@ 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 CI retries this error once a minute for up to 30 minutes, covering + the [Go module mirror's cache window](https://sum.golang.org/). Checksum verification stays enabled; + other errors fail immediately. 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: From 2e2768f5d654e0da55a77334bb6bc8584fed8781 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sun, 4 Oct 2026 18:18:07 +0000 Subject: [PATCH 2/3] fix(ci): check Go release readiness before validation --- .github/scripts/release.mjs | 36 +++++++++++++- .github/scripts/release.test.mjs | 80 +++++++++++++++++++++++++++++++- .github/workflows/ci.yml | 18 +------ .github/workflows/release.yml | 8 +++- docs/releasing.md | 13 ++++-- 5 files changed, 130 insertions(+), 25 deletions(-) diff --git a/.github/scripts/release.mjs b/.github/scripts/release.mjs index 96b28ab..f0479a0 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, @@ -115,6 +147,8 @@ async function main() { output('version', version); output('needed', await needsPublish(version)); console.log(`Release ${version} resolves to ${sha}.`); + } else if (process.argv[2] === 'go-ready') { + await waitForGoRelease(version); } else if (process.argv[2] === 'verify') { const tarball = `/tmp/npm/configbutler-krm-stream-${version}.tgz`; const pkg = JSON.parse(execFileSync('tar', ['-xOf', tarball, 'package/package.json'], { encoding: 'utf8' })); @@ -139,7 +173,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..0124434 100644 --- a/.github/scripts/release.test.mjs +++ b/.github/scripts/release.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { needsPublish, packageName, publishWithRecovery, registryMetadata, releaseTags, validatePackage, validateRelease } from './release.mjs'; +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 +112,81 @@ 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 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/ci.yml b/.github/workflows/ci.yml index 0dc3181..918f6dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,7 +185,6 @@ jobs: consumable: name: a stranger can `go get` this runs-on: ubuntu-latest - timeout-minutes: 40 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -299,22 +298,7 @@ jobs: func main() {} GO - # The checksum service can briefly report a new tag as unknown even though GitHub has it. - # Allow its documented 30-minute cache window, only for this release's missing revisions. - attempt=0 - until go get "${modules[@]}" 2>go-get.log; do - cat go-get.log >&2 - if [ -z "$RELEASE_VERSION" ] || [ "$attempt" -ge 30 ] \ - || ! grep -Fq 'reading https://sum.golang.org/lookup/' go-get.log \ - || ! grep -Fq -e "unknown revision gateway/v${RELEASE_VERSION}" \ - -e "unknown revision gateway/kube/v${RELEASE_VERSION}" go-get.log; then - exit 1 - fi - attempt=$((attempt + 1)) - echo "::notice::waiting for Go checksum service to see v${RELEASE_VERSION} (${attempt}/30); retrying in 60 seconds" - sleep 60 - done - cat go-get.log >&2 + go get "${modules[@]}" go build ./... go list -m github.com/ConfigButler/krm-stream/gateway github.com/ConfigButler/krm-stream/gateway/kube echo "a stranger can go get and build the Kubernetes adapter at ${revision}." 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 b5ac4c1..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,9 +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). Release CI retries this error once a minute for up to 30 minutes, covering - the [Go module mirror's cache window](https://sum.golang.org/). Checksum verification stays enabled; - other errors fail immediately. If the delay outlasts that window, wait and rerun the failed job. + 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: From 5517992d3d89737f46823eeb72ce6f02491e3a19 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sun, 4 Oct 2026 18:26:29 +0000 Subject: [PATCH 3/3] fix(ci): require the resolved version for Go readiness --- .github/scripts/release.mjs | 9 +++++++-- .github/scripts/release.test.mjs | 13 +++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/scripts/release.mjs b/.github/scripts/release.mjs index f0479a0..a07b907 100644 --- a/.github/scripts/release.mjs +++ b/.github/scripts/release.mjs @@ -126,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') { @@ -147,8 +154,6 @@ async function main() { output('version', version); output('needed', await needsPublish(version)); console.log(`Release ${version} resolves to ${sha}.`); - } else if (process.argv[2] === 'go-ready') { - await waitForGoRelease(version); } else if (process.argv[2] === 'verify') { const tarball = `/tmp/npm/configbutler-krm-stream-${version}.tgz`; const pkg = JSON.parse(execFileSync('tar', ['-xOf', tarball, 'package/package.json'], { encoding: 'utf8' })); diff --git a/.github/scripts/release.test.mjs b/.github/scripts/release.test.mjs index 0124434..ac48d01 100644 --- a/.github/scripts/release.test.mjs +++ b/.github/scripts/release.test.mjs @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; import { goReleaseReady, needsPublish, packageName, publishWithRecovery, registryMetadata, releaseTags, validatePackage, validateRelease, waitForGoRelease } from './release.mjs'; const sha = 'a'.repeat(40); @@ -117,6 +120,16 @@ const goResponse = (status, body = '') => ({ status, ok: status === 200, text: a 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); };