From 0ed46da65835c91fa729950970b677e8d82fa6be Mon Sep 17 00:00:00 2001 From: Josh France <12610835+16francej@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:59:33 -0700 Subject: [PATCH 1/2] Cut releases from one dispatch that tags what it published MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishing was two manual workflow_dispatch runs an operator had to order by hand — build signed images at a SHA, then publish the CLI with images_ref set to that same SHA — and nothing recorded the result. The repository has no tags and no releases, so the pair a version actually denotes (an npm version and the six GHCR digests baked into cli/manifest.json) existed only inside a published tarball. release.yml is now the single entry point: Actions -> Release -> Run workflow, from main. It refuses a tag it has already published, calls the two existing workflows as reusable workflows in order, then tags v and creates the GitHub release with the resolved digests attached as an asset, so the digest set stays recoverable from the tag. Calling rather than merging the two workflows keeps each dispatchable on its own and, because a reusable workflow's OIDC identity is its own ref, leaves the pinned cosign certificate identity verifying unchanged. Driving this from a tag push instead would have broken that identity, and would have left a dangling tag whenever a build failed. Publishing skips a version npm already has, so a run that died after npm and before the tag resumes instead of failing on a 403. The version is cli/package.json's, which CI now requires a pull request to bump whenever it touches what the package ships — without that the tag names nothing in particular. --- .github/workflows/cicd.yml | 28 ++++++++++ .github/workflows/publish-cli.yml | 23 +++++++- .github/workflows/release-package.yml | 1 + .github/workflows/release.yml | 76 +++++++++++++++++++++++++++ cli/README.md | 8 ++- cli/package.json | 2 +- test/release-workflows.test.ts | 63 +++++++++++++++++++++- 7 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index a5ddbe696..7515b0d37 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -88,6 +88,34 @@ jobs: run: | npm run typecheck:contract node --test "deploy/stacks/*/test/*.test.ts" + + cli-version: + name: CLI version bump + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + fetch-depth: 0 + persist-credentials: false + - name: Require a version bump when the published package changes + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + base=$(git merge-base "$BASE_SHA" "$HEAD_SHA") + changed=$(git diff --name-only "$base" "$HEAD_SHA" -- \ + cli/bin cli/src cli/templates cli/manifest.json cli/package.json cli/README.md) + [ -n "$changed" ] || exit 0 + base_version=$(git show "${base}:cli/package.json" | jq -r .version) + head_version=$(git show "${HEAD_SHA}:cli/package.json" | jq -r .version) + if [ "$base_version" = "$head_version" ]; then + printf '%s\n' "$changed" >&2 + echo "these files ship in @yc-software/qm; bump cli/package.json past $base_version" >&2 + exit 1 + fi + lint: name: Lint runs-on: ubuntu-latest diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index ad5ae3355..e26e141ad 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -1,6 +1,16 @@ name: Publish the QM CLI on: + workflow_call: + inputs: + images_ref: + description: Commit SHA whose published images the package should pin + required: false + type: string + outputs: + manifest: + description: The image manifest the published package pins + value: ${{ jobs.package.outputs.manifest }} workflow_dispatch: inputs: images_ref: @@ -15,6 +25,8 @@ jobs: package: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + outputs: + manifest: ${{ steps.pin.outputs.manifest }} steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: @@ -32,6 +44,7 @@ jobs: npm run typecheck npm run test:pack - name: Pin published image digests + id: pin env: IMAGES_REF: ${{ inputs.images_ref || github.sha }} run: | @@ -61,8 +74,16 @@ jobs: | length == 6 and all(test("^ghcr\\.io/yc-software/qm/[a-z-]+@sha256:[0-9a-f]{64}$")) and all(test("@sha256:(.)\\1{63}$") | not)' cli/manifest.json > /dev/null + printf 'manifest=%s\n' "$(jq -c . cli/manifest.json)" >> "$GITHUB_OUTPUT" - name: Publish working-directory: cli - run: npm publish --provenance --access public + run: | + set -euo pipefail + version=$(jq -r .version package.json) + if npm view "@yc-software/qm@$version" version > /dev/null 2>&1; then + echo "@yc-software/qm@$version is already on npm; keeping the published artifact" + exit 0 + fi + npm publish --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml index 37667b1e0..c5c33ec44 100644 --- a/.github/workflows/release-package.yml +++ b/.github/workflows/release-package.yml @@ -1,6 +1,7 @@ name: Build signed QM images on: + workflow_call: workflow_dispatch: permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..5eaa5b44f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,76 @@ +name: Release + +on: + workflow_dispatch: + +concurrency: + group: release + cancel-in-progress: false + +jobs: + preflight: + name: Preflight + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tag: ${{ steps.version.outputs.tag }} + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + persist-credentials: false + - id: version + name: Resolve the release tag + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="v$(jq -r .version cli/package.json)" + if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" > /dev/null 2>&1; then + echo "$tag is already released; bump cli/package.json before releasing again" >&2 + exit 1 + fi + printf 'tag=%s\n' "$tag" >> "$GITHUB_OUTPUT" + + images: + name: Images + needs: preflight + permissions: + contents: read + packages: write + id-token: write + uses: ./.github/workflows/release-package.yml + + cli: + name: CLI + needs: images + permissions: + contents: read + id-token: write + secrets: inherit + uses: ./.github/workflows/publish-cli.yml + + release: + name: Tag and publish the release + needs: + - preflight + - cli + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Create the GitHub release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.preflight.outputs.tag }} + MANIFEST: ${{ needs.cli.outputs.manifest }} + run: | + set -euo pipefail + printf '%s' "$MANIFEST" | jq . > images.json + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ + --title "$TAG" \ + --generate-notes \ + "images.json#Pinned image digests" diff --git a/cli/README.md b/cli/README.md index 6e87fbefa..c15e935c5 100644 --- a/cli/README.md +++ b/cli/README.md @@ -19,8 +19,12 @@ npm exec qm -- check --live ``` This package is published to npm as `@yc-software/qm`, with npm provenance attesting the -building workflow. First-party image publication is a separate release operation run by -`.github/workflows/release-package.yml`. The checked-in image manifest is a sentinel that +building workflow. A release is one dispatch of `.github/workflows/release.yml` from +`main`: it signs and pushes the first-party images, publishes the package pinning their +digests, and then tags `v` and creates the GitHub release with the resolved +digests attached. The version comes from `cli/package.json`, which CI requires a pull +request to bump whenever it changes what the package ships; a tag that already exists +stops the release rather than moving. The checked-in image manifest is a sentinel that a deployment overrides with real digests. The packed-artifact test exercises the consumer path locally. diff --git a/cli/package.json b/cli/package.json index 11c6a4573..3765f4bec 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@yc-software/qm", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "description": "Control-plane CLI for portable QM deployments on Docker, Fly, and AWS.", "type": "module", diff --git a/test/release-workflows.test.ts b/test/release-workflows.test.ts index b9027e25b..a56ae44c1 100644 --- a/test/release-workflows.test.ts +++ b/test/release-workflows.test.ts @@ -65,7 +65,10 @@ test("the CLI package publishes publicly with provenance", () => { test("publishing the CLI is a separate, attested, main-only operation", () => { const workflow = readFileSync(".github/workflows/publish-cli.yml", "utf8"); - assert.match(workflow, /^on:\n {2}workflow_dispatch:$/m); + assert.match(workflow, /^ {2}workflow_dispatch:$/m); + assert.match(workflow, /^ {2}workflow_call:$/m); + assert.doesNotMatch(workflow, /^ {2}push:$/m); + assert.doesNotMatch(workflow, /^ {2}pull_request:$/m); assert.match(workflow, /if: github\.ref == 'refs\/heads\/main'/); assert.match(workflow, /permissions:\s+contents: read\s+id-token: write/); assert.match(workflow, /registry-url: https:\/\/registry\.npmjs\.org/); @@ -97,3 +100,61 @@ test("the published package pins real image digests, never the checked-in sentin "the checked-in manifest stays a sentinel so a source checkout never pulls a stale digest", ); }); + +test("the release republishes nothing already on npm so a half-finished run can resume", () => { + const workflow = readFileSync(".github/workflows/publish-cli.yml", "utf8"); + + assert.match(workflow, /if npm view "@yc-software\/qm@\$version" version/); + assert.ok( + workflow.indexOf("npm view") < workflow.indexOf("npm publish --provenance"), + "the already-published check guards the publish rather than following it", + ); + assert.match(workflow, /manifest: \$\{\{ steps\.pin\.outputs\.manifest \}\}/); +}); + +test("one dispatchable workflow drives the whole release, main-only and in order", () => { + const workflow = readFileSync(".github/workflows/release.yml", "utf8"); + + assert.match(workflow, /^on:\n {2}workflow_dispatch:$/m); + assert.match(workflow, /^ {2}preflight:\n {4}name: Preflight\n {4}if: github\.ref == 'refs\/heads\/main'$/m); + assert.match( + workflow, + /^ {2}images:\n[\s\S]*?needs: preflight\n[\s\S]*?uses: \.\/\.github\/workflows\/release-package\.yml$/m, + ); + assert.match(workflow, /^ {2}cli:\n[\s\S]*?needs: images\n[\s\S]*?uses: \.\/\.github\/workflows\/publish-cli\.yml$/m); + assert.match(workflow, /^ {2}release:\n[\s\S]*?needs:\n {6}- preflight\n {6}- cli$/m); + assert.match(workflow, /concurrency:\n {2}group: release\n {2}cancel-in-progress: false/); +}); + +test("the release refuses a tag it already published and writes the tag last", () => { + const workflow = readFileSync(".github/workflows/release.yml", "utf8"); + + assert.match(workflow, /tag="v\$\(jq -r \.version cli\/package\.json\)"/); + assert.match(workflow, /is already released; bump cli\/package\.json before releasing again/); + assert.ok( + workflow.indexOf("already released") < workflow.indexOf("gh release create"), + "the tag gate runs before anything is published", + ); + assert.match(workflow, /gh release create "\$TAG"/); + assert.match(workflow, /--target "\$GITHUB_SHA"/); + assert.match(workflow, /--generate-notes/); + assert.match(workflow, /"images\.json#Pinned image digests"/); +}); + +test("only the tagging job may write to the repository", () => { + const workflow = readFileSync(".github/workflows/release.yml", "utf8"); + + const writes = workflow.match(/^ {6}contents: write$/gm) ?? []; + assert.equal(writes.length, 1); + assert.match(workflow, /^ {2}release:\n[\s\S]*?permissions:\n {6}contents: write\n[\s\S]*?gh release create/m); + assert.doesNotMatch(workflow, /packages: write\n {4}secrets: inherit/); +}); + +test("images are signed from a main ref, so the pinned cosign identity keeps verifying", () => { + const release = readFileSync(".github/workflows/release.yml", "utf8"); + const images = readFileSync(".github/workflows/release-package.yml", "utf8"); + + assert.doesNotMatch(release, /refs\/tags/); + assert.match(release, /if: github\.ref == 'refs\/heads\/main'/); + assert.match(images, /--certificate-identity='[^']*release-package\.yml@refs\/heads\/main'/); +}); From 01a9da8930cf9b62fd4f81253b304995487ceef6 Mon Sep 17 00:00:00 2001 From: Josh France <12610835+16francej@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:13:54 -0700 Subject: [PATCH 2/2] Close the gaps an adversarial pass found in the release path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes from an independent review of the release workflow. The tag is now created through the git refs API before gh release create, which then runs with --verify-tag instead of --target. --target only names a commit when gh creates the tag itself: if another actor raced the same tag in during the several minutes between preflight and tagging, gh would silently adopt it and the release would point away from the commit whose images and package were just published. Creating the ref is atomic and fails on a duplicate. A non-main dispatch failed by skipping every job, and a workflow whose jobs all skip reports success — an operator could read green and believe a release happened. Preflight now runs everywhere and exits non-zero off main. Skipping the publish because npm already has the version assumed that version came from this release. A direct publish-cli dispatch, or a re-dispatch at a different commit, could leave npm serving a tarball pinning digests the new tag claims to name. The skip now packs the published version, compares its pinned manifest to the digests just resolved, and only holds when they match. The version guard missed cli/LICENSE, cli/tsconfig*.json and cli/package-lock.json, all of which change the tarball or the emitted dist, and accepted a downgrade or a non-semver string as a bump. It now covers those paths and requires a strictly greater semver. Left as known limits: publish-cli still re-resolves the mutable SHA tag rather than carrying the exact digest release-package signed, gh release create is not atomic across its own tag/asset/publish calls, and Actions keeps only one pending run per concurrency group. --- .github/workflows/cicd.yml | 10 +++++-- .github/workflows/publish-cli.yml | 9 ++++++- .github/workflows/release.yml | 9 +++++-- test/release-workflows.test.ts | 44 ++++++++++++++++++++++++++++--- 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 7515b0d37..57b3bfa1a 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -106,15 +106,21 @@ jobs: set -euo pipefail base=$(git merge-base "$BASE_SHA" "$HEAD_SHA") changed=$(git diff --name-only "$base" "$HEAD_SHA" -- \ - cli/bin cli/src cli/templates cli/manifest.json cli/package.json cli/README.md) + cli/bin cli/src cli/templates cli/manifest.json cli/package.json \ + cli/package-lock.json cli/README.md cli/LICENSE cli/tsconfig.json cli/tsconfig.build.json) [ -n "$changed" ] || exit 0 base_version=$(git show "${base}:cli/package.json" | jq -r .version) head_version=$(git show "${HEAD_SHA}:cli/package.json" | jq -r .version) - if [ "$base_version" = "$head_version" ]; then + highest=$(printf '%s\n%s\n' "$base_version" "$head_version" | sort -V | tail -1) + if [ "$base_version" = "$head_version" ] || [ "$highest" != "$head_version" ]; then printf '%s\n' "$changed" >&2 echo "these files ship in @yc-software/qm; bump cli/package.json past $base_version" >&2 exit 1 fi + case "$head_version" in + [0-9]*.[0-9]*.[0-9]*) ;; + *) echo "cli/package.json version must be semver, got $head_version" >&2; exit 1 ;; + esac lint: name: Lint diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index e26e141ad..9acf92139 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -81,7 +81,14 @@ jobs: set -euo pipefail version=$(jq -r .version package.json) if npm view "@yc-software/qm@$version" version > /dev/null 2>&1; then - echo "@yc-software/qm@$version is already on npm; keeping the published artifact" + published=$(mktemp -d) + tarball=$(npm pack "@yc-software/qm@$version" --pack-destination "$published" --silent | tail -1) + tar -xzf "$published/$tarball" -C "$published" package/manifest.json + if [ "$(jq -Sc . "$published/package/manifest.json")" != "$(jq -Sc . manifest.json)" ]; then + echo "@yc-software/qm@$version is on npm pinning different image digests; bump the version" >&2 + exit 1 + fi + echo "@yc-software/qm@$version is already on npm pinning these digests; keeping it" exit 0 fi npm publish --provenance --access public diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5eaa5b44f..7bcfe9fa5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,6 @@ concurrency: jobs: preflight: name: Preflight - if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: contents: read @@ -26,6 +25,10 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail + if [ "$GITHUB_REF" != refs/heads/main ]; then + echo "releases are cut from main; this run is on $GITHUB_REF" >&2 + exit 1 + fi tag="v$(jq -r .version cli/package.json)" if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" > /dev/null 2>&1; then echo "$tag is already released; bump cli/package.json before releasing again" >&2 @@ -68,9 +71,11 @@ jobs: run: | set -euo pipefail printf '%s' "$MANIFEST" | jq . > images.json + gh api "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/$TAG" -f sha="$GITHUB_SHA" > /dev/null gh release create "$TAG" \ --repo "$GITHUB_REPOSITORY" \ - --target "$GITHUB_SHA" \ + --verify-tag \ --title "$TAG" \ --generate-notes \ "images.json#Pinned image digests" diff --git a/test/release-workflows.test.ts b/test/release-workflows.test.ts index a56ae44c1..15d568db9 100644 --- a/test/release-workflows.test.ts +++ b/test/release-workflows.test.ts @@ -116,7 +116,12 @@ test("one dispatchable workflow drives the whole release, main-only and in order const workflow = readFileSync(".github/workflows/release.yml", "utf8"); assert.match(workflow, /^on:\n {2}workflow_dispatch:$/m); - assert.match(workflow, /^ {2}preflight:\n {4}name: Preflight\n {4}if: github\.ref == 'refs\/heads\/main'$/m); + assert.match(workflow, /releases are cut from main; this run is on \$GITHUB_REF/); + assert.doesNotMatch( + workflow, + /^ {4}if: github\.ref == 'refs\/heads\/main'$/m, + "a non-main dispatch fails loudly instead of skipping every job and reporting green", + ); assert.match( workflow, /^ {2}images:\n[\s\S]*?needs: preflight\n[\s\S]*?uses: \.\/\.github\/workflows\/release-package\.yml$/m, @@ -136,11 +141,41 @@ test("the release refuses a tag it already published and writes the tag last", ( "the tag gate runs before anything is published", ); assert.match(workflow, /gh release create "\$TAG"/); - assert.match(workflow, /--target "\$GITHUB_SHA"/); assert.match(workflow, /--generate-notes/); assert.match(workflow, /"images\.json#Pinned image digests"/); }); +test("the tag is created atomically at the released commit, never adopted from elsewhere", () => { + const workflow = readFileSync(".github/workflows/release.yml", "utf8"); + + assert.match( + workflow, + /gh api "repos\/\$GITHUB_REPOSITORY\/git\/refs" \\\n\s+-f ref="refs\/tags\/\$TAG" -f sha="\$GITHUB_SHA"/, + ); + assert.match(workflow, /--verify-tag/); + assert.doesNotMatch( + workflow, + /--target/, + "--target only names a commit when gh creates the tag itself, so a tag another actor raced in would silently win", + ); + assert.ok( + workflow.indexOf("git/refs") < workflow.indexOf("gh release create"), + "the ref is created before the release so a duplicate tag fails the run", + ); +}); + +test("a resumed publish keeps npm only when it already pins the digests being released", () => { + const workflow = readFileSync(".github/workflows/publish-cli.yml", "utf8"); + + assert.match(workflow, /npm pack "@yc-software\/qm@\$version"/); + assert.match(workflow, /tar -xzf "\$published\/\$tarball" -C "\$published" package\/manifest\.json/); + assert.match(workflow, /is on npm pinning different image digests; bump the version/); + assert.ok( + workflow.indexOf("npm pack") < workflow.indexOf("keeping it"), + "the published tarball is compared before the publish is skipped", + ); +}); + test("only the tagging job may write to the repository", () => { const workflow = readFileSync(".github/workflows/release.yml", "utf8"); @@ -154,7 +189,8 @@ test("images are signed from a main ref, so the pinned cosign identity keeps ver const release = readFileSync(".github/workflows/release.yml", "utf8"); const images = readFileSync(".github/workflows/release-package.yml", "utf8"); - assert.doesNotMatch(release, /refs\/tags/); - assert.match(release, /if: github\.ref == 'refs\/heads\/main'/); + assert.match(release, /^on:\n {2}workflow_dispatch:$/m); + assert.doesNotMatch(release, /^ {2}push:$/m); + assert.match(release, /if \[ "\$GITHUB_REF" != refs\/heads\/main \]/); assert.match(images, /--certificate-identity='[^']*release-package\.yml@refs\/heads\/main'/); });