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
34 changes: 34 additions & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion .github/workflows/publish-cli.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand All @@ -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: |
Expand Down Expand Up @@ -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 }}
1 change: 1 addition & 0 deletions .github/workflows/release-package.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: Build signed QM images

on:
workflow_call:
workflow_dispatch:

permissions:
Expand Down
81 changes: 81 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 6 additions & 2 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>` 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.

Expand Down
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
99 changes: 98 additions & 1 deletion test/release-workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down Expand Up @@ -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'/);
});