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
40 changes: 40 additions & 0 deletions .github/workflows/announce-stable.yml
Original file line number Diff line number Diff line change
@@ -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"
53 changes: 53 additions & 0 deletions scripts/release/verify-stable.mjs
Original file line number Diff line number Diff line change
@@ -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();
42 changes: 42 additions & 0 deletions test/unit/scripts/verify-stable.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading