From c97e5c6f7271803e2ed51c88adac319bf0c2fd20 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Thu, 27 Aug 2026 17:07:32 -0700 Subject: [PATCH] chore: add a release automation script --- .github/workflows/release.yaml | 27 +++++++++ AGENTS.md | 12 ++++ CHANGELOG.md | 13 ++++- package.json | 3 +- scripts/release.ts | 104 +++++++++++++++++++++++++++++++++ 5 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 scripts/release.ts diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a11b083..88d824b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 4b9c88b..018e766 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `. 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index dc60e05..8fbc144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index ab196c9..9141ee7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..4a1ff28 --- /dev/null +++ b/scripts/release.ts @@ -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 "); + cut(argument); + break; + + case "notes": + notes(argument); + break; + + default: + die("usage: release.ts [tag]"); +}