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
104 changes: 71 additions & 33 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ jobs:
timeout-minutes: 15
runs-on: ubuntu-latest
permissions:
# No release is created here — tag-images creates the draft once the evidence gate has
# passed. Draft releases are invisible to tokens without push access, so the resume and
# parent-version preconditions below still need write.
contents: write
outputs:
released: ${{ steps.cut.outputs.released }}
Expand All @@ -36,6 +39,7 @@ jobs:
tag_name: ${{ steps.cut.outputs.tag_name }}
previous_version: ${{ steps.cut.outputs.previous_version }}
sha: ${{ steps.cut.outputs.sha }}
migrations: ${{ steps.cut.outputs.migrations }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -44,7 +48,13 @@ jobs:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0

- name: Cut release if this commit bumped the version
# Decides whether this commit cuts a release, and creates nothing.
# The draft is created in tag-images, after the evidence gate, so a gate failure leaves no
# release behind: release images are promoted by digest and never rebuilt, so a draft cut at
# a commit the gate rejected can never pass, and it would then block the next version too
# (the parent-version precondition below). Nothing survives a failed attempt, and the same
# version re-cuts from a commit that carries the fix.
- name: Plan the release for this commit
id: cut
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand All @@ -63,7 +73,8 @@ jobs:
echo "released=false" >> "$GITHUB_OUTPUT"
exit 0
fi
RESUME=false
# A draft can only exist here if an earlier attempt cleared the evidence gate and failed
# later; it is resumed rather than recreated.
if existing=$(gh release view "$TAG" --repo "${{ github.repository }}" --json isDraft,targetCommitish 2>/dev/null); then
if [ "$(jq -r .isDraft <<< "$existing")" != true ]; then
echo "Release $TAG is already published."
Expand All @@ -72,7 +83,6 @@ jobs:
fi
[ "$(jq -r .targetCommitish <<< "$existing")" = "$SHA" ] || {
echo "::error::Draft $TAG targets a different commit"; exit 1; }
RESUME=true
fi
echo "Cutting $TAG at $SHA (was $PARENT_VERSION)"

Expand All @@ -82,33 +92,17 @@ jobs:
jq -e '.isDraft == false and .isPrerelease == false' <<< "$previous" >/dev/null || {
echo "::error::$PREV_TAG is not a stable published release"; exit 1; }

NOTES=$(mktemp)
if [ -n "$PREV_TAG" ] && ! git diff --quiet "$PREV_TAG" "$SHA" -- server/application/src/main/resources/db/changelog/; then
{
echo "> [!WARNING]"
echo "> This release contains **schema migrations**. They run automatically on startup — back up your database before upgrading. See the [migration guide](${{ github.server_url }}/${{ github.repository }}/blob/main/MIGRATION.md)."
echo ""
} >> "$NOTES"
fi
# Materialise the changelog first: awk exits at the next section
# heading, which would SIGPIPE (exit 141) the still-writing `git show`
# once CHANGELOG.md outgrew the 64 KiB pipe buffer.
CHANGELOG_SNAPSHOT=$(mktemp)
git show "$SHA:CHANGELOG.md" > "$CHANGELOG_SNAPSHOT"
awk -v ver="## $VERSION" '
$0 == ver { on=1; next }
on && /^## / { exit }
on { print }
' "$CHANGELOG_SNAPSHOT" >> "$NOTES"
echo "----- release notes -----"; cat "$NOTES"; echo "-------------------------"

if [ "$RESUME" = false ]; then
gh release create "$TAG" --draft \
--title "$TAG" \
--notes-file "$NOTES" \
--target "$SHA" \
--repo "${{ github.repository }}"
fi
# The notes are assembled where the draft is created, in tag-images, from the CHANGELOG.md
# it checks out. Only this job has the tags to tell whether the release carries schema
# migrations, so that one bit travels as an output.
status=0
git diff --quiet "$PREV_TAG" "$SHA" -- server/application/src/main/resources/db/changelog/ || status=$?
case "$status" in
0) MIGRATIONS=false ;;
1) MIGRATIONS=true ;;
# Anything else is git failing, not a verdict — never stamp the warning by accident.
*) echo "::error::Could not diff $PREV_TAG..$SHA for schema migrations"; exit 1 ;;
esac

{
echo "released=true"
Expand All @@ -118,13 +112,14 @@ jobs:
echo "tag_name=$TAG"
echo "sha=$SHA"
echo "previous_version=$PARENT_VERSION"
echo "migrations=$MIGRATIONS"
} >> "$GITHUB_OUTPUT"

- name: Summary
if: steps.cut.outputs.released == 'true'
run: |
echo "## Preparing ${{ steps.cut.outputs.tag_name }}" >> "$GITHUB_STEP_SUMMARY"
echo "Evidence verification is in progress." >> "$GITHUB_STEP_SUMMARY"
echo "Evidence verification is in progress; the draft is created once it passes." >> "$GITHUB_STEP_SUMMARY"

tag-images:
needs: release
Expand All @@ -135,7 +130,7 @@ jobs:
packages: write
id-token: write
attestations: write
contents: write # gh release upload (release image lock)
contents: write # gh release create/upload, once the evidence gate has passed
outputs:
agent-pi-digest: ${{ steps.retag.outputs.agent-pi-digest }}
application-server-digest: ${{ steps.retag.outputs.application-server-digest }}
Expand Down Expand Up @@ -368,15 +363,58 @@ jobs:
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"$ASSET"

# First and only write to the release surface: every step above fails without creating one,
# so a rejected release leaves no draft to delete and no tag — a draft materialises none —
# and the version re-cuts unchanged from a commit that carries the fix.
- name: Create the draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ needs.release.outputs.tag_name }}
VERSION: ${{ needs.release.outputs.version }}
SHA: ${{ needs.release.outputs.sha }}
MIGRATIONS: ${{ needs.release.outputs.migrations }}
run: |
set -euo pipefail
# The release job has already proved that any existing release is a draft at this commit.
if gh release view "$TAG_NAME" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Resuming the existing draft $TAG_NAME."
exit 0
fi

NOTES=$(mktemp)
if [ "$MIGRATIONS" = true ]; then
{
echo "> [!WARNING]"
echo "> This release contains **schema migrations**. They run automatically on startup — back up your database before upgrading. See the [migration guide](${{ github.server_url }}/${{ github.repository }}/blob/main/MIGRATION.md)."
echo ""
} >> "$NOTES"
fi
# CHANGELOG.md as checked out at the released commit; awk exits at the next section.
awk -v ver="## $VERSION" '
$0 == ver { on=1; next }
on && /^## / { exit }
on { print }
' CHANGELOG.md >> "$NOTES"
echo "----- release notes -----"; cat "$NOTES"; echo "-------------------------"

gh release create "$TAG_NAME" --draft \
--title "$TAG_NAME" \
--notes-file "$NOTES" \
--target "$SHA" \
--repo "${{ github.repository }}"

- name: Upload release image lock to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ needs.release.outputs.tag_name }}
ASSET: ${{ steps.pin.outputs.asset-path }}
run: |
set -euo pipefail
# --clobber so a re-run over a resumed draft replaces its assets instead of failing on
# every name that already exists.
gh release upload "$TAG_NAME" \
"$ASSET" "${ASSET}.sigstore.json" evidence/* \
--clobber \
--repo "${{ github.repository }}"

upgrade-test:
Expand Down Expand Up @@ -608,7 +646,7 @@ jobs:
TAG_NAME: ${{ needs.release.outputs.tag_name }}
run: |
set -euo pipefail
gh release upload "$TAG_NAME" host-smoke/*.json --repo "${{ github.repository }}"
gh release upload "$TAG_NAME" host-smoke/*.json --clobber --repo "${{ github.repository }}"
gh release edit "$TAG_NAME" --repo "${{ github.repository }}" --draft=false
[ "$(gh release view "$TAG_NAME" --repo "${{ github.repository }}" --json isImmutable --jq .isImmutable)" = true ] || {
echo "::error::Repository immutable releases must be enabled before publishing"; exit 1; }
Expand Down
16 changes: 15 additions & 1 deletion .github/workflows/verify-changesets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,22 @@ jobs:
- name: Test changeset and version-sync policies
run: pnpm run check:changesets

- name: Check release-note presence
# A revert of a release commit restores MIGRATION.md, the consumed changesets and the pending
# migration fragments by construction, so it can never satisfy the rules below — the release
# reverts so far each needed an administrator bypass. The exemption is structural, never
# lexical: scripts/verify-revert.ts ignores the title and requires every commit the pull
# request adds to be the exact inverse, by patch id, of a commit already on the base.
- name: Detect a verified revert
id: revert
if: ${{ !github.event.pull_request.draft && github.event.pull_request.user.type != 'Bot' }}
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: node scripts/verify-revert.ts "$BASE_SHA" HEAD

- name: Check release-note presence
if: >-
${{ !github.event.pull_request.draft && github.event.pull_request.user.type != 'Bot'
&& steps.revert.outputs.verified-revert != 'true' }}
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
Expand Down
36 changes: 29 additions & 7 deletions docs/contributor/release-management.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ sequenceDiagram
M->>VP: changesets action folds pending changesets into the accumulating Version PR
Note over VP: CHANGELOG.md + version bump preview
VP->>M: Maintainer merges when the release is ready
M->>R: Create draft vX.Y.Z release
M->>R: Plan vX.Y.Z and assemble its notes
R->>R: Generate and verify digest-bound evidence
R->>R: Create the draft vX.Y.Z release and attach its evidence
R->>R: Upgrade seeded data from the previous stable release
R->>R: Qualify supported hosts
R->>R: Promote X.Y.Z / X.Y / latest image tags
Expand All @@ -56,10 +57,31 @@ sequenceDiagram
`main`, and a release is only cut if that run succeeds. Versioning also moves any
migration fragments at the existing `### Next release` anchor in `MIGRATION.md`, creates the
versioned section beneath it, and consumes the fragments.
3. **Merging the Version PR cuts the release.** The workflow creates a draft release at the merge
commit. After its evidence, seeded-upgrade, and supported-host gates pass, it promotes the CI-built images to
`X.Y.Z`, `X.Y`, and `latest`, publishes the release, deploys staging, and requests production
approval.
3. **Merging the Version PR cuts the release.** The workflow resolves the release image digests and
runs the evidence gate first, and only then creates the draft release at the merge commit and
attaches the evidence to it. After the seeded-upgrade and supported-host gates pass too, it
promotes the CI-built images to `X.Y.Z`, `X.Y`, and `latest`, publishes the release, deploys
staging, and requests production approval.

### When a release fails

Release images are promoted **by digest and never rebuilt**, so a commit the evidence gate rejects
can never pass on a retry — only a new commit can. The gate therefore runs before anything is
created: a rejected release leaves no draft and no tag behind (a draft materialises no tag), so
there is nothing to clean up and the version number is not consumed.

Recovering is a revert and a re-cut: revert the version commit, which restores the pending
changesets, land the fix, and let the Version PR re-cut the same version from a commit that carries
it. The `verify-changesets` freeze rules — `MIGRATION.md` is not editable in a feature PR, pending
changesets and migration fragments are not deletable — are lifted for a **verified revert**, which
`scripts/verify-revert.ts` decides structurally rather than by title: every commit the pull request
adds must record `This reverts commit <sha>.`, name a commit that is already an ancestor of the
base, and carry that commit's patch exactly reversed. Anything else leaves the rules in force.

A failure *after* the draft exists — the seeded-upgrade or supported-host gate, or publication
itself — is the case the resume path covers: re-running the workflow at the same commit reuses the
existing draft, regenerates the evidence, and replaces its assets. A draft that can never pass is
deleted by hand, and the version re-cuts from the fixed commit as above.

## Supported-host qualification

Expand All @@ -72,9 +94,9 @@ clean boot; the seeded-upgrade gate separately proves the supported migration pa

## Supply-chain evidence

A release remains a draft until every first-party and upstream production image in
No release exists at all until every first-party and upstream production image in
`security/release-images.json` has passed the evidence
gate. The gate resolves each image index and its `linux/amd64` and `linux/arm64` manifests to immutable
gate, and it remains a draft until the gates after it pass too. The gate resolves each image index and its `linux/amd64` and `linux/arm64` manifests to immutable
digests. It generates a lossless Syft inventory plus SPDX and CycloneDX SBOMs for each deployed platform,
scanning the registry manifest directly rather than a local Docker daemon copy, which would re-serialize
the manifest and digest to something the release never published. The gate proves that all three documents
Expand Down
33 changes: 33 additions & 0 deletions scripts/ci-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,39 @@ void describe("CI contract", () => {
assert.doesNotMatch(release, /node scripts\/check-release-(?:sbom|vulnerabilities)\.ts/);
});

void test("creates the draft release only once the evidence gate has passed", async () => {
const source = await readFile(".github/workflows/release.yml", "utf8");
const decide = job(source, "release");
const gate = job(source, "tag-images");
// Release images are promoted by digest and never rebuilt, so a draft cut before the gate
// can never pass at that commit — and it blocks the next version too, because the
// precondition below requires the previous version to be published.
assert.doesNotMatch(decide, /gh release create/);
assert.match(decide, /is not a published release/);
assert.match(decide, /is not a stable published release/);
const created = gate.indexOf('gh release create "$TAG_NAME"');
const gated = gate.lastIndexOf("node scripts/verify-release-evidence.ts");
const uploaded = gate.indexOf('gh release upload "$TAG_NAME"');
assert.ok(gated >= 0, "tag-images must run the evidence verifier");
assert.ok(created > gated, "the draft must be created after the evidence gate");
assert.ok(uploaded > created, "release assets need a draft to upload to");
// A re-run resumes the draft, so uploads must replace assets instead of failing on names.
for (const upload of source.matchAll(/gh release upload[\s\S]*?\n\n/g)) {
assert.match(upload[0], /--clobber/);
}
});

void test("exempts a verified revert from the changeset freeze rules", async () => {
const source = await readFile(".github/workflows/verify-changesets.yml", "utf8");
const detect = source.indexOf("- name: Detect a verified revert");
const guard = source.indexOf("- name: Check release-note presence");
assert.ok(detect >= 0 && guard > detect, "the revert check must precede the freeze guard");
assert.match(source, /run: node scripts\/verify-revert\.ts "\$BASE_SHA" HEAD/);
assert.match(source, /steps\.revert\.outputs\.verified-revert != 'true'/);
// The exemption is structural: a title or branch name is attacker-chosen and never read.
assert.doesNotMatch(source, /pull_request\.title|github\.head_ref/);
});

void test("never invokes a repository-local action before checkout", async () => {
for (const [file, source] of await workflowSources()) {
for (const jobSource of source.split(/^ {2}(?=[A-Za-z][\w-]*:\s*$)/m).slice(1)) {
Expand Down
Loading
Loading