diff --git a/.github/workflows/announce-stable.yml b/.github/workflows/announce-stable.yml new file mode 100644 index 0000000..d933ee5 --- /dev/null +++ b/.github/workflows/announce-stable.yml @@ -0,0 +1,40 @@ +name: Announce verified Stable release + +on: + workflow_dispatch: + inputs: + version: + description: "Published Stable version. Confirm its announcement was not already sent." + type: string + required: true + +permissions: + contents: read + +concurrency: + group: hqbase-staging-resources + queue: max + cancel-in-progress: false + +jobs: + announce: + if: github.ref_name == 'main' + runs-on: ubuntu-latest + environment: release + timeout-minutes: 10 + env: + GH_TOKEN: ${{ github.token }} + HQBASE_RELEASE_VERSION: ${{ inputs.version }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Verify published Stable without changing it + run: node scripts/release/verify-stable.mjs + - name: Announce verified Stable release + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} + run: | + gh release view "v${HQBASE_RELEASE_VERSION}" --json body --jq .body > "$RUNNER_TEMP/release-notes.md" + node scripts/release/notify-discord.mjs "$RUNNER_TEMP/release-notes.md" diff --git a/scripts/release/verify-stable.mjs b/scripts/release/verify-stable.mjs new file mode 100644 index 0000000..d4a726a --- /dev/null +++ b/scripts/release/verify-stable.mjs @@ -0,0 +1,53 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { verifiedCandidate } from "./channels.mjs"; +import { loadVerifiedRelease } from "./manifest.mjs"; +import { fetchPublicAsset } from "./public-assets.mjs"; +import { assertStableReleaseVersion } from "./version.mjs"; + +export function assertPublishedStable({ version, latest, manifest, candidate, report, deploy }) { + if (latest.tag_name !== `v${version}` || latest.draft !== false || latest.prerelease !== false) { + throw new Error("Only the published Latest Stable release can be announced."); + } + if ( + manifest.version !== version || + JSON.stringify(manifest) !== JSON.stringify(candidate) || + report.version !== version || + report.artifactSha256 !== manifest.artifact.sha256 || + report.sourceCommit !== manifest.sourceCommit || + deploy !== manifest.sourceCommit + ) { + throw new Error("Published Stable does not match its tested archive and deploy commit."); + } +} + +async function main() { + const version = assertStableReleaseVersion(process.env.HQBASE_RELEASE_VERSION); + if (process.env.GITHUB_REPOSITORY !== "HQBase/hqbase" || process.env.GITHUB_REF_NAME !== "main") { + throw new Error("Stable announcement recovery must run from canonical main."); + } + const api = (path) => + JSON.parse(execFileSync("gh", ["api", `repos/HQBase/hqbase/${path}`], { encoding: "utf8" })); + const { manifest: candidate } = await verifiedCandidate(version); + const { manifest } = await loadVerifiedRelease({ + expectedVersion: version, + fetcher: fetchPublicAsset + }); + const report = JSON.parse(readFileSync(resolve("release/evidence", `${version}.json`), "utf8")); + assertPublishedStable({ + version, + latest: api("releases/latest"), + manifest, + candidate, + report, + deploy: api("git/ref/heads/deploy").object.sha + }); + console.log( + `Verified published Stable ${version} and its tested archive ${manifest.artifact.sha256}.` + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) + await main(); diff --git a/test/unit/scripts/verify-stable.test.mjs b/test/unit/scripts/verify-stable.test.mjs new file mode 100644 index 0000000..56733a2 --- /dev/null +++ b/test/unit/scripts/verify-stable.test.mjs @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { assertPublishedStable } from "../../../scripts/release/verify-stable.mjs"; + +const manifest = { + version: "1.4.0", + sourceCommit: "a".repeat(40), + artifact: { sha256: "b".repeat(64) } +}; +const input = { + version: "1.4.0", + latest: { tag_name: "v1.4.0", draft: false, prerelease: false }, + manifest, + candidate: manifest, + report: { + version: "1.4.0", + sourceCommit: manifest.sourceCommit, + artifactSha256: manifest.artifact.sha256 + }, + deploy: manifest.sourceCommit +}; + +describe("Stable announcement recovery", () => { + it("accepts the exact reviewed and published Stable", () => { + expect(() => assertPublishedStable(input)).not.toThrow(); + }); + it.each([ + { ...input.latest, draft: true }, + { ...input.latest, prerelease: true }, + { ...input.latest, tag_name: "v1.4.1" } + ])("rejects unpublished candidates and a changed Latest", (latest) => { + expect(() => assertPublishedStable({ ...input, latest })).toThrow("Latest Stable"); + }); + it.each([ + { deploy: "c".repeat(40) }, + { candidate: { ...manifest, sourceCommit: "c".repeat(40) } }, + { report: { ...input.report, artifactSha256: "c".repeat(64) } }, + { report: { ...input.report, sourceCommit: "c".repeat(40) } }, + { report: { ...input.report, version: "1.4.1" } } + ])("rejects archive, evidence, and deploy mismatches", (change) => { + expect(() => assertPublishedStable({ ...input, ...change })).toThrow("tested archive"); + }); +});