chore: add a release automation script

This commit is contained in:
Aarnav Tale
2026-08-27 17:07:32 -07:00
parent 29afac60f6
commit c97e5c6f72
5 changed files with 157 additions and 2 deletions
+27
View File
@@ -77,3 +77,30 @@ jobs:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
release:
name: GitHub Release
needs: docker
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check out the repo
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Publish the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
node --disable-warning=ExperimentalWarning scripts/release.ts notes "$TAG" > "$RUNNER_TEMP/notes.md"
if [[ "$TAG" == *-* ]]; then
gh release create "$TAG" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md" --prerelease
else
gh release create "$TAG" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md" --latest
fi
+12
View File
@@ -53,3 +53,15 @@ The project has a documentation site available at the `docs/` directory built
with VitePress. The documentation is written in Markdown and can be easily
edited and extended. If making changes to staple features, please take care to
also update the documentation to reflect any changes in functionality or usage.
## Releases
Every user-facing change adds a line to the `# Next` section of `CHANGELOG.md`,
under `## Changes` or `## Fixes`. That section is what nightly users read on the
docs site, so write it as prose for them, not as a commit summary. Prose placed
directly under `# Next`, above the subheadings, becomes the release preamble.
Use it for compatibility notes and upgrade warnings.
To cut a release, run `pnpm release cut <version>`. It renames `# Next` to the
version, bumps `package.json`, commits, and tags. Pushing the tag builds the
images and publishes the GitHub release from that changelog section.
+12 -1
View File
@@ -1,8 +1,19 @@
# Next
## Changes
- **Rebuilt the Browser SSH module to use Tailscale's `tsconnect`**, which should result in fewer bugs and better compatibility with future Tailscale releases.
- Added `integration.agent.tailscale_netns`, an agent-only opt-out from Tailscale's routing-loop socket handling for deployments where its fallback pins the agent's Headscale connection to the wrong interface. Existing behavior remains enabled by default.
- Added a Disable/Enable key expiry action to the machine menu (via [#554](https://github.com/tale/headplane/pull/554)). Headscale does not keep the toggle state apart from the expiry date, so re-enabling expiry marks the node expired as of that moment.
- Headplane now supports Docker API version 1.24+ (Engine 1.12+, including Podman's Docker-compatible socket).
- Usernames are now validated before a user is created or renamed, so Headplane rejects names that Headscale accepts but ACL policy can never match (closes [#502](https://github.com/tale/headplane/issues/502)).
## Fixes
- Fixed untagged Headscale builds being read as version 0.0.0. The per-commit `main-*` and `development` images report a Go pseudo-version from `/version`, which is now treated as an unknown version instead of an ancient one (via [#590](https://github.com/tale/headplane/pull/590)).
- Fixed the Browser SSH WASM module not building under Nix (via [#588](https://github.com/tale/headplane/pull/588)).
- Fixed `server.data_path`, `headscale.config_path`, `headscale.dns_records_path` and `headscale.tls_cert_path` being silently lowercased, which pointed Headplane at a different location for any path containing a capital letter (closes [#612](https://github.com/tale/headplane/issues/612)).
- Fixed the Headplane agent falling back to an interactive Tailscale login. The agent now starts with a pre-auth-key, preserves its existing state across restarts, and auto-approves itself when Headscale requires manual approval (closes [#582](https://github.com/tale/headplane/issues/582)).
- Added `integration.agent.tailscale_netns`, an agent-only opt-out from Tailscale's routing-loop socket handling for deployments where its fallback pins the agent's Headscale connection to the wrong interface. Existing behavior remains enabled by default.
- Fixed creating pre-auth keys with an expiry of 1000 days or more. The number input submitted its locale-formatted value (`365,000`, `365 000`, `365.000`), which either failed with a 500 or silently created a key with a truncated expiry. The raw value is now submitted and the server rejects malformed expiries with a 400 (closes [#596](https://github.com/tale/headplane/issues/596)).
# 0.7.0
+2 -1
View File
@@ -18,7 +18,8 @@
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"lint": "oxlint",
"format": "oxfmt"
"format": "oxfmt",
"release": "node --disable-warning=ExperimentalWarning scripts/release.ts"
},
"dependencies": {
"@base-ui/react": "^1.7.0",
+104
View File
@@ -0,0 +1,104 @@
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
type Section = {
body: string;
start: number;
title: string;
};
const CHANGELOG = new URL("../CHANGELOG.md", import.meta.url);
const PACKAGE = new URL("../package.json", import.meta.url);
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/;
const BETA = "> This is a beta release. Please report any issues you encounter.";
function die(message: string): never {
console.error(`release: ${message}`);
process.exit(1);
}
function git(...args: string[]) {
try {
return execFileSync("git", args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "inherit"],
}).trim();
} catch {
die(`git ${args.join(" ")} failed`);
}
}
function readSections(text: string): Section[] {
const heads = [...text.matchAll(/^# (.+)$/gm)];
if (heads.length === 0) die("CHANGELOG.md has no sections");
return heads.map((head, index) => ({
body: text.slice(head.index + head[0].length, heads[index + 1]?.index),
start: head.index,
title: head[1],
}));
}
function cut(version: string) {
if (!SEMVER.test(version)) die(`\`${version}\` is not a version`);
const tag = `v${version}`;
if (git("status", "--porcelain", "--untracked-files=no")) die("the working tree is dirty");
if (git("tag", "--list", tag)) die(`${tag} already exists`);
const text = readFileSync(CHANGELOG, "utf8");
const [next, ...released] = readSections(text);
if (next.title !== "Next") die("CHANGELOG.md must open with a `# Next` section");
const notes = next.body.trim();
if (!notes) die("`# Next` is empty");
const date = new Date().toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
const beta = version.includes("-") ? `${BETA}\n\n` : "";
const section = `# ${version} (${date})\n\n${beta}${notes}\n\n---\n\n`;
writeFileSync(CHANGELOG, `# Next\n\n${section}${text.slice(released[0].start)}`);
const pkg = readFileSync(PACKAGE, "utf8");
const bumped = pkg.replace(/^(\s*"version": ")[^"]+(",)$/m, `$1${version}$2`);
if (bumped === pkg) die("could not find the version field in package.json");
writeFileSync(PACKAGE, bumped);
git("add", "CHANGELOG.md", "package.json");
git("commit", "-m", `chore: ${tag}`);
git("tag", "-m", tag, tag);
console.log(`${tag} is committed and tagged. To ship it:\n`);
console.log(" git push origin main --follow-tags");
}
function notes(tag?: string) {
const text = readFileSync(CHANGELOG, "utf8");
const section = readSections(text).find((entry) => /^\d/.test(entry.title));
if (!section) die("CHANGELOG.md has no released version");
const version = section.title.split(" ")[0];
if (tag && tag !== `v${version}`) die(`${tag} is not the top section (${version})`);
console.log(section.body.replace(/---\s*$/, "").trim());
}
const [command, argument] = process.argv.slice(2);
switch (command) {
case "cut":
if (!argument) die("usage: release.ts cut <version>");
cut(argument);
break;
case "notes":
notes(argument);
break;
default:
die("usage: release.ts <cut|notes> [tag]");
}