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
41 changes: 40 additions & 1 deletion .github/scripts/release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`, {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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,
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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');
}
}

Expand Down
93 changes: 92 additions & 1 deletion .github/scripts/release.test.mjs
Original file line number Diff line number Diff line change
@@ -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' };
Expand Down Expand Up @@ -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);
});
8 changes: 7 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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:
Expand Down
Loading