diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index a5ddbe696..57b3bfa1a 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -88,6 +88,40 @@ 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/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) + 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 runs-on: ubuntu-latest diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index ad5ae3355..9acf92139 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,23 @@ 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 + 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 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..7bcfe9fa5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: Release + +on: + workflow_dispatch: + +concurrency: + group: release + cancel-in-progress: false + +jobs: + preflight: + name: Preflight + 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 + 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 + 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 api "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/$TAG" -f sha="$GITHUB_SHA" > /dev/null + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --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..15d568db9 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,97 @@ 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, /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, + ); + 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, /--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"); + + 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.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'/); +});