From 7f82f532257b631dd44331a9a0c479f84e6ade49 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 11 Sep 2026 10:06:20 +0000 Subject: [PATCH 1/2] fix(ci): publish validated release commits and recover interrupted uploads --- .github/scripts/release.mjs | 102 +++++++++++++++++++++++++++++ .github/scripts/release.test.mjs | 45 +++++++++++++ .github/workflows/ci.yml | 49 ++++++++++++-- .github/workflows/release.yml | 106 ++++++++++++++++++++----------- docs/releasing.md | 29 ++++++++- 5 files changed, 287 insertions(+), 44 deletions(-) create mode 100644 .github/scripts/release.mjs create mode 100644 .github/scripts/release.test.mjs diff --git a/.github/scripts/release.mjs b/.github/scripts/release.mjs new file mode 100644 index 0000000..e2c7565 --- /dev/null +++ b/.github/scripts/release.mjs @@ -0,0 +1,102 @@ +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]; +} + +export async function registryVersion(version, fetcher = fetch) { + const response = await fetcher(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${version}`, { + signal: AbortSignal.timeout(30_000), + }); + if (response.status === 404) return null; + assert.ok(response.ok, `npm registry request failed: HTTP ${response.status}`); + const pkg = await response.json(); + assert.equal(pkg.name, packageName); + assert.equal(typeof pkg.version, 'string'); + if (version !== 'latest') assert.equal(pkg.version, version); + return pkg; +} + +export async function needsPublish(version, fetcher = fetch) { + releaseTags(version); + if (await registryVersion(version, fetcher)) return false; + const latest = await registryVersion('latest', fetcher); + if (latest) { + releaseTags(latest.version); + const candidate = version.split('.').map(Number); + const current = latest.version.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.version} 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(); diff --git a/.github/scripts/release.test.mjs b/.github/scripts/release.test.mjs new file mode 100644 index 0000000..4374386 --- /dev/null +++ b/.github/scripts/release.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { needsPublish, packageName, registryVersion, 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 }); + +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 url => url.endsWith('/latest') + ? response(200, { ...pkg, version: '0.2.1' }) : response(404); + 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, pkg)), false); +}); + +test('registry failures do not look like an unpublished package', async () => { + await assert.rejects(registryVersion('0.3.0', async () => response(503))); + await assert.rejects(registryVersion('0.3.0', async () => { throw new Error('network down'); })); + await assert.rejects(registryVersion('0.3.0', async () => response(200, { ...pkg, version: '0.2.1' }))); +}); + +test('replaying an old run cannot downgrade npm latest', async () => { + await assert.rejects(needsPublish('0.3.0', async url => url.endsWith('/latest') + ? response(200, { ...pkg, version: '0.4.0' }) : response(404))); +}); + +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')); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0accb32..ef859d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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: @@ -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. @@ -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 @@ -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 @@ -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" @@ -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" @@ -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" @@ -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 @@ -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" @@ -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 @@ -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" @@ -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" @@ -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" @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b9a9d2..b0db8e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,20 +17,27 @@ name: release on: + workflow_dispatch: + inputs: + version: + description: Existing stable release to recover (empty uses the current version) + type: string + default: "" push: branches: [main] permissions: contents: read -# Never cancel a release mid-publish; a follow-up push queues behind it. +# Serialize push and manual recovery runs. Correctness does not depend on every push running. concurrency: - group: release-${{ github.ref }} + group: release-main cancel-in-progress: false jobs: ci: name: ci + if: github.ref == 'refs/heads/main' uses: ./.github/workflows/ci.yml permissions: contents: read @@ -44,10 +51,7 @@ jobs: contents: write # create the tags and releases pull-requests: write # open/refresh the release PR outputs: - released: ${{ steps.release.outputs.releases_created }} version: ${{ steps.release.outputs['packages/krm-stream--version'] }} - kube_tag: ${{ steps.release.outputs['gateway/kube--tag_name'] }} - gateway_tag: ${{ steps.release.outputs['gateway--tag_name'] }} steps: - id: release uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 @@ -56,30 +60,63 @@ jobs: config-file: release-please-config.json manifest-file: .release-please-manifest.json - # Publish the tarball that ci.yml PACKED AND TESTED in this same run — not a second build of the - # same source. `npm pack` ran in the `client` job after the type check, the lint and the browser - # e2e's build; those exact bytes are what land on the registry. - # - # No NPM_TOKEN. This publishes over OIDC ("trusted publishing"): npm trades this workflow's - # short-lived GitHub identity token for a publish credential, which means the repo holds no - # long-lived registry secret that could leak, and npm attaches SLSA provenance to the release - # automatically — the published package can be traced back to this commit and this workflow. - # - # It requires a one-time setup on npmjs.com for `@configbutler/krm-stream`: repo - # ConfigButler/krm-stream, workflow `release.yml`. See docs/releasing.md. + # release-please reads live main, so it may release a commit newer than this push. + # Resolve the tag independently of releases_created: a retry must recover an npm upload + # that failed after GitHub releases were created. Never publish the triggering push's bytes. + release-target: + name: resolve the unpublished release + timeout-minutes: 10 + needs: release-please + runs-on: ubuntu-latest + outputs: + needed: ${{ steps.target.outputs.needed }} + sha: ${{ steps.target.outputs.sha }} + version: ${{ steps.target.outputs.version }} + gateway_tag: ${{ steps.target.outputs.gateway_tag }} + kube_tag: ${{ steps.target.outputs.kube_tag }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + - id: target + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version || needs.release-please.outputs.version }} + run: node .github/scripts/release.mjs resolve + + release-ci: + name: validate the tagged release + needs: release-target + if: needs.release-target.outputs.needed == 'true' + uses: ./.github/workflows/ci.yml + with: + ref: ${{ needs.release-target.outputs.sha }} + artifact-name: npm-release + permissions: + contents: read + + # Publish the exact artifact from full CI on the resolved release commit, using npm OIDC. + # npm must trust this repository's release.yml workflow; no long-lived token is needed. npm: name: publish to npm runs-on: ubuntu-latest timeout-minutes: 10 - needs: [ci, release-please] - if: needs.release-please.outputs.released == 'true' + needs: [release-target, release-ci, consumable-release] + if: needs.release-target.outputs.needed == 'true' permissions: contents: read id-token: write # the OIDC token npm exchanges for a publish credential steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: npm-packages + name: npm-release path: /tmp/npm - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -92,21 +129,18 @@ jobs: # Trusted publishing needs npm >= 11.5.1; Node 22 ships an older npm. - run: npm install -g npm@latest - - name: the tarballs must be the version that was just released + - name: verify release commit, tarball contents, and registry state + id: artifact env: - VERSION: ${{ needs.release-please.outputs.version }} - run: | - set -euo pipefail - scoped="/tmp/npm/configbutler-krm-stream-${VERSION}.tgz" - if [ ! -f "$scoped" ]; then - echo "::error::expected ${scoped}, but ci.yml packed: $(ls /tmp/npm)" - echo "the tarball and the tag disagree about what this release is — refusing to publish." - exit 1 - fi - echo "scoped_tarball=${scoped}" >> "$GITHUB_ENV" + VERSION: ${{ needs.release-target.outputs.version }} + RELEASE_SHA: ${{ needs.release-target.outputs.sha }} + run: node .github/scripts/release.mjs verify - name: publish the official package - run: npm publish "$scoped_tarball" --provenance --access public + if: steps.artifact.outputs.needed == 'true' + env: + TARBALL: ${{ steps.artifact.outputs.tarball }} + run: npm publish "$TARBALL" --provenance --access public # A Go module is published by its TAG, and until something outside this repository resolves that # tag, "published" is a hope. This is the same check as ci.yml's `consumable`, aimed at the thing an @@ -118,8 +152,8 @@ jobs: name: a stranger can `go get` the release runs-on: ubuntu-latest timeout-minutes: 10 - needs: release-please - if: needs.release-please.outputs.released == 'true' + needs: [release-target, release-ci] + if: needs.release-target.outputs.needed == 'true' permissions: contents: read steps: @@ -135,7 +169,7 @@ jobs: # so a proxy miss here would look like a failure that is not one. GOPROXY: direct GOFLAGS: -mod=mod - KUBE_TAG: ${{ needs.release-please.outputs.kube_tag }} + KUBE_TAG: ${{ needs.release-target.outputs.kube_tag }} run: | set -euo pipefail tmp="$(mktemp -d)" @@ -166,8 +200,8 @@ jobs: - name: warm proxy.golang.org and pkg.go.dev env: GOPROXY: https://proxy.golang.org - GATEWAY_TAG: ${{ needs.release-please.outputs.gateway_tag }} - KUBE_TAG: ${{ needs.release-please.outputs.kube_tag }} + GATEWAY_TAG: ${{ needs.release-target.outputs.gateway_tag }} + KUBE_TAG: ${{ needs.release-target.outputs.kube_tag }} run: | set -euo pipefail go list -m "github.com/ConfigButler/krm-stream/gateway@${GATEWAY_TAG#gateway/}" || true diff --git a/docs/releasing.md b/docs/releasing.md index d6455d3..be16b9d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -27,8 +27,9 @@ part of CI or future releases; new consumers should install `@configbutler/krm-s ## Publishing setup -`release.yml` runs CI first, then publishes the exact npm tarballs produced by CI using npm trusted -publishing. Before the first automated release, create the official npm package and configure its +`release.yml` validates the push, then lets Release Please create tags. It resolves the release tags +to one commit and runs the full CI suite on that immutable commit before publishing its exact npm +tarball using npm trusted publishing. Before the first automated release, create the official npm package and configure its trusted publisher to allow the `ConfigButler/krm-stream` repository's `release.yml` workflow. No long-lived `NPM_TOKEN` is required after that setup. @@ -45,4 +46,26 @@ task build-client ``` Confirm that the release PR has passed CI and that the npm trusted-publisher configuration exists -for both package names. +for `@configbutler/krm-stream`. + +## Recovery and closely spaced merges + +It is safe to merge a release PR while a preceding push is still running CI. Release Please reads +live `main`, so an older run may create the newer commit's tags. Publishing resolves those tags and +validates their commit separately; it never uses the older push's tarball. All three component tags, +the release manifest, the package version, and the artifact's recorded commit must agree. + +Publication does not depend on Release Please creating a tag in that run. If GitHub releases exist +but npm publication failed, the next run can recover it. An already published npm version is a +successful no-op. Registry errors fail the run instead of being treated as a missing version, and +recovery refuses to move npm's `latest` tag backwards. + +To recover a specific existing release, run the **release** workflow on **main**, optionally setting +`version` (for example, `0.3.0`): + +```bash +gh workflow run release.yml --ref main -f version=0.3.0 +``` + +This reruns validation and the Go consumer check before publishing. Do not delete or move release +tags, rename an older tarball, or remove the version/commit checks to recover a failed publication. From 05e4da3db92f492fe451ddabe56391123dd70e0c Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 11 Sep 2026 10:09:13 +0000 Subject: [PATCH 2/2] fix(ci): keep release version data out of registry requests --- .github/scripts/release.mjs | 32 ++++++++++++++++++++------------ .github/scripts/release.test.mjs | 29 ++++++++++++++++++++--------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/.github/scripts/release.mjs b/.github/scripts/release.mjs index e2c7565..e502030 100644 --- a/.github/scripts/release.mjs +++ b/.github/scripts/release.mjs @@ -23,30 +23,38 @@ export function validateRelease(version, shas, manifest, pkg) { return shas[0]; } -export async function registryVersion(version, fetcher = fetch) { - const response = await fetcher(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${version}`, { +// 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 pkg = await response.json(); - assert.equal(pkg.name, packageName); - assert.equal(typeof pkg.version, 'string'); - if (version !== 'latest') assert.equal(pkg.version, version); - return pkg; + 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); - if (await registryVersion(version, fetcher)) return false; - const latest = await registryVersion('latest', fetcher); + 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.version); + releaseTags(latest); const candidate = version.split('.').map(Number); - const current = latest.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.version} to ${version}`); + `refusing to move npm latest backwards from ${latest} to ${version}`); } return true; } diff --git a/.github/scripts/release.test.mjs b/.github/scripts/release.test.mjs index 4374386..2c8a0bc 100644 --- a/.github/scripts/release.test.mjs +++ b/.github/scripts/release.test.mjs @@ -1,11 +1,12 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { needsPublish, packageName, registryVersion, releaseTags, validateArtifact, validateRelease } from './release.mjs'; +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); @@ -16,24 +17,22 @@ test('a release resolves to its tagged commit independently of the triggering co }); test('a tagged version missing from npm is recovered even without releases_created', async () => { - const fetcher = async url => url.endsWith('/latest') - ? response(200, { ...pkg, version: '0.2.1' }) : response(404); + 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, pkg)), false); + 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(registryVersion('0.3.0', async () => response(503))); - await assert.rejects(registryVersion('0.3.0', async () => { throw new Error('network down'); })); - await assert.rejects(registryVersion('0.3.0', async () => response(200, { ...pkg, version: '0.2.1' }))); + 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 url => url.endsWith('/latest') - ? response(200, { ...pkg, version: '0.4.0' }) : response(404))); + 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', () => { @@ -43,3 +42,15 @@ test('the original race and mismatched build bytes are rejected before publishin 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'); +});