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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 5 additions & 7 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,17 +110,15 @@ 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"

# 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"
Expand Down
13 changes: 11 additions & 2 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ because all three of its declarative mechanisms provably break here:
So `scripts/set-version.sh <version>` owns the write side, and
`scripts/check-release-version.sh v<version>` 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:
Expand Down Expand Up @@ -105,7 +108,13 @@ 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. 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

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/next/deployment/production.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion apps/docs/next/development/contributing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 33 additions & 7 deletions apps/docs/scripts/release-version.mjs
Original file line number Diff line number Diff line change
@@ -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 -- <major.minor.patch>");
throw new Error("usage: npm run release:docs -- <major.minor.patch> [--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];
Expand All @@ -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,
Expand Down Expand Up @@ -55,3 +64,20 @@ 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 -- <major.minor.patch> [--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 });
}
125 changes: 125 additions & 0 deletions apps/docs/scripts/release-version.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { copyFile, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
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");
await writeFile(resolve(docs, "docs.json"), `${JSON.stringify({
navigation: { versions: [{ version: "Next", pages: ["next/index"] }] },
}, null, 2)}\n`);
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"),
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/,
);
});

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`);
});
13 changes: 12 additions & 1 deletion scripts/check-release-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,27 @@ compose="$(sed -n 's/^.*BLUE_DEPLOYMENT_VERSION:-\([^}]*\)}.*$/\1/p' "$root/depl
# Copied verbatim into blue-deployment-v<tag>.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
echo "$name version is $actual, expected $version" >&2
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" || {
Expand Down
71 changes: 67 additions & 4 deletions scripts/set-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:-}"
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading