From 67724faa058f7fead555b528a0b19801f122708f Mon Sep 17 00:00:00 2001 From: Blocks Task Runner Date: Thu, 17 Sep 2026 04:04:07 +0000 Subject: [PATCH 1/2] fix(docs): automate release version references --- .github/workflows/ci.yml | 1 + .github/workflows/release-please.yml | 8 +-- RELEASING.md | 10 ++- apps/docs/next/deployment/production.mdx | 2 +- apps/docs/next/development/contributing.mdx | 5 +- apps/docs/scripts/release-version.mjs | 34 ++++++++-- apps/docs/scripts/release-version.test.mjs | 63 ++++++++++++++++++ scripts/check-release-version.sh | 13 +++- scripts/set-version.sh | 71 +++++++++++++++++++-- scripts/test-release-version.sh | 59 +++++++++++++++++ 10 files changed, 243 insertions(+), 23 deletions(-) create mode 100644 apps/docs/scripts/release-version.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51c951c..ca6ad86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,6 +161,7 @@ jobs: - run: sh -n scripts/install.sh scripts/test-install.sh scripts/test-release-version.sh scripts/package-deployment.sh scripts/check-release-version.sh scripts/set-version.sh deploy/consumer/provisioner.sh - run: scripts/test-install.sh - run: scripts/test-release-version.sh + - run: node --test apps/docs/scripts/release-version.test.mjs - run: bash -n deploy/blue-entrypoint.sh deploy/blue-healthcheck.sh - run: | docker compose -f deploy/docker-compose.yml config > /tmp/blue-compose-governance.yaml diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 699c771..70e0e00 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -114,13 +114,7 @@ jobs: scripts/set-version.sh "$version" - # release-version.mjs throws if docs.json already lists the version, so - # skip it on a re-run rather than letting an idempotent job fail. - if [ -d "apps/docs/$version" ]; then - echo "apps/docs/$version already exists; leaving the snapshot as-is" - else - node apps/docs/scripts/release-version.mjs "$version" - fi + node apps/docs/scripts/release-version.mjs "$version" --replace-current scripts/check-release-version.sh "v$version" node apps/docs/scripts/check-release-snapshot.mjs "$version" diff --git a/RELEASING.md b/RELEASING.md index 696e3e9..e7f9f76 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -75,7 +75,10 @@ because all three of its declarative mechanisms provably break here: So `scripts/set-version.sh ` owns the write side, and `scripts/check-release-version.sh v` owns the read side. They are exact mirrors — every file one writes is a file the other reads — and CI runs the -check on every PR, so a file that drifts out of the pair fails `packaging`. +check on every PR, so a file that drifts out of the pair fails `packaging`. The +writer also updates the small allowlist of release-bearing examples in the live +`apps/docs/next` tree; other documentation versions, policy examples, and +dependency versions are deliberately outside its ownership. `scripts/set-version.sh` needs `node` and `cargo` on PATH. Run it by hand if you ever need to prepare a release without the bot: @@ -105,7 +108,10 @@ version and self-consistently wrong until finalize runs — then re-runs So an un-finalized release branch holds the merge button shut, and says why. If finalize ever fails, use **Re-run failed jobs** on the Release Please run, or dispatch the workflow again: finalize takes no inputs, discovers the PR and the -version off the branch, and commits only if the tree changed. +version off the branch, refreshes that current not-yet-released documentation +snapshot from the synchronized `next` tree, and commits only if the tree +changed. Replacement is automation-only and refuses to touch any historical +release that is not the first/default stable navigation entry. ## Release candidates diff --git a/apps/docs/next/deployment/production.mdx b/apps/docs/next/deployment/production.mdx index 25a6c92..55b5579 100644 --- a/apps/docs/next/deployment/production.mdx +++ b/apps/docs/next/deployment/production.mdx @@ -69,7 +69,7 @@ export BLUE_HELM_DEPLOY_FOLDER=../../chart/blue tofu output -json helm_values > "$BLUE_HELM_GENERATED_FILE" # Pins the image to an exact build. The chart refuses a moving tag in production. -export BLUE_IMAGE_DIGEST="$(docker buildx imagetools inspect ghcr.io/blocksorg/blue:0.1.0 | awk '/^Digest:/{print $2}')" +export BLUE_IMAGE_DIGEST="$(docker buildx imagetools inspect ghcr.io/blocksorg/blue:${VERSION} | awk '/^Digest:/{print $2}')" echo "$BLUE_IMAGE_DIGEST" # must print sha256:...; if empty, stop here ``` **DNS not in Route 53?** Leave the four domain lines out of `terraform.tfvars`, skip the three `tofu output` lines for the certificate and hostnames, and set them yourself: diff --git a/apps/docs/next/development/contributing.mdx b/apps/docs/next/development/contributing.mdx index bd68345..fcd7f7c 100644 --- a/apps/docs/next/development/contributing.mdx +++ b/apps/docs/next/development/contributing.mdx @@ -63,7 +63,10 @@ See the repository's `CONTRIBUTING.md` for the issue-first workflow, Conventiona ## Publish a documentation version -Update the Cargo workspace and OpenAPI versions, finish the `Next` documentation, then run: +Normal releases are generated by the Release Please finalizer after it updates +the Cargo, OpenAPI, deployment, and allowlisted live-documentation versions. To +create a snapshot manually while preparing a release without the bot, finish +the `Next` documentation, synchronize those versions, then run: ```bash cd apps/docs diff --git a/apps/docs/scripts/release-version.mjs b/apps/docs/scripts/release-version.mjs index 0373061..06f7044 100644 --- a/apps/docs/scripts/release-version.mjs +++ b/apps/docs/scripts/release-version.mjs @@ -1,14 +1,14 @@ -import { cp, copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { cp, copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { checkReleaseSnapshot, releasedNavigation } from "./check-release-snapshot.mjs"; -const version = process.argv[2]; +export async function releaseVersion(version, { root, replaceCurrent = false } = {}) { if (!version || !/^\d+\.\d+\.\d+$/.test(version)) { - throw new Error("usage: npm run release:docs -- "); + throw new Error("usage: npm run release:docs -- [--replace-current]"); } -const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +root ??= resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const docsRoot = resolve(root, "apps/docs"); const cargo = await readFile(resolve(root, "Cargo.toml"), "utf8"); const workspaceVersion = cargo.match(/\[workspace\.package\][\s\S]*?version\s*=\s*"([^"]+)"/)?.[1]; @@ -22,11 +22,20 @@ if (workspaceVersion !== version || contractVersion !== version) { const releaseDir = resolve(docsRoot, version); const configPath = resolve(docsRoot, "docs.json"); const config = JSON.parse(await readFile(configPath, "utf8")); -if (config.navigation.versions.some((entry) => entry.version === version)) { - throw new Error(`documentation version ${version} already exists in docs.json`); -} const next = config.navigation.versions.find((entry) => entry.version === "Next"); if (!next) throw new Error("docs.json has no Next version to snapshot"); +const existing = config.navigation.versions.filter((entry) => entry.version === version); +if (existing.length) { + if (!replaceCurrent) { + throw new Error(`documentation version ${version} already exists in docs.json`); + } + if (existing.length !== 1 || config.navigation.versions[0]?.version !== version) { + throw new Error(`refusing to replace historical documentation version ${version}`); + } + await rm(releaseDir, { recursive: true, force: true }); + await rm(resolve(docsRoot, `openapi/${version}.yaml`), { force: true }); + config.navigation.versions = config.navigation.versions.filter((entry) => entry.version !== version); +} await cp(resolve(docsRoot, "next"), releaseDir, { recursive: true, @@ -55,3 +64,14 @@ config.navigation.versions = [stable, next, ...config.navigation.versions.filter await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`); await checkReleaseSnapshot(version, root); console.log(`Created immutable documentation snapshot ${version}.`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + const replaceCurrent = args.includes("--replace-current"); + const positional = args.filter((arg) => arg !== "--replace-current"); + if (positional.length !== 1 || args.some((arg) => arg.startsWith("--") && arg !== "--replace-current")) { + throw new Error("usage: npm run release:docs -- [--replace-current]"); + } + await releaseVersion(positional[0], { replaceCurrent }); +} diff --git a/apps/docs/scripts/release-version.test.mjs b/apps/docs/scripts/release-version.test.mjs new file mode 100644 index 0000000..1047088 --- /dev/null +++ b/apps/docs/scripts/release-version.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import test from "node:test"; +import { releaseVersion } from "./release-version.mjs"; + +const version = "2.3.4"; + +async function fixture() { + const root = await mkdtemp(resolve(tmpdir(), "blue-release-docs-")); + const docs = resolve(root, "apps/docs"); + await mkdir(resolve(docs, "next"), { recursive: true }); + await mkdir(resolve(root, "deploy/contract"), { recursive: true }); + await writeFile(resolve(root, "Cargo.toml"), `[workspace.package]\nversion = "${version}"\n`); + await writeFile(resolve(root, "deploy/contract/governance.openapi.yaml"), `info:\n version: "${version}"\n`); + await writeFile(resolve(docs, "next/index.mdx"), "See /next/guide.\n"); + await writeFile(resolve(docs, "docs.json"), `${JSON.stringify({ + navigation: { versions: [{ version: "Next", pages: ["next/index"] }] }, + }, null, 2)}\n`); + return root; +} + +async function generatedState(root) { + return { + docs: await readFile(resolve(root, `apps/docs/${version}/index.mdx`), "utf8"), + openapi: await readFile(resolve(root, `apps/docs/openapi/${version}.yaml`), "utf8"), + config: await readFile(resolve(root, "apps/docs/docs.json"), "utf8"), + }; +} + +test("creates, replaces, and idempotently refreshes the current release", async (t) => { + const root = await fixture(); + t.after(() => rm(root, { recursive: true, force: true })); + + await releaseVersion(version, { root }); + assert.equal((await generatedState(root)).docs, `See /${version}/guide.\n`); + await assert.rejects(() => releaseVersion(version, { root }), /already exists/); + + await writeFile(resolve(root, "apps/docs/next/index.mdx"), "Updated /next/guide.\n"); + await releaseVersion(version, { root, replaceCurrent: true }); + const refreshed = await generatedState(root); + assert.equal(refreshed.docs, `Updated /${version}/guide.\n`); + + await releaseVersion(version, { root, replaceCurrent: true }); + assert.deepEqual(await generatedState(root), refreshed); +}); + +test("refuses to replace a non-current historical release", async (t) => { + const root = await fixture(); + t.after(() => rm(root, { recursive: true, force: true })); + await releaseVersion(version, { root }); + + const path = resolve(root, "apps/docs/docs.json"); + const config = JSON.parse(await readFile(path, "utf8")); + config.navigation.versions.unshift({ version: "3.0.0", pages: ["3.0.0/index"] }); + await writeFile(path, `${JSON.stringify(config, null, 2)}\n`); + + await assert.rejects( + () => releaseVersion(version, { root, replaceCurrent: true }), + /refusing to replace historical documentation version/, + ); +}); diff --git a/scripts/check-release-version.sh b/scripts/check-release-version.sh index 27539ee..ecd27e7 100755 --- a/scripts/check-release-version.sh +++ b/scripts/check-release-version.sh @@ -25,9 +25,15 @@ compose="$(sed -n 's/^.*BLUE_DEPLOYMENT_VERSION:-\([^}]*\)}.*$/\1/p' "$root/depl # Copied verbatim into blue-deployment-v.tar.gz by package-deployment.sh, # so a stale literal here ships the wrong version inside the release bundle. consumer="$(sed -n 's/^.*--build-arg BLUE_VERSION=\([^ ]*\).*$/\1/p' "$root/deploy/consumer/.github/workflows/deploy.yml" | head -n 1)" +docs_production="$(sed -n '/^## Before you start$/,/^## /s/^VERSION=\([0-9][0-9.]*\)$/\1/p' "$root/apps/docs/next/deployment/production.mdx")" +docs_compose="$(sed -n 's/^BLUE_DEPLOYMENT_VERSION=\([0-9][0-9.]*\)$/\1/p' "$root/apps/docs/next/development/local-compose.mdx")" +docs_cli="$(sed -n 's/^| `BLUE_VERSION=\([0-9][0-9.]*\)` | Install a specific release instead of the latest\. |$/\1/p' "$root/apps/docs/next/cli/commands.mdx")" +docs_contributing="$(sed -n 's/^npm run release:docs -- \([0-9][0-9.]*\)$/\1/p' "$root/apps/docs/next/development/contributing.mdx")" for pair in "workspace:$workspace" "cli:$cli" "contract:$contract" "chart:$chart" \ - "chart appVersion:$app" "compose default:$compose" "consumer build arg:$consumer"; do + "chart appVersion:$app" "compose default:$compose" "consumer build arg:$consumer" \ + "docs production VERSION:$docs_production" "docs compose version:$docs_compose" \ + "docs CLI installer version:$docs_cli" "docs release command version:$docs_contributing"; do name="${pair%%:*}" actual="${pair#*:}" if [ "$actual" != "$version" ]; then @@ -35,6 +41,11 @@ for pair in "workspace:$workspace" "cli:$cli" "contract:$contract" "chart:$chart exit 1 fi done +image_reference_count="$(grep -Fc 'docker buildx imagetools inspect ghcr.io/blocksorg/blue:${VERSION}' "$root/apps/docs/next/deployment/production.mdx" || true)" +if [ "$image_reference_count" != 1 ]; then + echo "docs production image reference must use \${VERSION} exactly once" >&2 + exit 1 +fi test -d "$root/apps/docs/next" || { echo "missing apps/docs/next" >&2; exit 1; } test -f "$root/apps/docs/openapi/next.yaml" || { echo "missing apps/docs/openapi/next.yaml" >&2; exit 1; } test -f "$root/deploy/consumer/blue/blue.yaml" || { diff --git a/scripts/set-version.sh b/scripts/set-version.sh index 5486d3d..34aea2c 100755 --- a/scripts/set-version.sh +++ b/scripts/set-version.sh @@ -10,6 +10,12 @@ # against the open release PR, but it is an ordinary script: run it by hand when # preparing a release without the bot. # +# Live documentation examples written alongside the shipped release metadata: +# apps/docs/next/deployment/production.mdx +# apps/docs/next/development/local-compose.mdx +# apps/docs/next/cli/commands.mdx +# apps/docs/next/development/contributing.mdx +# # Deliberately NOT written: # scripts/test-install.sh self-contained fixture; its literals must # match each other, not the release @@ -18,8 +24,7 @@ # client to upgrade on every release # tests/e2e-slim/Cargo.toml separate workspace, never shipped # .github/workflows/release.yml cosmetic `workflow_dispatch` default -# apps/docs/next/**/*.mdx prose; an automated rewrite here gets -# frozen immutably into the docs snapshot +# other apps/docs/next/**/*.mdx prose, policy examples, and dependencies set -eu version="${1:-}" @@ -37,8 +42,12 @@ cd "$root" rewrite() { file="$1" shift - "$@" < "$file" > "$file.set-version.tmp" - mv "$file.set-version.tmp" "$file" + if "$@" < "$file" > "$file.set-version.tmp"; then + mv "$file.set-version.tmp" "$file" + else + rm -f "$file.set-version.tmp" + return 1 + fi } # Cargo manifests: scope to the section. `[workspace.dependencies]` below @@ -89,6 +98,60 @@ rewrite deploy/consumer/.github/workflows/deploy.yml awk -v version="$version" ' { sub(/--build-arg BLUE_VERSION=[^ ]*/, "--build-arg BLUE_VERSION=" version); print } ' +# These are the only release-bearing examples in the live documentation. Each +# rewrite is deliberately narrow and requires exactly one match so prose, +# dependency versions, policy floors, and historical snapshots stay untouched. +rewrite apps/docs/next/deployment/production.mdx awk -v version="$version" ' + /^## / { section = $0 } + section == "## Before you start" && /^VERSION=[0-9]+\.[0-9]+\.[0-9]+$/ { + print "VERSION=" version + found++ + next + } + { print } + END { if (found != 1) exit 1 } +' + +rewrite apps/docs/next/deployment/production.mdx awk ' + /^export BLUE_IMAGE_DIGEST="\$\(docker buildx imagetools inspect ghcr\.io\/blocksorg\/blue:/ { + if ($0 !~ / \| awk/) next + sub(/ghcr\.io\/blocksorg\/blue:[^ ]+/, "ghcr.io/blocksorg/blue:${VERSION}") + found++ + } + { print } + END { if (found != 1) exit 1 } +' + +rewrite apps/docs/next/development/local-compose.mdx awk -v version="$version" ' + /^BLUE_DEPLOYMENT_VERSION=[0-9]+\.[0-9]+\.[0-9]+$/ { + print "BLUE_DEPLOYMENT_VERSION=" version + found++ + next + } + { print } + END { if (found != 1) exit 1 } +' + +rewrite apps/docs/next/cli/commands.mdx awk -v version="$version" ' + /^\| `BLUE_VERSION=[0-9]+\.[0-9]+\.[0-9]+` \| Install a specific release instead of the latest\. \|$/ { + print "| `BLUE_VERSION=" version "` | Install a specific release instead of the latest. |" + found++ + next + } + { print } + END { if (found != 1) exit 1 } +' + +rewrite apps/docs/next/development/contributing.mdx awk -v version="$version" ' + /^npm run release:docs -- [0-9]+\.[0-9]+\.[0-9]+$/ { + print "npm run release:docs -- " version + found++ + next + } + { print } + END { if (found != 1) exit 1 } +' + # Not a rewrite — apps/docs/openapi/next.yaml is a byte-for-byte copy of the # canonical contract, asserted by apps/docs/scripts/check-contract.mjs. Copying # it makes that identity structural instead of two rewrites happening to agree. diff --git a/scripts/test-release-version.sh b/scripts/test-release-version.sh index dd51f18..e516a87 100755 --- a/scripts/test-release-version.sh +++ b/scripts/test-release-version.sh @@ -63,4 +63,63 @@ for bad in "v$version-rc.zzz" "v$version-beta" "v$version-rc" ""; do || fail "package-deployment.sh accepted '$bad'" done +# --- allowlisted live documentation ---------------------------------------- + +# Exercise the writer in an isolated minimal repository. Stub the two external +# commands: their behavior is tested elsewhere, while this fixture is concerned +# only with the exact fields set-version.sh owns. +fixture="$tmp/repository" +mkdir -p "$fixture/scripts" "$fixture/crates/gh-cli" \ + "$fixture/deploy/contract" "$fixture/deploy/helm" \ + "$fixture/deploy/consumer/.github/workflows" "$fixture/deploy/consumer/blue" \ + "$fixture/apps/docs/openapi" "$fixture/apps/docs/scripts" \ + "$fixture/apps/docs/next/deployment" "$fixture/apps/docs/next/development" \ + "$fixture/apps/docs/next/cli" "$tmp/bin" +for file in Cargo.toml crates/gh-cli/Cargo.toml deploy/contract/governance.openapi.yaml \ + deploy/helm/Chart.yaml deploy/docker-compose.yml \ + deploy/consumer/.github/workflows/deploy.yml deploy/consumer/blue/blue.yaml \ + deploy/consumer/provisioner.sh apps/docs/openapi/next.yaml \ + apps/docs/next/deployment/production.mdx \ + apps/docs/next/development/local-compose.mdx apps/docs/next/cli/commands.mdx \ + apps/docs/next/development/contributing.mdx; do + cp "$root/$file" "$fixture/$file" +done +cp "$root/scripts/set-version.sh" "$root/scripts/check-release-version.sh" "$fixture/scripts/" +printf '#!/bin/sh\nexit 0\n' > "$tmp/bin/node" +printf '#!/bin/sh\nexit 0\n' > "$tmp/bin/cargo" +chmod +x "$tmp/bin/node" "$tmp/bin/cargo" "$fixture/deploy/consumer/provisioner.sh" + +unrelated='minimum_client_version: 7.6.5' +printf '\n%s\n' "$unrelated" >> "$fixture/apps/docs/next/development/contributing.mdx" +PATH="$tmp/bin:$PATH" "$fixture/scripts/set-version.sh" 9.8.7 >/dev/null +PATH="$tmp/bin:$PATH" "$fixture/scripts/check-release-version.sh" v9.8.7 >/dev/null \ + || fail "release scripts rejected synchronized live documentation fields" +grep -Fx "$unrelated" "$fixture/apps/docs/next/development/contributing.mdx" >/dev/null \ + || fail "set-version.sh changed an unrelated semantic version example" +grep -F 'ghcr.io/blocksorg/blue:${VERSION}' "$fixture/apps/docs/next/deployment/production.mdx" >/dev/null \ + || fail "set-version.sh did not make the production image follow VERSION" + +assert_named_mismatch() { + file="$1" + expression="$2" + expected="$3" + cp "$file" "$file.before-mismatch" + sed "$expression" "$file.before-mismatch" > "$file" + output="$("$fixture/scripts/check-release-version.sh" v9.8.7 2>&1 || true)" + mv "$file.before-mismatch" "$file" + printf '%s\n' "$output" | grep -F "$expected" >/dev/null \ + || fail "release checker did not identify mismatched field: $expected" +} + +assert_named_mismatch "$fixture/apps/docs/next/deployment/production.mdx" \ + 's/^VERSION=9\.8\.7$/VERSION=9.8.6/' 'docs production VERSION version is 9.8.6' +assert_named_mismatch "$fixture/apps/docs/next/development/local-compose.mdx" \ + 's/^BLUE_DEPLOYMENT_VERSION=9\.8\.7$/BLUE_DEPLOYMENT_VERSION=9.8.6/' 'docs compose version version is 9.8.6' +assert_named_mismatch "$fixture/apps/docs/next/cli/commands.mdx" \ + 's/BLUE_VERSION=9\.8\.7/BLUE_VERSION=9.8.6/' 'docs CLI installer version version is 9.8.6' +assert_named_mismatch "$fixture/apps/docs/next/development/contributing.mdx" \ + 's/release:docs -- 9\.8\.7/release:docs -- 9.8.6/' 'docs release command version version is 9.8.6' +assert_named_mismatch "$fixture/apps/docs/next/deployment/production.mdx" \ + 's/blue:${VERSION}/blue:9.8.7/' 'docs production image reference must use ${VERSION} exactly once' + echo "Release version tests passed" From 5cfc9afac440fec56e7b3d4bcef7ace49e421a6d Mon Sep 17 00:00:00 2001 From: Blocks Task Runner Date: Thu, 17 Sep 2026 04:31:27 +0000 Subject: [PATCH 2/2] fix(docs): protect published release snapshots --- .github/workflows/release-please.yml | 4 ++ RELEASING.md | 5 +- apps/docs/scripts/release-version.mjs | 6 ++ apps/docs/scripts/release-version.test.mjs | 66 +++++++++++++++++++++- 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 70e0e00..443aa65 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -110,6 +110,10 @@ jobs: echo "manifest version is not MAJOR.MINOR.PATCH (got '$version')" >&2 exit 1 fi + if [[ "$(gh release view "v$version" --json isDraft --jq .isDraft 2>/dev/null)" == "false" ]]; then + echo "v$version is already published; refusing to replace its documentation snapshot" >&2 + exit 1 + fi echo "RELEASE_VERSION=$version" >> "$GITHUB_ENV" scripts/set-version.sh "$version" diff --git a/RELEASING.md b/RELEASING.md index e7f9f76..369e986 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -111,7 +111,10 @@ dispatch the workflow again: finalize takes no inputs, discovers the PR and the version off the branch, refreshes that current not-yet-released documentation snapshot from the synchronized `next` tree, and commits only if the tree changed. Replacement is automation-only and refuses to touch any historical -release that is not the first/default stable navigation entry. +release that is not the first/default stable navigation entry. The replacement +flag is accepted only when the CLI is running in the Release Please workflow, +and the finalizer checks GitHub before changing the worktree and refuses to +replace a snapshot when that version's GitHub Release is already published. ## Release candidates diff --git a/apps/docs/scripts/release-version.mjs b/apps/docs/scripts/release-version.mjs index 06f7044..f76879d 100644 --- a/apps/docs/scripts/release-version.mjs +++ b/apps/docs/scripts/release-version.mjs @@ -73,5 +73,11 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur if (positional.length !== 1 || args.some((arg) => arg.startsWith("--") && arg !== "--replace-current")) { throw new Error("usage: npm run release:docs -- [--replace-current]"); } + if ( + replaceCurrent + && (process.env.GITHUB_ACTIONS !== "true" || process.env.GITHUB_WORKFLOW !== "Release Please") + ) { + throw new Error("--replace-current is reserved for the Release Please finalizer"); + } await releaseVersion(positional[0], { replaceCurrent }); } diff --git a/apps/docs/scripts/release-version.test.mjs b/apps/docs/scripts/release-version.test.mjs index 1047088..8001ac8 100644 --- a/apps/docs/scripts/release-version.test.mjs +++ b/apps/docs/scripts/release-version.test.mjs @@ -1,17 +1,28 @@ import assert from "node:assert/strict"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { copyFile, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import test from "node:test"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; import { releaseVersion } from "./release-version.mjs"; const version = "2.3.4"; +const execFileAsync = promisify(execFile); +const scripts = dirname(fileURLToPath(import.meta.url)); async function fixture() { const root = await mkdtemp(resolve(tmpdir(), "blue-release-docs-")); const docs = resolve(root, "apps/docs"); await mkdir(resolve(docs, "next"), { recursive: true }); + await mkdir(resolve(docs, "scripts"), { recursive: true }); await mkdir(resolve(root, "deploy/contract"), { recursive: true }); + await copyFile(resolve(scripts, "release-version.mjs"), resolve(docs, "scripts/release-version.mjs")); + await copyFile( + resolve(scripts, "check-release-snapshot.mjs"), + resolve(docs, "scripts/check-release-snapshot.mjs"), + ); await writeFile(resolve(root, "Cargo.toml"), `[workspace.package]\nversion = "${version}"\n`); await writeFile(resolve(root, "deploy/contract/governance.openapi.yaml"), `info:\n version: "${version}"\n`); await writeFile(resolve(docs, "next/index.mdx"), "See /next/guide.\n"); @@ -21,6 +32,28 @@ async function fixture() { return root; } +async function runCli(root, env = {}) { + return execFileAsync( + process.execPath, + [resolve(root, "apps/docs/scripts/release-version.mjs"), version, "--replace-current"], + { + env: { + ...process.env, + GITHUB_ACTIONS: "", + GITHUB_WORKFLOW: "", + ...env, + }, + }, + ); +} + +async function fixtureWithSnapshot() { + const root = await fixture(); + await releaseVersion(version, { root }); + await writeFile(resolve(root, "apps/docs/next/index.mdx"), "Updated /next/guide.\n"); + return root; +} + async function generatedState(root) { return { docs: await readFile(resolve(root, `apps/docs/${version}/index.mdx`), "utf8"), @@ -61,3 +94,32 @@ test("refuses to replace a non-current historical release", async (t) => { /refusing to replace historical documentation version/, ); }); + +test("CLI refuses replacement outside GitHub Actions without mutation", async (t) => { + const root = await fixtureWithSnapshot(); + t.after(() => rm(root, { recursive: true, force: true })); + const before = await generatedState(root); + + await assert.rejects(() => runCli(root), /reserved for the Release Please finalizer/); + assert.deepEqual(await generatedState(root), before); +}); + +test("CLI refuses replacement from another GitHub Actions workflow without mutation", async (t) => { + const root = await fixtureWithSnapshot(); + t.after(() => rm(root, { recursive: true, force: true })); + const before = await generatedState(root); + + await assert.rejects( + () => runCli(root, { GITHUB_ACTIONS: "true", GITHUB_WORKFLOW: "Release" }), + /reserved for the Release Please finalizer/, + ); + assert.deepEqual(await generatedState(root), before); +}); + +test("CLI permits replacement from the Release Please finalizer", async (t) => { + const root = await fixtureWithSnapshot(); + t.after(() => rm(root, { recursive: true, force: true })); + + await runCli(root, { GITHUB_ACTIONS: "true", GITHUB_WORKFLOW: "Release Please" }); + assert.equal((await generatedState(root)).docs, `Updated /${version}/guide.\n`); +});