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
110 changes: 110 additions & 0 deletions .github/scripts/release.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { appendFileSync, readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';

export const packageName = '@configbutler/krm-stream';
export function releaseTags(version) {
assert.match(version, /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/, 'expected a stable release version');
return [`${packageName}-v${version}`, `gateway/v${version}`, `gateway/kube/v${version}`];
}

export function validateRelease(version, shas, manifest, pkg) {
releaseTags(version);
assert.equal(shas.length, 3);
assert.match(shas[0], /^[a-f0-9]{40}$/);
assert.ok(shas.every(sha => sha === shas[0]), 'release tags must identify the same commit');
for (const path of ['packages/krm-stream', 'gateway', 'gateway/kube']) {
assert.equal(manifest[path], version, `${path} manifest version disagrees with tag`);
}
assert.equal(pkg.name, packageName);
assert.equal(pkg.version, version, 'package version disagrees with tag');
return shas[0];
}

// Keep the outbound URL constant: manifest and workflow-input data stay local.
export async function registryMetadata(fetcher = fetch) {
const response = await fetcher('https://registry.npmjs.org/@configbutler%2Fkrm-stream', {
headers: { Accept: 'application/vnd.npm.install-v1+json' },
signal: AbortSignal.timeout(30_000),
});
if (response.status === 404) return null;
assert.ok(response.ok, `npm registry request failed: HTTP ${response.status}`);
const metadata = await response.json();
assert.equal(metadata.name, packageName);
assert.ok(metadata.versions && typeof metadata.versions === 'object');
assert.ok(metadata['dist-tags'] && typeof metadata['dist-tags'] === 'object');
return metadata;
}

export async function needsPublish(version, fetcher = fetch) {
releaseTags(version);
const metadata = await registryMetadata(fetcher);
if (!metadata) return true;
const existing = metadata.versions[version];
if (existing) {
assert.equal(existing.version, version);
return false;
}
const latest = metadata['dist-tags'].latest;
if (latest) {
releaseTags(latest);
const candidate = version.split('.').map(Number);
const current = latest.split('.').map(Number);
const differing = candidate.findIndex((part, i) => part !== current[i]);
assert.ok(differing >= 0 && candidate[differing] > current[differing],
`refusing to move npm latest backwards from ${latest} to ${version}`);
}
return true;
}

export function validateArtifact(metadata, pkg, integrity, sha, version) {
assert.equal(metadata.sha, sha, 'artifact was built from a different commit');
assert.equal(metadata.integrity, integrity, 'artifact bytes disagree with build metadata');
assert.equal(pkg.name, packageName);
assert.equal(pkg.version, version, 'tarball version disagrees with release');
}

const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
const output = (key, value) => appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`);

async function main() {
const version = process.env.VERSION || JSON.parse(readFileSync('.release-please-manifest.json'))['packages/krm-stream'];
const tags = releaseTags(version);
if (process.argv[2] === 'resolve') {
// A package version without its tag is not a release yet. API failures must fail the job.
const releases = JSON.parse(execFileSync('gh', ['api', '--paginate', '--slurp',
`repos/${process.env.GITHUB_REPOSITORY}/releases`], { encoding: 'utf8' })).flat();
const release = releases.find(item => item.tag_name === tags[0] && !item.draft && !item.prerelease);
if (!release) {
output('needed', false);
console.log(`No stable GitHub release for ${version} yet.`);
return;
}
const shas = tags.map(tag => git('rev-parse', `refs/tags/${tag}^{commit}`));
const readAtTag = path => JSON.parse(git('show', `${shas[0]}:${path}`));
const sha = validateRelease(version, shas, readAtTag('.release-please-manifest.json'),
readAtTag('packages/krm-stream/package.json'));
git('merge-base', '--is-ancestor', sha, 'origin/main');
output('sha', sha);
output('version', version);
output('gateway_tag', tags[1]);
output('kube_tag', tags[2]);
output('needed', await needsPublish(version));
console.log(`Release ${version} resolves to ${sha}.`);
} 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' }));
const metadata = JSON.parse(readFileSync('/tmp/npm/source.json'));
const integrity = `sha512-${createHash('sha512').update(readFileSync(tarball)).digest('base64')}`;
validateArtifact(metadata, pkg, integrity, process.env.RELEASE_SHA, version);
// A retry after npm accepted the upload is successful without another publish attempt.
output('needed', await needsPublish(version));
output('tarball', tarball);
} else {
throw new Error('usage: release.mjs resolve|verify');
}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
56 changes: 56 additions & 0 deletions .github/scripts/release.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { needsPublish, packageName, registryMetadata, releaseTags, validateArtifact, validateRelease } 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' };
const pkg = { name: packageName, version: '0.3.0' };
const response = (status, body) => ({ status, ok: status === 200, json: async () => body });
const metadata = (version) => ({ name: packageName, versions: { [version]: { version } }, 'dist-tags': { latest: version } });

test('a release resolves to its tagged commit independently of the triggering commit', () => {
assert.equal(validateRelease('0.3.0', [sha, sha, sha], manifest, pkg), sha);
assert.throws(() => validateRelease('0.3.0', [sha, 'b'.repeat(40), sha], manifest, pkg));
assert.throws(() => validateRelease('0.3.0', [sha, sha, sha], manifest, { ...pkg, version: '0.2.1' }));
assert.throws(() => validateRelease('0.3.0', [sha, sha, sha], { ...manifest, gateway: '0.2.1' }, pkg));
assert.throws(() => releaseTags('0.3.0/../../main'));
});

test('a tagged version missing from npm is recovered even without releases_created', async () => {
const fetcher = async () => response(200, metadata('0.2.1'));
assert.equal(await needsPublish('0.3.0', fetcher), true);
});

test('retry after successful publication is a no-op', async () => {
assert.equal(await needsPublish('0.3.0', async () => response(200, metadata('0.3.0'))), false);
});

test('registry failures do not look like an unpublished package', async () => {
await assert.rejects(registryMetadata(async () => response(503)));
await assert.rejects(registryMetadata(async () => { throw new Error('network down'); }));
await assert.rejects(registryMetadata(async () => response(200, { name: 'unexpected-package' })));
});

test('replaying an old run cannot downgrade npm latest', async () => {
await assert.rejects(needsPublish('0.3.0', async () => response(200, metadata('0.4.0'))));
});

test('the original race and mismatched build bytes are rejected before publishing', () => {
const metadata = { sha, integrity: 'sha512-example' };
validateArtifact(metadata, pkg, metadata.integrity, sha, '0.3.0');
assert.throws(() => validateArtifact(metadata, { ...pkg, version: '0.2.1' }, metadata.integrity, sha, '0.3.0'));
assert.throws(() => validateArtifact(metadata, pkg, metadata.integrity, 'b'.repeat(40), '0.3.0'));
assert.throws(() => validateArtifact(metadata, pkg, 'sha512-other', sha, '0.3.0'));
});

test('registry requests never include manifest or workflow-input data', async () => {
const requests = [];
const fetcher = async (url, options) => {
requests.push({ url, headers: options.headers });
return response(200, metadata('0.2.1'));
};
await needsPublish('0.3.0', fetcher);
await needsPublish('0.4.0', fetcher);
assert.deepEqual(requests[0], requests[1]);
assert.equal(requests[0].url, 'https://registry.npmjs.org/@configbutler%2Fkrm-stream');
});
49 changes: 44 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ name: ci
on:
pull_request:
workflow_call:
inputs:
ref:
description: Immutable release commit to validate; empty uses the event checkout
type: string
default: ""
artifact-name:
description: Separate artifacts when validating both the push and a release
type: string
default: npm-packages
workflow_dispatch:

permissions:
Expand All @@ -18,7 +27,7 @@ permissions:
# number, and github.run_id keeps the group unique — so a release run can never be cancelled by, or
# cancel, anything else.
concurrency:
group: ci-${{ github.event.pull_request.number || github.run_id }}
group: ci-${{ github.event.pull_request.number || github.run_id }}-${{ inputs.artifact-name || 'npm-packages' }}
cancel-in-progress: true

jobs:
Expand All @@ -32,6 +41,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ inputs.ref }}

# Versions come from the devcontainer's ENV block, so CI and a developer's shell cannot lint
# with different rules and disagree about whether the tree is clean.
Expand All @@ -54,6 +64,13 @@ jobs:

- run: hadolint .devcontainer/Dockerfile

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
- name: release safety regression tests
if: hashFiles('.github/scripts/release.test.mjs') != ''
run: node --test .github/scripts/release.test.mjs

# The point of the monorepo: one contract, and a change to it must be seen by BOTH
# implementations in the same commit. Both jobs read conformance/gen/, which is built
# from conformance/{bodies,fixtures}/*.yaml — so a protocol change that breaks either
Expand All @@ -65,6 +82,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ inputs.ref }}
- name: install yq
run: sudo curl -fsSL -o /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.48.1/yq_linux_amd64 && sudo chmod +x /usr/local/bin/yq
- name: regenerate
Expand All @@ -80,6 +98,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ inputs.ref }}
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.27.1"
Expand Down Expand Up @@ -114,6 +133,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ inputs.ref }}
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.27.1"
Expand Down Expand Up @@ -151,6 +171,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ inputs.ref }}
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.27.1"
Expand Down Expand Up @@ -214,7 +235,7 @@ jobs:
fi ;;
esac

sha="${{ github.event.pull_request.head.sha || github.sha }}"
sha="${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}"
tmp="$(mktemp -d)"
cd "$tmp"
go mod init example.com/stranger
Expand Down Expand Up @@ -245,6 +266,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ inputs.ref }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
Expand Down Expand Up @@ -279,10 +301,24 @@ jobs:
- name: build, pack, and prove the tarball is complete
run: task pack-client PACK_DIR=/tmp/npm

- name: bind the tarball to the checked-out commit
run: |
node --input-type=module <<'JS'
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
const files = readdirSync('/tmp/npm').filter(name => name.endsWith('.tgz'));
if (files.length !== 1) throw new Error('expected exactly one tarball');
const sha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
const integrity = 'sha512-' + createHash('sha512')
.update(readFileSync('/tmp/npm/' + files[0])).digest('base64');
writeFileSync('/tmp/npm/source.json', JSON.stringify({ sha, integrity }));
JS

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: npm-packages
path: /tmp/npm/*.tgz
name: ${{ inputs.artifact-name || 'npm-packages' }}
path: /tmp/npm/*
if-no-files-found: error
retention-days: 7

Expand All @@ -293,6 +329,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ inputs.ref }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
Expand All @@ -316,6 +353,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ inputs.ref }}
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.27.1"
Expand All @@ -340,6 +378,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ inputs.ref }}
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.27.1"
Expand All @@ -354,6 +393,6 @@ jobs:
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: playwright-trace
name: playwright-trace-${{ inputs.artifact-name || 'npm-packages' }}
path: examples/vanilla-browser/test-results/
retention-days: 7
Loading