diff --git a/.github/scripts/release.mjs b/.github/scripts/release.mjs index e502030..a3f43bb 100644 --- a/.github/scripts/release.mjs +++ b/.github/scripts/release.mjs @@ -1,5 +1,4 @@ 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'; @@ -59,9 +58,7 @@ export async function needsPublish(version, fetcher = fetch) { 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'); +export function validatePackage(pkg, version) { assert.equal(pkg.name, packageName); assert.equal(pkg.version, version, 'tarball version disagrees with release'); } @@ -89,16 +86,12 @@ async function main() { 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); + validatePackage(pkg, version); // A retry after npm accepted the upload is successful without another publish attempt. output('needed', await needsPublish(version)); output('tarball', tarball); diff --git a/.github/scripts/release.test.mjs b/.github/scripts/release.test.mjs index 2c8a0bc..0571aca 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, registryMetadata, releaseTags, validateArtifact, validateRelease } from './release.mjs'; +import { needsPublish, packageName, registryMetadata, releaseTags, validatePackage, 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' }; @@ -35,12 +35,10 @@ 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('an older tarball or the wrong package cannot be published as the release', () => { + validatePackage(pkg, '0.3.0'); + assert.throws(() => validatePackage({ ...pkg, version: '0.2.1' }, '0.3.0')); + assert.throws(() => validatePackage({ ...pkg, name: 'krm-stream' }, '0.3.0')); }); test('registry requests never include manifest or workflow-input data', async () => { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef859d1..6799cff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,8 @@ # The validation pipeline: everything needed to TRUST a commit, and nothing that publishes. # # It runs directly on pull requests, and release.yml calls it as a reusable workflow on every push to -# main. That is the whole trust model in one rule: the release tail runs only after the exact same -# jobs a PR gets, in the same run — so a write token never meets code this pipeline has not already -# validated. (GitHub enforces the fork half of it too: a fork PR always gets a read-only token.) +# main. Release runs select the commit before calling this workflow, and publish only after all jobs +# pass. Validation always has read-only permissions, including on fork PRs. name: ci on: @@ -14,10 +13,10 @@ on: 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 + release-version: + description: Verify the public Go release tag instead of a commit SHA type: string - default: npm-packages + default: "" workflow_dispatch: permissions: @@ -27,7 +26,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 }}-${{ inputs.artifact-name || 'npm-packages' }} + group: ci-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: true jobs: @@ -161,9 +160,8 @@ jobs: # It also guards the other half of the promise: the CORE module must stay dependency-free. `go get # .../gateway` pulling in client-go would make the protocol cost what the adapter costs. # - # There is a sibling of this job in release.yml (`consumable-release`) that runs the same check - # against the PUBLISHED TAG rather than a commit sha. That one is the check that actually matters - # to an adopter; this one is the one that catches the mistake before it is tagged. + # Release runs use the public version tag here; ordinary CI uses the commit SHA. + # This is one consumer check, shared by both paths. consumable: name: a stranger can `go get` this runs-on: ubuntu-latest @@ -214,6 +212,7 @@ jobs: # a cache miss here would look like a pass. GOPROXY: direct GOFLAGS: -mod=mod + RELEASE_VERSION: ${{ inputs.release-version }} run: | set -euo pipefail @@ -221,8 +220,7 @@ jobs: # one that has already been released, so the resolution below is exactly what an adopter # gets. On a RELEASE commit it is the version being released — whose tag does not exist # until the release PR merges — and `go get` would fail here for a reason that is not a - # bug. So: if the required core version has no tag yet, skip. release.yml re-runs this same - # check against the real tag once it exists, which is the stronger check anyway. + # bug. Skip that case in ordinary CI; release CI requires the tags to exist. core="$(sed -n 's|^[[:space:]]*github.com/ConfigButler/krm-stream/gateway v\([^ ]*\).*|\1|p' gateway/kube/go.mod | head -1)" case "$core" in 0.0.0-*) ;; # a pseudo-version names a commit, and always resolves @@ -230,12 +228,19 @@ jobs: echo "::error::gateway/kube/go.mod does not require the core module at all" && exit 1 ;; *) if ! git ls-remote --exit-code --tags origin "refs/tags/gateway/v${core}" >/dev/null 2>&1; then - echo "::notice::gateway/kube requires core v${core}, which is not tagged yet — this is a release commit. The published tag is verified by release.yml (consumable-release)." + if [ -n "$RELEASE_VERSION" ]; then + echo "::error::released adapter requires missing core tag gateway/v${core}" + exit 1 + fi + echo "::notice::core v${core} is not tagged yet; release CI verifies the public tag after tagging." exit 0 fi ;; esac - sha="${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}" + revision="${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}" + if [ -n "$RELEASE_VERSION" ]; then + revision="v${RELEASE_VERSION}" + fi tmp="$(mktemp -d)" cd "$tmp" go mod init example.com/stranger @@ -254,9 +259,18 @@ jobs: func main() {} GO - go get "github.com/ConfigButler/krm-stream/gateway/kube@${sha}" + go get "github.com/ConfigButler/krm-stream/gateway/kube@${revision}" go build ./... - echo "a stranger can go get and build the Kubernetes adapter." + echo "a stranger can go get and build the Kubernetes adapter at ${revision}." + + - name: warm the released module versions in the Go proxy + if: inputs.release-version != '' + env: + GOPROXY: https://proxy.golang.org + VERSION: ${{ inputs.release-version }} + run: | + go list -m "github.com/ConfigButler/krm-stream/gateway@v${VERSION}" || true + go list -m "github.com/ConfigButler/krm-stream/gateway/kube@v${VERSION}" || true client: name: client (typescript) @@ -301,26 +315,13 @@ 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: ${{ inputs.artifact-name || 'npm-packages' }} - path: /tmp/npm/* + name: npm-packages + path: /tmp/npm/*.tgz if-no-files-found: error - retention-days: 7 + # Match GitHub's 30-day window for retrying failed jobs without rebuilding. + retention-days: 30 vue: name: Vue adapter example @@ -393,6 +394,6 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() with: - name: playwright-trace-${{ inputs.artifact-name || 'npm-packages' }} + name: playwright-trace path: examples/vanilla-browser/test-results/ retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0db8e7..b00fb29 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,19 +1,6 @@ -# The trusted pipeline for pushes to main: run the FULL validation workflow (ci.yml — the exact same -# jobs a PR gets), and only once every one of them is green let release-please version and tag, and -# then publish. Nothing here runs for pull requests, so a write token never meets unreviewed code. -# -# What "release" means is different for the two halves of this repo, and both are handled below: -# -# npm the client is a package, and it is PUSHED to registry.npmjs.org. -# Go the gateway and its Kubernetes adapter are not pushed anywhere. A Go module is -# published by the mere existence of a git tag of the form `/vX.Y.Z`, which -# the GitHub release created here IS. So `consumable-release` is not a formality: the tag -# is the artifact, and that job is the only thing that ever proves the artifact works. -# -# Versioning is conventional-commits, in lockstep: one number for the npm client, the gateway and the -# adapter, because this repo's whole claim is that they are one contract and move in one commit. A -# client that speaks protocol N and a gateway that speaks protocol N have no business carrying version -# numbers that need a compatibility table to compare. +# Prepare tags first, then validate the selected immutable commit exactly once. +# Publish the artifact from that CI run. If publishing fails, re-run failed jobs; +# the successful CI job and its artifact are reused. name: release on: @@ -35,23 +22,18 @@ concurrency: cancel-in-progress: false jobs: - ci: - name: ci + prepare: + name: prepare the release if: github.ref == 'refs/heads/main' - uses: ./.github/workflows/ci.yml - permissions: - contents: read - - release-please: - name: release-please runs-on: ubuntu-latest timeout-minutes: 10 - needs: ci permissions: - contents: write # create the tags and releases - pull-requests: write # open/refresh the release PR + contents: write + pull-requests: write outputs: - version: ${{ steps.release.outputs['packages/krm-stream--version'] }} + needed: ${{ steps.target.outputs.needed }} + sha: ${{ steps.target.outputs.sha }} + version: ${{ steps.target.outputs.version }} steps: - id: release uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 @@ -60,21 +42,8 @@ jobs: config-file: release-please-config.json manifest-file: .release-please-manifest.json - # 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: + # Release Please reads live main, which may have advanced since this run began. + # Resolve tags before CI so the selected release, rather than the older push, is tested. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -85,17 +54,17 @@ jobs: - id: target env: GH_TOKEN: ${{ github.token }} - VERSION: ${{ inputs.version || needs.release-please.outputs.version }} + VERSION: ${{ inputs.version || steps.release.outputs['packages/krm-stream--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' + ci: + name: ci + needs: prepare uses: ./.github/workflows/ci.yml with: - ref: ${{ needs.release-target.outputs.sha }} - artifact-name: npm-release + # With no pending publication, this remains ordinary CI on the triggering push. + ref: ${{ needs.prepare.outputs.needed == 'true' && needs.prepare.outputs.sha || github.sha }} + release-version: ${{ needs.prepare.outputs.needed == 'true' && needs.prepare.outputs.version || '' }} permissions: contents: read @@ -105,8 +74,8 @@ jobs: name: publish to npm runs-on: ubuntu-latest timeout-minutes: 10 - needs: [release-target, release-ci, consumable-release] - if: needs.release-target.outputs.needed == 'true' + needs: [prepare, ci] + if: needs.prepare.outputs.needed == 'true' permissions: contents: read id-token: write # the OIDC token npm exchanges for a publish credential @@ -116,7 +85,7 @@ jobs: persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: npm-release + name: npm-packages path: /tmp/npm - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -126,14 +95,13 @@ jobs: # there is no token to put in it. registry-url: https://registry.npmjs.org - # Trusted publishing needs npm >= 11.5.1; Node 22 ships an older npm. + # Use an npm version with trusted publishing support. - run: npm install -g npm@latest - - name: verify release commit, tarball contents, and registry state + - name: verify tarball version and registry state id: artifact env: - VERSION: ${{ needs.release-target.outputs.version }} - RELEASE_SHA: ${{ needs.release-target.outputs.sha }} + VERSION: ${{ needs.prepare.outputs.version }} run: node .github/scripts/release.mjs verify - name: publish the official package @@ -141,68 +109,3 @@ jobs: 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 - # adopter actually types — `go get ...@v0.3.1`, not `@` — and it is the only run of it that can - # exist, because the tag does not exist until the job above creates it. - # - # It is also the check that would have caught the `replace` bug at the only moment that mattered. - consumable-release: - name: a stranger can `go get` the release - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [release-target, release-ci] - if: needs.release-target.outputs.needed == 'true' - permissions: - contents: read - steps: - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: "1.27.1" - cache: false # it must resolve from the network. That is the entire point. - - # No checkout anywhere in this job, on purpose. - - name: go get the released ADAPTER from a clean module - env: - # direct: the tag was created seconds ago and proxy.golang.org may not have indexed it yet, - # so a proxy miss here would look like a failure that is not one. - GOPROXY: direct - GOFLAGS: -mod=mod - KUBE_TAG: ${{ needs.release-target.outputs.kube_tag }} - run: | - set -euo pipefail - tmp="$(mktemp -d)" - cd "$tmp" - go mod init example.com/stranger - cat > main.go <<'GO' - package main - - import ( - "k8s.io/client-go/dynamic" - - "github.com/ConfigButler/krm-stream/gateway" - "github.com/ConfigButler/krm-stream/gateway/kube" - ) - - // The one line an adopter writes: the adapter must satisfy the core's seam. - var _ gateway.Backend = kube.NewBackend(dynamic.Interface(nil)) - - func main() {} - GO - go get "github.com/ConfigButler/krm-stream/gateway/kube@${KUBE_TAG#gateway/kube/}" - go build ./... - echo "a stranger can go get and build ${KUBE_TAG}." - - # Ask the module proxy for the new versions, which is what makes them appear on pkg.go.dev. It - # would get there eventually on its own, via the first person to ask — but "eventually, if a - # stranger happens to try" is a poor way to publish documentation. - - name: warm proxy.golang.org and pkg.go.dev - env: - GOPROXY: https://proxy.golang.org - 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 - go list -m "github.com/ConfigButler/krm-stream/gateway/kube@${KUBE_TAG#gateway/kube/}" || true diff --git a/docs/releasing.md b/docs/releasing.md index be16b9d..0ba7787 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -27,14 +27,19 @@ part of CI or future releases; new consumers should install `@configbutler/krm-s ## Publishing setup -`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. +`release.yml` follows one path: -Go modules are published by their version tags. The release workflow verifies the new tags from a -clean module consumer after publication. +1. Release Please creates tags and selects the release commit. +2. CI validates and packs that commit once, including a clean consumer of the public Go tag. +3. npm publishes the tarball produced by that CI run. + +If there is no pending npm release, CI checks the triggering push and publishing is skipped. +Tags are created before release CI; npm publication waits for every CI job to pass. A failed CI +run can therefore leave GitHub/Go tags present without an npm package. Fix source problems in a +new release rather than moving public tags. + +Configure npm trusted publishing for `ConfigButler/krm-stream`, workflow `release.yml`. No +long-lived `NPM_TOKEN` is required. `krm-stream` is frozen and needs no publisher configuration. ## Before merging a release PR @@ -48,24 +53,32 @@ task build-client Confirm that the release PR has passed CI and that the npm trusted-publisher configuration exists for `@configbutler/krm-stream`. -## Recovery and closely spaced merges +## Retry a failed release -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. +Use **Re-run failed jobs** on the original Actions run, or: -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. +```bash +gh run rerun RUN_ID --failed +``` -To recover a specific existing release, run the **release** workflow on **main**, optionally setting -`version` (for example, `0.3.0`): +If npm publication fails, this retries publishing with the **same tested tarball**. Successful +preparation and CI jobs are reused: no new commit, tag, build, or release version is needed. If +npm accepted the upload before the job failed, the retry detects the existing version and succeeds +without publishing again. A failed CI job must pass before publishing can start. + +Tarballs are retained 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). +If the artifact was deleted or expired, an explicit recovery run rebuilds and validates once: ```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. +Recovery fails on registry errors and refuses to move npm's `latest` backwards. Do not delete or +move release tags, rename an older tarball, or remove the version checks to recover a failure. + +## Closely spaced merges + +Release Please reads live `main`, so an older push can create a newer release commit's tags. +Preparation resolves all component tags to one immutable commit **before** CI starts. CI checks +out that commit and publishes its artifact, so the triggering push's older package cannot be +mistaken for the release. Publication does not depend on a tag being newly created in that run.