From 7a9d6c196af503497d6ab4e47dba9db49ec57266 Mon Sep 17 00:00:00 2001
From: "github.sudoku"
Date: Sun, 31 May 2026 20:45:35 +0200
Subject: [PATCH 01/11] docs(spec): /release one-command release automation
design
Approved design for a `/release` command that cuts a release end-to-end:
local skill drafts curated landing notes + publishes the changelog feed
(landing-first), then CI bumps/tags/publishes the feedzero Docker image.
Hybrid execution, unattended note-drafting with a house-style lint gate,
version auto-derived from conventional commits, four-way version lock,
reusable docker-publish workflow. Supersedes the buggy /new-release skill.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../2026-05-31-release-automation-design.md | 179 ++++++++++++++++++
1 file changed, 179 insertions(+)
create mode 100644 docs/superpowers/specs/2026-05-31-release-automation-design.md
diff --git a/docs/superpowers/specs/2026-05-31-release-automation-design.md b/docs/superpowers/specs/2026-05-31-release-automation-design.md
new file mode 100644
index 0000000..185d558
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-31-release-automation-design.md
@@ -0,0 +1,179 @@
+# `/release` — one-command release automation
+
+- **Date:** 2026-05-31
+- **Status:** Approved design, pre-implementation
+- **Author:** maintainer + Claude
+- **Supersedes:** the `/new-release` skill (which never created a git tag and had stale `/kindle/` paths)
+
+## Goal
+
+Type `/release` (optionally `/release X.Y.Z`) and have a complete, correct
+release happen end to end with no further interaction: draft the curated
+landing release notes, publish the landing changelog feed, then bump,
+tag, and publish the feedzero Docker image — in the order that keeps the
+app's "What's new" feed from 404ing.
+
+### Success criteria
+
+1. A single command produces: a new `releases.mjs` entry + regenerated
+ `releases.xml` (landing, deployed first), an updated `package.json` +
+ vendored fixture + git tag `vX.Y.Z` (feedzero), and a published
+ multi-arch image at `ghcr.io/forcingfx/feedzero:{vX.Y.Z,X.Y,latest}`.
+2. The version is **identical** in all four places: landing `releases.mjs`,
+ feedzero `package.json`, the vendored fixture, and the git tag.
+3. A release that would be wrong (tests red, version mismatch, landing not
+ live) **cannot ship** — it aborts before the irreversible step.
+4. No manual version typing in the common case (derived from commits).
+
+## Decisions (resolved during brainstorming)
+
+| Fork | Decision |
+|---|---|
+| Release-notes authoring | **Fully unattended draft** from the git log, rewritten to house style, with a non-negotiable style **lint** gate; notes remain amendable later (entry IDs preserved). |
+| Execution model | **Hybrid**: a local Claude skill owns the creative + landing side; **CI** owns the feedzero bump/tag/publish, triggered only *after* landing is confirmed live. |
+| Version source | **Auto from conventional commits** since the last release (`feat`→minor, `fix`→patch, `feat!`/`BREAKING CHANGE`→major); `/release X.Y.Z` overrides. |
+| Publish trigger | **Reusable workflow**: `docker-publish.yml` gains `workflow_call`; `release.yml` calls it after tagging. (Avoids the "`GITHUB_TOKEN`-pushed tag doesn't trigger workflows" gotcha and needs no extra token.) |
+| Bump commit | `release.yml` commits the version bump to `main` via `GITHUB_TOKEN`. Requires Actions be allowed to push to `main`. Documented fallback: push the tag with a fine-scoped PAT and let the existing tag trigger fire. |
+
+## Architecture
+
+```
+LOCAL (/release skill) CI (feedzero release.yml)
+───────────────────────── ──────────────────────────
+1 preflight: both repos clean + on main +
+ fetched; feedzero `npm test` + `tsc` green
+ (ABORT otherwise)
+2 version = computeVersion(lastVersion, commits)
+ (or the explicit override arg)
+3 entry = draftNotes(commits) → house style
+4 violations = lintNotes(entry); auto-fix the
+ fixable, ABORT on the rest
+5 prepend entry to releases.mjs;
+ `node build-releases.mjs`
+6 commit + push LANDING
+7 poll https://feedzero.app/releases.xml until
+ feedzero:release: is live (timeout → ABORT)
+8 `gh workflow run release.yml -f version=X.Y.Z` ──▶ 9 checkout main
+ (skill done; safe to walk away) 10 set package.json version
+ 11 cp landing releases.xml → tests/fixtures/
+ 12 `npm test` + `tsc` (ABORT, nothing tagged)
+ 13 commit bump to main
+ 14 create + push tag vX.Y.Z
+ 15 uses: docker-publish.yml (workflow_call)
+ → multi-arch build → GHCR
+```
+
+## Components
+
+Each unit is independently testable; the logic lives in pure modules so the
+git/network glue stays thin.
+
+### 1. `scripts/release/` — pure logic (Vitest-covered)
+
+Framework-free ESM, no git/network/fs side effects in the core functions
+(callers pass data in, get data out).
+
+- **`compute-version.mjs`** — `computeVersion(lastVersion: string, commits: Commit[]): string`.
+ Classifies each conventional-commit subject; returns the next semver.
+ Rules: any `feat!`/`BREAKING CHANGE` → major; else any `feat` → minor;
+ else any `fix`/`perf` → patch. If there are *no releasable commits* (only
+ `chore`/`docs`/`test`/`ci`/`build`), it returns `null` and the skill
+ aborts with "nothing to release" — the empty-release guard lives here so
+ it's unit-tested. Pure function of its inputs.
+- **`draft-notes.mjs`** — `draftNotes(commits, { version, date }): ReleaseEntry`.
+ Maps commit types to Keep-a-Changelog sections (`feat`→added,
+ `fix`→fixed, `refactor`/`perf`/`style`/`chore` user-facing→changed,
+ reverts→changed), strips the conventional prefix + scope, rewrites each
+ subject to a verb-led past-tense sentence ending in a period, derives
+ `title`/`subtitle` from the highest-impact change. Returns the entry
+ object shape `releases.mjs` already uses.
+- **`lint-notes.mjs`** — `lintNotes(entry): Violation[]`. Encodes the
+ documented house style: each bullet starts with a capitalized past-tense
+ verb, ends with `.`, contains no emoji, no em-dash, none of a
+ banned-marketing-verb list (e.g. "seamlessly", "effortlessly",
+ "revolutionary"), no exclamation marks. Returns structured violations so
+ the skill can auto-fix the mechanical ones (missing period, em-dash→comma)
+ and abort on the rest.
+
+```ts
+type Commit = { type: string; scope?: string; breaking: boolean; subject: string; hash: string };
+type ReleaseEntry = { version: string; date: string; title: string; subtitle: string;
+ added?: string[]; changed?: string[]; fixed?: string[]; removed?: string[] };
+type Violation = { field: string; index: number; rule: string; fixable: boolean };
+```
+
+### 2. `.claude/skills/release/SKILL.md` — orchestrator
+
+Thin: shells out to git in both repos, calls the pure modules, polls the
+live feed, fires the CI workflow. Replaces `/new-release`. Steps mirror the
+LOCAL column above. Supports `--dry-run` (print computed version + drafted
+entry + planned git operations; touch nothing) and an explicit version arg.
+Hard preflight aborts: dirty tree, behind remote, red tests/tsc, missing
+landing repo, computed version not greater than the last.
+
+### 3. `.github/workflows/release.yml` (feedzero) — feedzero side
+
+`workflow_dispatch` with a required `version` input. Steps 9–15 above.
+Runs `npm test` + `tsc` before tagging; if either fails the job exits
+before the tag is created (nothing half-shipped). After tagging it calls
+the publish workflow.
+
+### 4. `docker-publish.yml` — make reusable
+
+Add `on: workflow_call` (with a `ref`/`version` input) alongside the
+existing `push: tags: v*.*.*` trigger so both manual tags and `release.yml`
+publish through one definition (no duplicated build logic).
+
+## Version lock + guardrails
+
+The four version touchpoints are chained so a mismatch is impossible to
+ship:
+
+1. **`package.json` `==` newest version in vendored `tests/fixtures/release-feed.xml`** — a new in-repo Vitest test (`release-version-sync.test.ts`). This is a purely in-repo check (no live cross-repo call). The fixture is refreshed from landing's `releases.xml` at release time (step 11), so it is the vendored stand-in for "what landing published"; keeping `package.json` equal to it transitively ties feedzero to the landing notes.
+2. **git tag `==` `package.json`** — a guard step at the top of `docker-publish.yml` (and `release.yml`): `test "${TAG#v}" = "$(node -p 'require("./package.json").version')"`.
+
+Transitively: landing notes (via the vendored fixture) == `package.json` == tag.
+
+**Abort gates** (each precedes an irreversible action):
+- Local: red `npm test`/`tsc`, dirty/behind tree, or no version increment → abort before touching landing.
+- Local: landing feed not live within the poll timeout → abort before firing CI (feedzero never proceeds ahead of landing).
+- CI: red `npm test`/`tsc` → abort before `git tag`.
+
+## Failure handling, idempotency, recovery
+
+- **Entry IDs preserved** (`feedzero:release:`, `feedzero:changelog`) so re-runs never cause subscriber re-import storms — enforced by reusing `build-releases.mjs` unchanged.
+- **Partial-failure resume:** if landing pushed but the feedzero CI failed, re-running `/release` detects the existing `releases.mjs` entry for the computed version and **skips to step 7** (poll) → step 8 (fire CI) rather than appending a duplicate entry.
+- **CI is the slow, unattended half:** once the skill fires `release.yml` (step 8) the local machine is free; the multi-arch build finishes in CI.
+
+## One-time setup (documented in the runbook)
+
+- Allow GitHub Actions to push to `main` so `release.yml` can commit the
+ bump + tag (Settings → Actions → Workflow permissions: read/write; or a
+ ruleset bypass for the Actions app). **Fallback** if you'd rather not
+ grant that: store a fine-scoped PAT (`contents: write`) as a secret,
+ push the tag with it, and let the existing tag trigger publish — at the
+ cost of managing a token.
+- GHCR package already public (done). No `DOCKERHUB_*` unless Docker Hub
+ mirroring is wanted.
+
+## Testing strategy
+
+- **Unit (Vitest), the bulk of the risk:** `compute-version` (every bump
+ class + override), `draft-notes` (type→section mapping, prefix/scope
+ stripping, title derivation), `lint-notes` (each rule, fixable vs fatal).
+- **`--dry-run`** is the integration smoke for the skill: run it in the repo,
+ assert it prints a plausible version + a lint-clean entry and performs no
+ git writes.
+- **Workflow:** `actionlint` on `release.yml` + the modified
+ `docker-publish.yml`; the version-sync Vitest test guards the lock chain.
+- No live end-to-end publish in CI (that's the real release); the
+ `workflow_call` refactor is covered by actionlint + a manual dispatch dry
+ run before first use.
+
+## Out of scope (future)
+
+- Auto-drafting the optional LinkedIn/bento social card.
+- Auto-taking the landing screenshot (kept manual; orthogonal to versioning).
+- A GitHub-UI-only trigger (the local skill is the entry point by design).
+- `release-please`-style release PRs — the unattended local draft replaces
+ that need.
From 0dd7c1844d211470ac19542ba4ebd8459e0fcd5b Mon Sep 17 00:00:00 2001
From: "github.sudoku"
Date: Sun, 31 May 2026 21:02:49 +0200
Subject: [PATCH 02/11] docs(plan): /release automation implementation plan
Bottom-up RGR plan (8 tasks): pure modules (compute-version, draft-notes,
lint-notes) with Vitest, lock-chain guard test, reusable docker-publish +
drift guard, release.yml, /release skill (retires /new-release), runbook.
Implements the 2026-05-31 release-automation design spec.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../plans/2026-05-31-release-automation.md | 587 ++++++++++++++++++
1 file changed, 587 insertions(+)
create mode 100644 docs/superpowers/plans/2026-05-31-release-automation.md
diff --git a/docs/superpowers/plans/2026-05-31-release-automation.md b/docs/superpowers/plans/2026-05-31-release-automation.md
new file mode 100644
index 0000000..48461bf
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-31-release-automation.md
@@ -0,0 +1,587 @@
+# /release Automation Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A `/release` command that, unattended, drafts curated landing release notes, publishes the landing changelog feed, then bumps/tags/publishes the feedzero Docker image — version-locked across all four touchpoints.
+
+**Architecture:** Pure ESM logic modules (`scripts/release/*.mjs`) do version math, note drafting, and style linting (no git/network). A local Claude skill (`.claude/skills/release/`) orchestrates: it calls the pure modules, edits + pushes the landing repo, polls the live feed, then fires a feedzero CI workflow (`release.yml`) which bumps `package.json`, refreshes the vendored fixture from the live feed, runs tests, tags, and calls the reusable `docker-publish.yml`.
+
+**Tech Stack:** Node ESM, Vitest, GitHub Actions (`workflow_call`), `gh` CLI, Caddy/Docker (existing).
+
+**Working dir:** worktree `~/builder/feedzero-wt-release-auto`, branch `feat/release-automation`. Landing repo at `../feedzero-landing`. Run `tsc` with `npx -p typescript@6.0.3 tsc --noEmit` (local node_modules may be stale).
+
+---
+
+### Task 1: `compute-version.mjs` — semver from conventional commits
+
+**Files:**
+- Create: `scripts/release/compute-version.mjs`
+- Test: `tests/scripts/release/compute-version.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, it, expect } from "vitest";
+import { parseConventional, computeBump, nextVersion, computeVersion } from "../../../scripts/release/compute-version.mjs";
+
+describe("parseConventional", () => {
+ it("parses type, scope, breaking, description", () => {
+ expect(parseConventional("feat(reader): add X")).toEqual({ type: "feat", scope: "reader", breaking: false, description: "add X" });
+ expect(parseConventional("fix!: drop Y")).toEqual({ type: "fix", scope: null, breaking: true, description: "drop Y" });
+ });
+ it("flags BREAKING CHANGE in the subject", () => {
+ expect(parseConventional("feat: z BREAKING CHANGE: w").breaking).toBe(true);
+ });
+ it("returns type null for non-conventional subjects", () => {
+ expect(parseConventional("just words").type).toBeNull();
+ });
+});
+
+describe("computeBump", () => {
+ it("major on any breaking", () => expect(computeBump(["feat: a", "fix!: b"])).toBe("major"));
+ it("minor on any feat (no breaking)", () => expect(computeBump(["fix: a", "feat: b"])).toBe("minor"));
+ it("patch on fix/perf only", () => expect(computeBump(["fix: a", "perf: b"])).toBe("patch"));
+ it("null when nothing releasable", () => expect(computeBump(["chore: a", "docs: b", "test: c"])).toBeNull());
+});
+
+describe("nextVersion / computeVersion", () => {
+ it("bumps correctly", () => {
+ expect(nextVersion("0.11.0", "major")).toBe("1.0.0");
+ expect(nextVersion("0.11.0", "minor")).toBe("0.12.0");
+ expect(nextVersion("0.11.3", "patch")).toBe("0.11.4");
+ expect(nextVersion("0.11.0", null)).toBeNull();
+ });
+ it("computeVersion composes parse+bump+next", () => {
+ expect(computeVersion("0.11.0", ["feat: a"])).toBe("0.12.0");
+ expect(computeVersion("0.11.0", ["chore: a"])).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run test, verify it fails**
+
+Run: `npx vitest run tests/scripts/release/compute-version.test.ts`
+Expected: FAIL — cannot find module `compute-version.mjs`.
+
+- [ ] **Step 3: Implement**
+
+```js
+// scripts/release/compute-version.mjs
+// Pure semver logic from conventional-commit subject lines. No I/O.
+const RELEASE_BUMP = { feat: "minor", fix: "patch", perf: "patch" };
+
+export function parseConventional(subject) {
+ const m = /^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/s.exec(subject.trim());
+ if (!m) return { type: null, scope: null, breaking: false, description: subject.trim() };
+ const [, type, scope, bang, description] = m;
+ return { type, scope: scope ?? null, breaking: Boolean(bang) || /BREAKING CHANGE/.test(subject), description: description.trim() };
+}
+
+export function computeBump(subjects) {
+ let bump = null;
+ for (const s of subjects) {
+ const c = parseConventional(s);
+ if (c.breaking) return "major";
+ const b = RELEASE_BUMP[c.type];
+ if (b === "minor") bump = "minor";
+ else if (b === "patch" && bump !== "minor") bump = "patch";
+ }
+ return bump;
+}
+
+export function nextVersion(last, bump) {
+ const [maj, min, pat] = last.split(".").map(Number);
+ if (bump === "major") return `${maj + 1}.0.0`;
+ if (bump === "minor") return `${maj}.${min + 1}.0`;
+ if (bump === "patch") return `${maj}.${min}.${pat + 1}`;
+ return null;
+}
+
+export function computeVersion(last, subjects) {
+ return nextVersion(last, computeBump(subjects));
+}
+```
+
+- [ ] **Step 4: Run test, verify pass**
+
+Run: `npx vitest run tests/scripts/release/compute-version.test.ts` → PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/release/compute-version.mjs tests/scripts/release/compute-version.test.ts
+git commit -m "feat(release): conventional-commit version computation"
+```
+
+---
+
+### Task 2: `draft-notes.mjs` — commits → releases.mjs entry
+
+**Files:**
+- Create: `scripts/release/draft-notes.mjs`
+- Test: `tests/scripts/release/draft-notes.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, it, expect } from "vitest";
+import { toHouseStyle, draftNotes } from "../../../scripts/release/draft-notes.mjs";
+
+describe("toHouseStyle", () => {
+ it("capitalizes, ensures terminal period, replaces em-dash, drops exclamation", () => {
+ expect(toHouseStyle("added a thing — really!")).toBe("Added a thing, really.");
+ });
+ it("leaves backticks intact (build-releases turns them into )", () => {
+ expect(toHouseStyle("set `FOO`")).toBe("Set `FOO`.");
+ });
+});
+
+describe("draftNotes", () => {
+ it("buckets feat→added, fix→fixed, perf/refactor→changed; ignores chore/docs", () => {
+ const entry = draftNotes(
+ ["feat: add A", "fix: fix B", "perf: speed C", "chore: bump dep", "docs: tweak"],
+ { version: "0.12.0", date: "2026-06-01T12:00:00Z" },
+ );
+ expect(entry.version).toBe("0.12.0");
+ expect(entry.added).toEqual(["Add A."]);
+ expect(entry.fixed).toEqual(["Fix B."]);
+ expect(entry.changed).toEqual(["Speed C."]);
+ expect(entry.title).toBe("Add A");
+ expect(entry.subtitle).toBe("1 added, 1 changed, 1 fixed.");
+ });
+ it("omits empty sections and falls back to a maintenance title", () => {
+ const entry = draftNotes(["refactor: tidy"], { version: "0.12.0", date: "2026-06-01T12:00:00Z" });
+ expect(entry.added).toBeUndefined();
+ expect(entry.changed).toEqual(["Tidy."]);
+ expect(entry.title).toBe("Tidy");
+ });
+});
+```
+
+- [ ] **Step 2: Run test, verify it fails** — `npx vitest run tests/scripts/release/draft-notes.test.ts` → FAIL (module missing).
+
+- [ ] **Step 3: Implement**
+
+```js
+// scripts/release/draft-notes.mjs
+// Pure: conventional-commit subjects -> a releases.mjs entry object. No I/O.
+import { parseConventional } from "./compute-version.mjs";
+
+const SECTION_BY_TYPE = { feat: "added", fix: "fixed", perf: "changed", refactor: "changed" };
+
+export function toHouseStyle(description) {
+ let s = description.trim().replace(/\s*—\s*/g, ", ").replace(/!+/g, "");
+ s = s.charAt(0).toUpperCase() + s.slice(1);
+ if (!/[.]$/.test(s)) s += ".";
+ return s;
+}
+
+export function draftNotes(subjects, { version, date }) {
+ const buckets = { added: [], changed: [], fixed: [] };
+ for (const subj of subjects) {
+ const section = SECTION_BY_TYPE[parseConventional(subj).type];
+ if (!section) continue;
+ buckets[section].push(toHouseStyle(parseConventional(subj).description));
+ }
+ const lead = buckets.added[0] ?? buckets.changed[0] ?? buckets.fixed[0] ?? "Maintenance release.";
+ const counts = [];
+ for (const k of ["added", "changed", "fixed"]) if (buckets[k].length) counts.push(`${buckets[k].length} ${k}`);
+ const entry = { version, date, title: lead.replace(/\.$/, ""), subtitle: counts.join(", ") + "." };
+ for (const k of ["added", "changed", "fixed"]) if (buckets[k].length) entry[k] = buckets[k];
+ return entry;
+}
+```
+
+- [ ] **Step 4: Run test, verify pass** → PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/release/draft-notes.mjs tests/scripts/release/draft-notes.test.ts
+git commit -m "feat(release): draft release-notes entry from commits"
+```
+
+---
+
+### Task 3: `lint-notes.mjs` — house-style gate
+
+**Files:**
+- Create: `scripts/release/lint-notes.mjs`
+- Test: `tests/scripts/release/lint-notes.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, it, expect } from "vitest";
+import { lintBullet, lintNotes } from "../../../scripts/release/lint-notes.mjs";
+
+describe("lintBullet", () => {
+ it("passes a clean past-tense bullet", () => expect(lintBullet("Added a feature.")).toEqual([]));
+ it("flags missing terminal period (fixable)", () =>
+ expect(lintBullet("Added a feature")).toEqual([{ rule: "ends-with-period", fixable: true }]));
+ it("flags lowercase start (fatal)", () =>
+ expect(lintBullet("added a feature.")).toContainEqual({ rule: "capitalized-start", fixable: false }));
+ it("flags marketing verbs and emoji (fatal)", () => {
+ expect(lintBullet("Seamlessly improved sync.").some((v) => v.rule === "no-marketing-verb")).toBe(true);
+ expect(lintBullet("Added sparkle ✨.").some((v) => v.rule === "no-emoji")).toBe(true);
+ });
+});
+
+describe("lintNotes", () => {
+ it("reports field + index for each violation", () => {
+ const v = lintNotes({ added: ["Added a feature"], fixed: ["fixed a bug."] });
+ expect(v).toContainEqual({ field: "added", index: 0, rule: "ends-with-period", fixable: true });
+ expect(v).toContainEqual({ field: "fixed", index: 0, rule: "capitalized-start", fixable: false });
+ });
+});
+```
+
+- [ ] **Step 2: Run test, verify it fails** — FAIL (module missing).
+
+- [ ] **Step 3: Implement**
+
+```js
+// scripts/release/lint-notes.mjs
+// Pure house-style checks for release-notes bullets. No I/O.
+const BANNED = ["seamlessly", "effortlessly", "revolutionary", "game-chang", "blazing",
+ "delightful", "magical", "cutting-edge", "best-in-class", "powerful"];
+const EMOJI = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}]/u;
+
+export function lintBullet(text) {
+ const v = [];
+ if (!/^[A-Z]/.test(text)) v.push({ rule: "capitalized-start", fixable: false });
+ if (!/[.]$/.test(text)) v.push({ rule: "ends-with-period", fixable: true });
+ if (/—/.test(text)) v.push({ rule: "no-em-dash", fixable: true });
+ if (/!/.test(text)) v.push({ rule: "no-exclamation", fixable: true });
+ if (EMOJI.test(text)) v.push({ rule: "no-emoji", fixable: false });
+ if (BANNED.some((w) => text.toLowerCase().includes(w))) v.push({ rule: "no-marketing-verb", fixable: false });
+ return v;
+}
+
+export function lintNotes(entry) {
+ const out = [];
+ for (const field of ["added", "changed", "fixed", "removed"]) {
+ (entry[field] ?? []).forEach((text, index) => {
+ for (const v of lintBullet(text)) out.push({ field, index, ...v });
+ });
+ }
+ return out;
+}
+```
+
+- [ ] **Step 4: Run test, verify pass** → PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/release/lint-notes.mjs tests/scripts/release/lint-notes.test.ts
+git commit -m "feat(release): house-style lint for release notes"
+```
+
+---
+
+### Task 4: Lock-chain guard test + refresh vendored fixture
+
+**Files:**
+- Modify: `tests/fixtures/release-feed.xml` (refresh to current release)
+- Create: `tests/scripts/release-version-sync.test.ts`
+
+- [ ] **Step 1: Refresh the vendored fixture from the live landing feed**
+
+Run:
+```bash
+curl -fsSL https://feedzero.app/releases.xml -o tests/fixtures/release-feed.xml
+grep -oE 'feedzero:release:[0-9.]+' tests/fixtures/release-feed.xml | head -1
+```
+Expected: `feedzero:release:0.11.0` (matches `package.json`). If it shows an older version, the landing site hasn't published 0.11.0 yet — stop and resolve that first.
+
+- [ ] **Step 2: Write the failing test**
+
+```ts
+// tests/scripts/release-version-sync.test.ts
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+
+const REPO = path.resolve(__dirname, "../..");
+
+describe("release version lock (#212/#211 drift guard)", () => {
+ it("package.json version equals the newest vendored release-feed entry", () => {
+ const pkg = JSON.parse(readFileSync(path.join(REPO, "package.json"), "utf8")).version;
+ const xml = readFileSync(path.join(REPO, "tests/fixtures/release-feed.xml"), "utf8");
+ const newest = /feedzero:release:([0-9][0-9.]*)<\/id>/.exec(xml)?.[1];
+ expect(newest, "no release id in fixture").toBeTruthy();
+ expect(newest).toBe(pkg);
+ });
+});
+```
+
+- [ ] **Step 3: Run test**
+
+Run: `npx vitest run tests/scripts/release-version-sync.test.ts`
+Expected: PASS (after Step 1 refresh). If FAIL, the fixture top ≠ `package.json` — re-run Step 1 or fix `package.json`.
+
+- [ ] **Step 4: Confirm the existing parser contract test still passes**
+
+Run: `npx vitest run tests/core/parser/release-feed-fixture.test.ts` → PASS (same Atom format).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/fixtures/release-feed.xml tests/scripts/release-version-sync.test.ts
+git commit -m "test(release): lock package.json to the vendored release feed"
+```
+
+---
+
+### Task 5: Make `docker-publish.yml` reusable + add tag-drift guard
+
+**Files:**
+- Modify: `.github/workflows/docker-publish.yml`
+
+- [ ] **Step 1: Add `workflow_call` trigger with a `version` input**
+
+Edit the `on:` block to:
+```yaml
+on:
+ push:
+ tags:
+ - 'v*.*.*'
+ workflow_dispatch:
+ workflow_call:
+ inputs:
+ version:
+ description: "Release version without leading v (e.g. 0.12.0). Set when called by release.yml."
+ required: true
+ type: string
+```
+
+- [ ] **Step 2: Add a drift-guard + tag-derivation step** as the FIRST step in the `build-and-push` job's `steps:` (right after `- name: Check out`):
+
+```yaml
+ - name: Resolve + verify release version
+ id: ver
+ run: |
+ if [ "${{ github.event_name }}" = "workflow_call" ]; then
+ VER="${{ inputs.version }}"
+ else
+ VER="${GITHUB_REF_NAME#v}"
+ fi
+ PKG="$(node -p "require('./package.json').version")"
+ if [ "${{ github.event_name }}" != "workflow_dispatch" ] && [ "$VER" != "$PKG" ]; then
+ echo "::error::version mismatch: ref/input=$VER package.json=$PKG"; exit 1
+ fi
+ echo "version=$VER" >> "$GITHUB_OUTPUT"
+ echo "minor=${VER%.*}" >> "$GITHUB_OUTPUT"
+```
+
+- [ ] **Step 3: Replace the `tags:` list in the `Derive image metadata` step** so it works for both tag-push and workflow_call:
+
+```yaml
+ tags: |
+ type=raw,value=v${{ steps.ver.outputs.version }},enable=${{ github.event_name != 'workflow_dispatch' }}
+ type=raw,value=${{ steps.ver.outputs.minor }},enable=${{ github.event_name != 'workflow_dispatch' }}
+ type=raw,value=latest,enable=${{ github.event_name != 'workflow_dispatch' }}
+ type=sha,enable=${{ github.event_name == 'workflow_dispatch' }}
+```
+
+- [ ] **Step 4: Lint the workflow**
+
+Run: `sg docker -c "docker run --rm -v $PWD:/repo --workdir /repo rhysd/actionlint:latest .github/workflows/docker-publish.yml; echo RC=\$?"`
+Expected: `RC=0`, no output lines.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add .github/workflows/docker-publish.yml
+git commit -m "ci(release): make docker-publish reusable + version drift guard"
+```
+
+---
+
+### Task 6: `release.yml` — feedzero bump/tag/publish
+
+**Files:**
+- Create: `.github/workflows/release.yml`
+
+- [ ] **Step 1: Write the workflow**
+
+```yaml
+name: Release feedzero
+# Triggered by the local /release skill via `gh workflow run` AFTER the
+# landing changelog feed is confirmed live. Bumps version, refreshes the
+# vendored fixture from the live feed, verifies the version lock, tests,
+# tags, and publishes the image via the reusable docker-publish workflow.
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Release version without leading v (e.g. 0.12.0)"
+ required: true
+ type: string
+
+permissions:
+ contents: write
+ packages: write
+
+jobs:
+ prepare:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: main
+ fetch-depth: 0
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ - run: npm ci
+ - name: Bump package.json
+ run: npm version "${{ inputs.version }}" --no-git-tag-version
+ - name: Refresh vendored fixture from the live landing feed
+ run: curl -fsSL https://feedzero.app/releases.xml -o tests/fixtures/release-feed.xml
+ - name: Verify version lock (package.json == feed top == input)
+ run: |
+ PKG="$(node -p "require('./package.json').version")"
+ FEED="$(grep -oE 'feedzero:release:[0-9.]+' tests/fixtures/release-feed.xml | head -1 | cut -d: -f3)"
+ test "$PKG" = "${{ inputs.version }}" || { echo "::error::package.json $PKG != input"; exit 1; }
+ test "$FEED" = "${{ inputs.version }}" || { echo "::error::landing feed top $FEED != ${{ inputs.version }} (publish landing first)"; exit 1; }
+ - run: npx tsc --noEmit
+ - run: npm test
+ - name: Commit bump + fixture and tag
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add package.json package-lock.json tests/fixtures/release-feed.xml
+ git commit -m "release: v${{ inputs.version }}"
+ git push origin main
+ git tag "v${{ inputs.version }}"
+ git push origin "v${{ inputs.version }}"
+ publish:
+ needs: prepare
+ uses: ./.github/workflows/docker-publish.yml
+ with:
+ version: ${{ inputs.version }}
+ secrets: inherit
+```
+
+- [ ] **Step 2: Lint**
+
+Run: `sg docker -c "docker run --rm -v $PWD:/repo --workdir /repo rhysd/actionlint:latest .github/workflows/release.yml; echo RC=\$?"`
+Expected: `RC=0`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add .github/workflows/release.yml
+git commit -m "ci(release): release.yml orchestrates bump/tag/publish"
+```
+
+---
+
+### Task 7: `/release` skill (orchestrator) + retire `/new-release`
+
+**Files:**
+- Create: `.claude/skills/release/SKILL.md`
+- Delete: `.claude/skills/new-release/SKILL.md` (superseded; buggy: never tagged, stale `/kindle/` paths)
+
+- [ ] **Step 1: Write the skill**
+
+````markdown
+---
+name: release
+description: Cut a FeedZero release end-to-end and unattended — draft curated landing notes, publish the changelog feed (landing first), then trigger CI to bump, tag, and publish the Docker image. Version is auto-derived from conventional commits.
+argument-hint: "[X.Y.Z to force a version] [--dry-run]"
+---
+
+# /release
+
+Cut a release with one command. Supersedes the old `/new-release`.
+
+## Inputs
+- Optional explicit version `X.Y.Z` (else derived from conventional commits).
+- `--dry-run`: compute + draft + lint and print everything; make NO writes.
+
+## Preconditions (ABORT if any fail)
+1. feedzero working tree clean, on `main`, up to date (`git fetch && git status`).
+2. `npm test` and `npx -p typescript@6.0.3 tsc --noEmit` both green.
+3. Landing repo present at `../feedzero-landing`, clean, on `main`.
+
+## Steps
+
+1. **Last version**: `node -e "import('../feedzero-landing/releases.mjs').then(m=>console.log(m.releases[0].version))"`.
+2. **Commits since**: find the boundary — the commit that bumped to the last version (search `git log` for `release: v` or tag `v`), then `git log --pretty=%s ..HEAD`. Collect subject lines.
+3. **Version**: import `scripts/release/compute-version.mjs`; `computeVersion(last, subjects)`. If `null` → ABORT "nothing to release". If an explicit arg was given, use it instead.
+4. **Draft notes**: `draftNotes(subjects, { version, date: })` from `scripts/release/draft-notes.mjs`.
+5. **Lint**: `lintNotes(entry)` from `scripts/release/lint-notes.mjs`. Auto-fix `fixable` violations (append period, em-dash→comma, strip `!`); if any non-fixable remain → ABORT and show them.
+6. **--dry-run?** print version + entry + planned git ops and STOP here.
+7. **Write landing**: prepend the entry object to the `releases` array in `../feedzero-landing/releases.mjs`, then `cd ../feedzero-landing && node build-releases.mjs`. Verify `releases.xml` first `` is the new version.
+8. **Push landing FIRST**: `cd ../feedzero-landing && git add releases.mjs releases.xml index.html && git commit -m "release: v — " && git push origin main`.
+9. **Wait for landing live**: poll `https://feedzero.app/releases.xml` (every 15s, up to ~5 min) until it contains `feedzero:release:`. Timeout → ABORT (do NOT trigger feedzero).
+10. **Trigger feedzero CI**: `gh workflow run release.yml --repo forcingfx/feedzero -f version=`. Report the run URL.
+11. **Report**: print the landing commit, the feed URL, and the CI run link. Done — the user can walk away; CI bumps/tags/publishes.
+
+## Notes
+- NEVER change existing `` values (`feedzero:release:*`, `feedzero:changelog`) — breaks subscribers.
+- Notes are editable after the fact: edit `releases.mjs`, re-run `build-releases.mjs`, push landing.
+- **Resume after partial failure**: if `releases.mjs` already has an entry for ``, skip steps 4–8 and resume at step 9.
+- Screenshot/bento/LinkedIn are out of scope here — run those manually if wanted.
+````
+
+- [ ] **Step 2: Delete the superseded skill**
+
+```bash
+git rm -r .claude/skills/new-release
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add .claude/skills/release/SKILL.md
+git commit -m "feat(release): /release orchestrator skill; retire /new-release"
+```
+
+---
+
+### Task 8: Setup runbook + full verification
+
+**Files:**
+- Modify: `docs/operations/self-host-image-publishing.md` (add a `/release` + setup section)
+
+- [ ] **Step 1: Append a `## Automated releases (/release)` section** documenting:
+ - The flow (local skill → landing-first → CI).
+ - **One-time setup:** GitHub → Settings → Actions → General → Workflow permissions → **Read and write** (lets `release.yml` push the bump + tag). Fallback: a fine-scoped PAT (`contents: write`) stored as secret `RELEASE_TAG_TOKEN`, used by `release.yml`'s push step, if you prefer not to grant Actions write.
+ - That the `v*` tag still publishes via the existing trigger for manual tags.
+
+```bash
+# (write the section, then:)
+git add docs/operations/self-host-image-publishing.md
+git commit -m "docs(release): document /release flow + one-time setup"
+```
+
+- [ ] **Step 2: Full suite + type check + lint**
+
+Run:
+```bash
+npx vitest run # expect 0 failures (incl. new release tests + lock guard)
+npx -y -p typescript@6.0.3 tsc --noEmit -p tsconfig.json # expect clean
+sg docker -c "docker run --rm -v $PWD:/repo --workdir /repo rhysd/actionlint:latest .github/workflows/release.yml .github/workflows/docker-publish.yml; echo RC=\$?" # RC=0
+```
+
+- [ ] **Step 3: Dry-run the skill end-to-end (no writes)**
+
+Manually exercise the skill logic: run the three pure modules against `git log` of the last 20 commits and confirm it prints a plausible version + a lint-clean entry. (The skill's `--dry-run` path.)
+
+- [ ] **Step 4: Push branch + open PR (do NOT merge)**
+
+```bash
+git push -u origin feat/release-automation
+gh pr create --base main --head feat/release-automation --title "feat: /release one-command release automation" --body "Implements docs/superpowers/specs/2026-05-31-release-automation-design.md. Not auto-merging."
+```
+
+---
+
+## Self-review notes (author)
+- Spec coverage: compute-version (T1), draft-notes (T2), lint (T3), lock-chain (T4), reusable docker-publish + drift guard (T5), release.yml (T6), skill + retire new-release (T7), runbook/setup + verification (T8). All spec sections mapped.
+- The CI fixture refresh in T6 uses the LIVE feed (curl), since `../feedzero-landing` is absent in CI — this also makes the lock-chain re-verify against what landing actually published (landing-first enforced).
+- Types consistent: `parseConventional`→`computeBump`→`computeVersion`; `draftNotes` entry shape matches `releases.mjs`; `lintNotes` violation shape `{field,index,rule,fixable}` used in skill step 5.
From 4180ca0d4e0b804a46685aac21141f6f2dce2ade Mon Sep 17 00:00:00 2001
From: "github.sudoku"
Date: Sun, 31 May 2026 21:04:06 +0200
Subject: [PATCH 03/11] feat(release): conventional-commit version computation
---
scripts/release/compute-version.mjs | 44 +++++++++++++++
tests/scripts/release/compute-version.test.ts | 54 +++++++++++++++++++
2 files changed, 98 insertions(+)
create mode 100644 scripts/release/compute-version.mjs
create mode 100644 tests/scripts/release/compute-version.test.ts
diff --git a/scripts/release/compute-version.mjs b/scripts/release/compute-version.mjs
new file mode 100644
index 0000000..778c15e
--- /dev/null
+++ b/scripts/release/compute-version.mjs
@@ -0,0 +1,44 @@
+// Pure semver logic from conventional-commit subject lines. No I/O.
+//
+// Used by the /release skill to derive the next version automatically:
+// feat!/BREAKING CHANGE -> major, feat -> minor, fix/perf -> patch,
+// nothing releasable -> null (the skill aborts with "nothing to release").
+const RELEASE_BUMP = { feat: "minor", fix: "patch", perf: "patch" };
+
+export function parseConventional(subject) {
+ const m = /^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/s.exec(subject.trim());
+ if (!m) {
+ return { type: null, scope: null, breaking: false, description: subject.trim() };
+ }
+ const [, type, scope, bang, description] = m;
+ return {
+ type,
+ scope: scope ?? null,
+ breaking: Boolean(bang) || /BREAKING CHANGE/.test(subject),
+ description: description.trim(),
+ };
+}
+
+export function computeBump(subjects) {
+ let bump = null;
+ for (const s of subjects) {
+ const c = parseConventional(s);
+ if (c.breaking) return "major";
+ const b = RELEASE_BUMP[c.type];
+ if (b === "minor") bump = "minor";
+ else if (b === "patch" && bump !== "minor") bump = "patch";
+ }
+ return bump;
+}
+
+export function nextVersion(last, bump) {
+ const [maj, min, pat] = last.split(".").map(Number);
+ if (bump === "major") return `${maj + 1}.0.0`;
+ if (bump === "minor") return `${maj}.${min + 1}.0`;
+ if (bump === "patch") return `${maj}.${min}.${pat + 1}`;
+ return null;
+}
+
+export function computeVersion(last, subjects) {
+ return nextVersion(last, computeBump(subjects));
+}
diff --git a/tests/scripts/release/compute-version.test.ts b/tests/scripts/release/compute-version.test.ts
new file mode 100644
index 0000000..c14bd58
--- /dev/null
+++ b/tests/scripts/release/compute-version.test.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect } from "vitest";
+import {
+ parseConventional,
+ computeBump,
+ nextVersion,
+ computeVersion,
+} from "../../../scripts/release/compute-version.mjs";
+
+describe("parseConventional", () => {
+ it("parses type, scope, breaking, description", () => {
+ expect(parseConventional("feat(reader): add X")).toEqual({
+ type: "feat",
+ scope: "reader",
+ breaking: false,
+ description: "add X",
+ });
+ expect(parseConventional("fix!: drop Y")).toEqual({
+ type: "fix",
+ scope: null,
+ breaking: true,
+ description: "drop Y",
+ });
+ });
+ it("flags BREAKING CHANGE in the subject", () => {
+ expect(parseConventional("feat: z BREAKING CHANGE: w").breaking).toBe(true);
+ });
+ it("returns type null for non-conventional subjects", () => {
+ expect(parseConventional("just words").type).toBeNull();
+ });
+});
+
+describe("computeBump", () => {
+ it("major on any breaking", () =>
+ expect(computeBump(["feat: a", "fix!: b"])).toBe("major"));
+ it("minor on any feat (no breaking)", () =>
+ expect(computeBump(["fix: a", "feat: b"])).toBe("minor"));
+ it("patch on fix/perf only", () =>
+ expect(computeBump(["fix: a", "perf: b"])).toBe("patch"));
+ it("null when nothing releasable", () =>
+ expect(computeBump(["chore: a", "docs: b", "test: c"])).toBeNull());
+});
+
+describe("nextVersion / computeVersion", () => {
+ it("bumps correctly", () => {
+ expect(nextVersion("0.11.0", "major")).toBe("1.0.0");
+ expect(nextVersion("0.11.0", "minor")).toBe("0.12.0");
+ expect(nextVersion("0.11.3", "patch")).toBe("0.11.4");
+ expect(nextVersion("0.11.0", null)).toBeNull();
+ });
+ it("computeVersion composes parse+bump+next", () => {
+ expect(computeVersion("0.11.0", ["feat: a"])).toBe("0.12.0");
+ expect(computeVersion("0.11.0", ["chore: a"])).toBeNull();
+ });
+});
From 07d4ca3ec2346bb23ec67840a387089faff39665 Mon Sep 17 00:00:00 2001
From: "github.sudoku"
Date: Sun, 31 May 2026 21:04:41 +0200
Subject: [PATCH 04/11] feat(release): draft release-notes entry from commits
---
scripts/release/draft-notes.mjs | 42 +++++++++++++++++++++++
tests/scripts/release/draft-notes.test.ts | 32 +++++++++++++++++
2 files changed, 74 insertions(+)
create mode 100644 scripts/release/draft-notes.mjs
create mode 100644 tests/scripts/release/draft-notes.test.ts
diff --git a/scripts/release/draft-notes.mjs b/scripts/release/draft-notes.mjs
new file mode 100644
index 0000000..f461ab5
--- /dev/null
+++ b/scripts/release/draft-notes.mjs
@@ -0,0 +1,42 @@
+// Pure: conventional-commit subjects -> a releases.mjs entry object. No I/O.
+//
+// Maps commit types to Keep-a-Changelog sections, rewrites each subject to a
+// verb-led past-tense sentence in the landing house style, and derives a
+// title/subtitle. The result is editable after the fact (entry IDs are
+// preserved by build-releases.mjs), so an imperfect auto-title is fine.
+import { parseConventional } from "./compute-version.mjs";
+
+const SECTION_BY_TYPE = { feat: "added", fix: "fixed", perf: "changed", refactor: "changed" };
+
+export function toHouseStyle(description) {
+ let s = description.trim().replace(/\s*—\s*/g, ", ").replace(/!+/g, "");
+ s = s.charAt(0).toUpperCase() + s.slice(1);
+ if (!/[.]$/.test(s)) s += ".";
+ return s;
+}
+
+export function draftNotes(subjects, { version, date }) {
+ const buckets = { added: [], changed: [], fixed: [] };
+ for (const subj of subjects) {
+ const parsed = parseConventional(subj);
+ const section = SECTION_BY_TYPE[parsed.type];
+ if (!section) continue; // chore/docs/test/ci/build/style are not user-facing
+ buckets[section].push(toHouseStyle(parsed.description));
+ }
+ const lead =
+ buckets.added[0] ?? buckets.changed[0] ?? buckets.fixed[0] ?? "Maintenance release.";
+ const counts = [];
+ for (const k of ["added", "changed", "fixed"]) {
+ if (buckets[k].length) counts.push(`${buckets[k].length} ${k}`);
+ }
+ const entry = {
+ version,
+ date,
+ title: lead.replace(/\.$/, ""),
+ subtitle: counts.join(", ") + ".",
+ };
+ for (const k of ["added", "changed", "fixed"]) {
+ if (buckets[k].length) entry[k] = buckets[k];
+ }
+ return entry;
+}
diff --git a/tests/scripts/release/draft-notes.test.ts b/tests/scripts/release/draft-notes.test.ts
new file mode 100644
index 0000000..2e07a6b
--- /dev/null
+++ b/tests/scripts/release/draft-notes.test.ts
@@ -0,0 +1,32 @@
+import { describe, it, expect } from "vitest";
+import { toHouseStyle, draftNotes } from "../../../scripts/release/draft-notes.mjs";
+
+describe("toHouseStyle", () => {
+ it("capitalizes, ensures terminal period, replaces em-dash, drops exclamation", () => {
+ expect(toHouseStyle("added a thing — really!")).toBe("Added a thing, really.");
+ });
+ it("leaves backticks intact (build-releases turns them into )", () => {
+ expect(toHouseStyle("set `FOO`")).toBe("Set `FOO`.");
+ });
+});
+
+describe("draftNotes", () => {
+ it("buckets feat→added, fix→fixed, perf/refactor→changed; ignores chore/docs", () => {
+ const entry = draftNotes(
+ ["feat: add A", "fix: fix B", "perf: speed C", "chore: bump dep", "docs: tweak"],
+ { version: "0.12.0", date: "2026-06-01T12:00:00Z" },
+ );
+ expect(entry.version).toBe("0.12.0");
+ expect(entry.added).toEqual(["Add A."]);
+ expect(entry.fixed).toEqual(["Fix B."]);
+ expect(entry.changed).toEqual(["Speed C."]);
+ expect(entry.title).toBe("Add A");
+ expect(entry.subtitle).toBe("1 added, 1 changed, 1 fixed.");
+ });
+ it("omits empty sections and falls back to a maintenance title", () => {
+ const entry = draftNotes(["refactor: tidy"], { version: "0.12.0", date: "2026-06-01T12:00:00Z" });
+ expect(entry.added).toBeUndefined();
+ expect(entry.changed).toEqual(["Tidy."]);
+ expect(entry.title).toBe("Tidy");
+ });
+});
From 6a5fffed4228ab6410660e53f941ebcbd0d65ec7 Mon Sep 17 00:00:00 2001
From: "github.sudoku"
Date: Sun, 31 May 2026 21:05:16 +0200
Subject: [PATCH 05/11] feat(release): house-style lint for release notes
---
scripts/release/lint-notes.mjs | 43 ++++++++++++++++++++++++
tests/scripts/release/lint-notes.test.ts | 28 +++++++++++++++
2 files changed, 71 insertions(+)
create mode 100644 scripts/release/lint-notes.mjs
create mode 100644 tests/scripts/release/lint-notes.test.ts
diff --git a/scripts/release/lint-notes.mjs b/scripts/release/lint-notes.mjs
new file mode 100644
index 0000000..54a0b95
--- /dev/null
+++ b/scripts/release/lint-notes.mjs
@@ -0,0 +1,43 @@
+// Pure house-style checks for release-notes bullets. No I/O.
+//
+// The /release skill auto-fixes `fixable` violations (append a period, turn
+// em-dashes into commas, strip exclamation marks) and ABORTS on the rest
+// (lowercase start, emoji, marketing verbs) so a clumsy auto-drafted line
+// can never reach users' "What's new" feed unattended.
+const BANNED = [
+ "seamlessly",
+ "effortlessly",
+ "revolutionary",
+ "game-chang",
+ "blazing",
+ "delightful",
+ "magical",
+ "cutting-edge",
+ "best-in-class",
+ "powerful",
+];
+const EMOJI =
+ /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}\u{FE00}-\u{FE0F}]/u;
+
+export function lintBullet(text) {
+ const v = [];
+ if (!/^[A-Z]/.test(text)) v.push({ rule: "capitalized-start", fixable: false });
+ if (!/[.]$/.test(text)) v.push({ rule: "ends-with-period", fixable: true });
+ if (/—/.test(text)) v.push({ rule: "no-em-dash", fixable: true });
+ if (/!/.test(text)) v.push({ rule: "no-exclamation", fixable: true });
+ if (EMOJI.test(text)) v.push({ rule: "no-emoji", fixable: false });
+ if (BANNED.some((w) => text.toLowerCase().includes(w))) {
+ v.push({ rule: "no-marketing-verb", fixable: false });
+ }
+ return v;
+}
+
+export function lintNotes(entry) {
+ const out = [];
+ for (const field of ["added", "changed", "fixed", "removed"]) {
+ (entry[field] ?? []).forEach((text, index) => {
+ for (const v of lintBullet(text)) out.push({ field, index, ...v });
+ });
+ }
+ return out;
+}
diff --git a/tests/scripts/release/lint-notes.test.ts b/tests/scripts/release/lint-notes.test.ts
new file mode 100644
index 0000000..16a375f
--- /dev/null
+++ b/tests/scripts/release/lint-notes.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from "vitest";
+import { lintBullet, lintNotes } from "../../../scripts/release/lint-notes.mjs";
+
+describe("lintBullet", () => {
+ it("passes a clean past-tense bullet", () =>
+ expect(lintBullet("Added a feature.")).toEqual([]));
+ it("flags missing terminal period (fixable)", () =>
+ expect(lintBullet("Added a feature")).toEqual([
+ { rule: "ends-with-period", fixable: true },
+ ]));
+ it("flags lowercase start (fatal)", () =>
+ expect(lintBullet("added a feature.")).toContainEqual({
+ rule: "capitalized-start",
+ fixable: false,
+ }));
+ it("flags marketing verbs and emoji (fatal)", () => {
+ expect(lintBullet("Seamlessly improved sync.").some((v) => v.rule === "no-marketing-verb")).toBe(true);
+ expect(lintBullet("Added sparkle ✨.").some((v) => v.rule === "no-emoji")).toBe(true);
+ });
+});
+
+describe("lintNotes", () => {
+ it("reports field + index for each violation", () => {
+ const v = lintNotes({ added: ["Added a feature"], fixed: ["fixed a bug."] });
+ expect(v).toContainEqual({ field: "added", index: 0, rule: "ends-with-period", fixable: true });
+ expect(v).toContainEqual({ field: "fixed", index: 0, rule: "capitalized-start", fixable: false });
+ });
+});
From ea0fd7892d542253c23233afc96fdd6a1ea2fb3a Mon Sep 17 00:00:00 2001
From: "github.sudoku"
Date: Sun, 31 May 2026 21:05:53 +0200
Subject: [PATCH 06/11] test(release): lock package.json to the vendored
release feed
---
tests/fixtures/release-feed.xml | 69 +++++++++++++++++++++-
tests/scripts/release-version-sync.test.ts | 25 ++++++++
2 files changed, 93 insertions(+), 1 deletion(-)
create mode 100644 tests/scripts/release-version-sync.test.ts
diff --git a/tests/fixtures/release-feed.xml b/tests/fixtures/release-feed.xml
index 282b3ad..2a3197b 100644
--- a/tests/fixtures/release-feed.xml
+++ b/tests/fixtures/release-feed.xml
@@ -3,12 +3,79 @@
FeedZero Release NotesWhat changed in FeedZero.feedzero:changelog
- 2026-05-17T12:00:00Z
+ 2026-05-22T12:00:00ZFeedZero
+
+ feedzero:release:0.11.0
+ v0.11.0: Signal, free cloud sync, per-feed rules, and auto-refresh
+
+ 2026-05-22T12:00:00Z
+ 2026-05-22T12:00:00Z
+ Signal surfaces what is loud across your feeds entirely on-device. Cloud sync is now free for everyone. Per-feed rules act on new articles automatically, feeds refresh in the background, and non-RSS sources work by pasting their URL.
+ Signal surfaces what is loud across your feeds entirely on-device. Cloud sync is now free for everyone. Per-feed rules act on new articles automatically, feeds refresh in the background, and non-RSS sources work by pasting their URL.
+
Added
+
+
Added Signal, an on-device view of what is loud across your feeds. It clusters article titles on proper-noun and compound-noun entities (for example OpenAI or Iran War), ranks topics by how many distinct feeds cover each story rather than how often one outlet repeats it, and folds syndicated coverage of the same story behind a Covered by N outlets badge. Story rows show a peek preview on hover or tap before opening the full reader, and the back button returns to Signal rather than the feed list. It unlocks once the local corpus reaches 100 articles and runs with no external API or LLM. Available on the Personal tier.
+
Added a per-feed auto-action rules engine. Each rule pairs a condition (the same predicate used by smart filters) with one or more actions applied to new articles during refresh: mark read, star, mute, or move to a folder. Rules are scoped to a single feed and reachable from that feed's settings. Available on the Personal tier.
+
Added bridges that turn non-RSS source URLs into the native feed each source already publishes. Pasting a Reddit subreddit or user, a GitHub repository, a Mastodon profile, or a YouTube channel URL into Add feed resolves the underlying feed by URL pattern alone, with no scraping. URLs that match no bridge fall back to the existing discovery path. Available on the Personal tier.
+
Added a per-feed Prefetch full text toggle that extracts the 20 most recent articles on each refresh for offline reading. Feeds you read often (10 or more articles in the past 30 days) prefetch automatically, with the selection computed on-device from the encrypted vault. Available on the Personal tier.
+
Added periodic background auto-refresh. Feeds refresh every 30 minutes, and a tab that regains focus after sitting idle longer than the interval refreshes immediately.
+
Added refresh controls to mobile. A refresh button sits in the mobile header and a Refresh all entry in the navigation drawer, both showing a spinning Refreshing state while they run.
+
Added a quick-switch favicon dock to the closed mobile bottom drawer. The closed strip now shows an All items button and favicons for your most recently viewed feeds; tapping one switches feed without opening the drawer, and the chevron opens the full feed list.
+
+
Changed
+
+
Made end-to-end encrypted cloud sync a Free-tier feature. Every user can now sync across devices without a license. The Free tier keeps its 50-feed cap, while paid tiers add unlimited feeds, smart filters, auto-organize, and offline prefetch.
+
Replaced the sidebar dropdown menus with a context-aware cog and a sort control in a sticky header above the article list. The cog opens feed, folder, or smart-filter settings depending on what is selected and hides on aggregated views like All items and Starred. The sort control is an icon-only pill on desktop that expands to its label on hover and stays labeled on mobile.
+
Tightened license enforcement with daily in-session re-verification. A tab left open now re-checks the subscription every 24 hours, and again when the device wakes from sleep, so a subscription that ends mid-session no longer keeps paid features until the next reload.
+
Per-feed Refresh now and Clear cached articles actions now disable while running, spin their icon, and confirm with a toast.
+
+
Fixed
+
+
Fixed duplicate articles appearing when a single feed was refreshed concurrently. Overlapping refreshes of the same feed now share one in-flight request.
+
Fixed feed favicons that failed to load staying blank. Failed favicons now retry on the next refresh and recover.
+
Fixed feeds dropping out of an OPML import when their server rate-limited the initial fetch. Rate-limited URLs now import as placeholder feeds and fill in on a later refresh.
+
Fixed Signal previews rendering empty when an article had no extracted content, which now fall back to the summary, and fixed the preview sheet being clipped by the iOS toolbar.
+
]]>
+ FeedZero
+
+
+ feedzero:release:0.10.0
+ v0.10.0: Self-host packaged, privacy promise enforced
+
+ 2026-05-19T15:00:00Z
+ 2026-05-19T15:00:00Z
+ Self-host deploys in three commands via Docker. The client stops loading Vercel Speed Insights. Error logs stop emitting feed URLs. The repository ships an explicit AGPL-3.0-or-later LICENSE.
+ Self-host deploys in three commands via Docker. The client stops loading Vercel Speed Insights. Error logs stop emitting feed URLs. The repository ships an explicit AGPL-3.0-or-later LICENSE.
+
Added
+
+
Added a single-container Docker deploy with Caddy in front for automatic TLS. cp .env.example .env, edit one value, run ./scripts/feedzero up. Day-2 ops (update, backup, restore, logs, doctor) wrap the underlying docker-compose commands so self-hosters do not memorize them.
+
Added scripts/feedzero (POSIX shell) and scripts/feedzero.ps1 (PowerShell) so the same surface works on macOS, Linux, WSL2, Git Bash, and native Windows.
+
Added a comprehensive self-hosting guide at docs/self-hosting.md. Covers Docker installation on each OS, public-hostname deploys via Let's Encrypt, LAN-only deploys with self-signed certs (with per-OS instructions for trusting Caddy's root CA), day-2 operations, and the seven failures self-hosters actually hit.
+
Added a GitHub Actions workflow that builds and publishes a multi-arch self-host image (amd64 + arm64) to ghcr.io/forcingfx/feedzero on every version tag. Raspberry Pi self-hosters no longer rebuild from source on updates.
+
Added integration tests that exercise feed-store and sync-store actions against the real encrypted database via fake-indexeddb. Replaces faith-based mocks at the storage boundary, closing the gap that produced the issue #117 cascade.
+
+
Changed
+
+
Changed the license from implicit "All rights reserved" to AGPL-3.0-or-later. The repository now ships a LICENSE file and a matching SPDX identifier in package.json. Section 13 (the network-use clause) means anyone running a modified FeedZero as a public service must offer their users the modified source.
+
Reduced the first-paint bundle by 90 KB (gzipped). The Defuddle full-text extractor and its adapter registry now load on demand when a user clicks Extracted, not on every page load. Main bundle dropped from 404 KB to 314 KB gzipped — meaningful for the five-year-old phone on a slow connection.
+
Refactored the feeds route from a 459-line monolith into a layout-with-Outlet shape (ADR 013). The stable two-panel topology survives navigation cleanly and adding a new full-page surface no longer means another isXxxPage flag.
+
Split the explore catalog from a single 889-line file into a tab-pluggable shell plus per-tab modules. Sets up the upcoming curated catalog work (use-case packs, editorial collections, platform bridges).
+
+
Fixed
+
+
Server stopped logging full feed URLs in proxy error paths. Previously a failed fetch on /api/feed or /api/icon emitted the target URL into stdout, where it landed in operator-readable log retention. The privacy-floor logger (logError) now handles both call sites with an opaque trace id the user can quote in support.
+
+
Removed
+
+
Removed the @vercel/speed-insights client SDK. The README's headline privacy promise (“No telemetry. No analytics. No crash reporting. No third-party tracking.”) now matches the shipped code. No page-view or Web-Vitals beacons leave the browser.
+
]]>
+ FeedZero
+ feedzero:release:0.9.0v0.9.0: Settings unification, log in for existing license holders, OPML folders
diff --git a/tests/scripts/release-version-sync.test.ts b/tests/scripts/release-version-sync.test.ts
new file mode 100644
index 0000000..0065383
--- /dev/null
+++ b/tests/scripts/release-version-sync.test.ts
@@ -0,0 +1,25 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+
+const REPO = path.resolve(__dirname, "../..");
+
+// Guards the version drift that shipped 0.10/0.11 while package.json + the
+// baked APP_VERSION stayed 0.9.0 (issues #211/#212). The vendored fixture is
+// the in-repo stand-in for what the landing changelog published, refreshed
+// on every release; keeping package.json equal to its newest entry ties the
+// app's reported version to the published release notes.
+describe("release version lock (#211/#212 drift guard)", () => {
+ it("package.json version equals the newest vendored release-feed entry", () => {
+ const pkg = JSON.parse(
+ readFileSync(path.join(REPO, "package.json"), "utf8"),
+ ).version;
+ const xml = readFileSync(
+ path.join(REPO, "tests/fixtures/release-feed.xml"),
+ "utf8",
+ );
+ const newest = /feedzero:release:([0-9][0-9.]*)<\/id>/.exec(xml)?.[1];
+ expect(newest, "no release id found in vendored fixture").toBeTruthy();
+ expect(newest).toBe(pkg);
+ });
+});
From 5a455b0e8c416da9da416e161f7c7ae443670a99 Mon Sep 17 00:00:00 2001
From: "github.sudoku"
Date: Sun, 31 May 2026 21:06:48 +0200
Subject: [PATCH 07/11] ci(release): make docker-publish reusable + version
drift guard
---
.github/workflows/docker-publish.yml | 37 +++++++++++++++++++++++-----
1 file changed, 31 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index af3c63a..47f0a05 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -27,6 +27,12 @@ on:
tags:
- 'v*.*.*'
workflow_dispatch:
+ workflow_call:
+ inputs:
+ version:
+ description: "Release version without leading v (e.g. 0.12.0). Set when called by release.yml."
+ required: true
+ type: string
concurrency:
group: docker-publish-${{ github.ref }}
@@ -52,6 +58,24 @@ jobs:
- name: Check out
uses: actions/checkout@v6
+ # Resolve the release version from the trigger and (except for the
+ # SHA-only manual dispatch) refuse to publish if it disagrees with
+ # package.json. Closes the drift class behind issues #211/#212.
+ - name: Resolve + verify release version
+ id: ver
+ run: |
+ if [ "${{ github.event_name }}" = "workflow_call" ]; then
+ VER="${{ inputs.version }}"
+ else
+ VER="${GITHUB_REF_NAME#v}"
+ fi
+ PKG="$(node -p "require('./package.json').version")"
+ if [ "${{ github.event_name }}" != "workflow_dispatch" ] && [ "$VER" != "$PKG" ]; then
+ echo "::error::version mismatch: ref/input=$VER package.json=$PKG"; exit 1
+ fi
+ echo "version=$VER" >> "$GITHUB_OUTPUT"
+ echo "minor=${VER%.*}" >> "$GITHUB_OUTPUT"
+
- name: Set up QEMU (arm64 emulation for cross-arch builds)
uses: docker/setup-qemu-action@v4
@@ -91,13 +115,14 @@ jobs:
images: |
ghcr.io/${{ github.repository_owner }}/feedzero
${{ secrets.DOCKERHUB_TOKEN != '' && format('docker.io/{0}/feedzero', github.repository_owner) || '' }}
- # Tags:
- # * On version tag: vX.Y.Z, X.Y, latest
- # * On manual dispatch: short SHA
+ # Tags (driven by the resolved version so tag-push and
+ # workflow_call behave identically):
+ # * Release (tag push or workflow_call): vX.Y.Z, X.Y, latest
+ # * Manual dispatch: short SHA only (a throwaway smoke build)
tags: |
- type=semver,pattern={{version}},prefix=v
- type=semver,pattern={{major}}.{{minor}}
- type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
+ type=raw,value=v${{ steps.ver.outputs.version }},enable=${{ github.event_name != 'workflow_dispatch' }}
+ type=raw,value=${{ steps.ver.outputs.minor }},enable=${{ github.event_name != 'workflow_dispatch' }}
+ type=raw,value=latest,enable=${{ github.event_name != 'workflow_dispatch' }}
type=sha,enable=${{ github.event_name == 'workflow_dispatch' }}
- name: Build + push
From 398cd9947d6fe302513c1a9701a60f56af188537 Mon Sep 17 00:00:00 2001
From: "github.sudoku"
Date: Sun, 31 May 2026 21:07:15 +0200
Subject: [PATCH 08/11] ci(release): release.yml orchestrates bump/tag/publish
---
.github/workflows/release.yml | 57 +++++++++++++++++++++++++++++++++++
1 file changed, 57 insertions(+)
create mode 100644 .github/workflows/release.yml
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..861f9d3
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,57 @@
+name: Release feedzero
+# Triggered by the local /release skill via `gh workflow run` AFTER the
+# landing changelog feed is confirmed live. Bumps the version, refreshes the
+# vendored fixture from the live feed, verifies the version lock, runs the
+# test suite, tags, and publishes the image via the reusable docker-publish
+# workflow. See docs/operations/self-host-image-publishing.md.
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Release version without leading v (e.g. 0.12.0)"
+ required: true
+ type: string
+
+permissions:
+ contents: write
+ packages: write
+
+jobs:
+ prepare:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: main
+ fetch-depth: 0
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ - run: npm ci
+ - name: Bump package.json
+ run: npm version "${{ inputs.version }}" --no-git-tag-version
+ - name: Refresh vendored fixture from the live landing feed
+ run: curl -fsSL https://feedzero.app/releases.xml -o tests/fixtures/release-feed.xml
+ - name: Verify version lock (package.json == feed top == input)
+ run: |
+ PKG="$(node -p "require('./package.json').version")"
+ FEED="$(grep -oE 'feedzero:release:[0-9.]+' tests/fixtures/release-feed.xml | head -1 | cut -d: -f3)"
+ test "$PKG" = "${{ inputs.version }}" || { echo "::error::package.json $PKG != input ${{ inputs.version }}"; exit 1; }
+ test "$FEED" = "${{ inputs.version }}" || { echo "::error::landing feed top $FEED != ${{ inputs.version }} (publish landing first)"; exit 1; }
+ - run: npx tsc --noEmit
+ - run: npm test
+ - name: Commit bump + fixture, then tag
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add package.json package-lock.json tests/fixtures/release-feed.xml
+ git commit -m "release: v${{ inputs.version }}"
+ git push origin main
+ git tag "v${{ inputs.version }}"
+ git push origin "v${{ inputs.version }}"
+ publish:
+ needs: prepare
+ uses: ./.github/workflows/docker-publish.yml
+ with:
+ version: ${{ inputs.version }}
+ secrets: inherit
From 0b72af7267856a065a6ab4353a69a4eb88e46a6c Mon Sep 17 00:00:00 2001
From: "github.sudoku"
Date: Sun, 31 May 2026 21:07:58 +0200
Subject: [PATCH 09/11] feat(release): /release orchestrator skill; retire
/new-release
---
.claude/skills/new-release/SKILL.md | 188 ----------------------------
.claude/skills/release/SKILL.md | 63 ++++++++++
2 files changed, 63 insertions(+), 188 deletions(-)
delete mode 100644 .claude/skills/new-release/SKILL.md
create mode 100644 .claude/skills/release/SKILL.md
diff --git a/.claude/skills/new-release/SKILL.md b/.claude/skills/new-release/SKILL.md
deleted file mode 100644
index d9ceaef..0000000
--- a/.claude/skills/new-release/SKILL.md
+++ /dev/null
@@ -1,188 +0,0 @@
----
-name: new-release
-description: Cut a new FeedZero release — write release notes, regenerate the Atom feed, bump the version, update the test fixture, optionally create a bento page, and push both repos in deployment order.
-argument-hint: " e.g. 0.6.0"
----
-
-# New Release
-
-Cut a new FeedZero release. This skill codifies the exact process so every release is consistent and nothing is forgotten.
-
-## Arguments
-
-`$ARGUMENTS` is the new version number (e.g. `0.6.0`). If omitted, ask the user.
-
-## Prerequisites
-
-Before starting, verify:
-1. All tests pass: `npm test` in the feedzero repo.
-2. Working tree is clean (no uncommitted changes that should be in this release).
-3. The landing repo at `../feedzero-landing/` is accessible and on `main`.
-
-## Step-by-step process
-
-### 1. Determine what changed
-
-```bash
-# Find the previous release version
-cd ../feedzero-landing && node -e "import('./releases.mjs').then(m => console.log(m.releases[0].version))"
-```
-
-Then in the feedzero repo, get the git log since the last release was cut. Cross-reference with `../feedzero-landing/releases.mjs` to find the boundary commit.
-
-```bash
-cd /home/DeadEye3164/builder/kindle/feedzero
-git log --oneline ..HEAD
-```
-
-Categorize commits into Added / Changed / Fixed / Removed per Keep-a-Changelog convention. Skip merge commits, test-only commits, and docs-only commits unless they're user-facing.
-
-### 2. Write the release entry
-
-Edit `../feedzero-landing/releases.mjs`. Add a new object at the TOP of the `releases` array:
-
-```js
-{
- version: "",
- date: "",
- title: "",
- subtitle: "",
- added: [
- // Each bullet: one verb-led past-tense sentence ending with a period.
- // No marketing verbs, no emojis. Backticks become tags.
- ],
- changed: [ /* ... */ ],
- fixed: [ /* ... */ ],
- // Omit empty sections entirely (don't include removed: [] if nothing was removed)
-}
-```
-
-**Style rules** (from the file's header comment):
-- Plain, factual, README/man-page tone.
-- Each bullet is one verb-led past-tense sentence ending with a period.
-- No marketing verbs, no emojis, no call-to-action.
-- Backticks in `releases.mjs` get converted to `` tags in the feed.
-
-### 3. Regenerate the Atom feed and HTML
-
-```bash
-cd ../feedzero-landing
-node build-releases.mjs
-```
-
-This regenerates:
-- `releases.xml` — the Atom feed at `https://feedzero.app/releases.xml`
-- `index.html` — updates the release notes accordion on the landing page
-
-Verify the output:
-```bash
-head -20 releases.xml # Should show the new version as the first
-```
-
-### 4. Bump the version in feedzero
-
-```bash
-cd /home/DeadEye3164/builder/kindle/feedzero
-```
-
-Edit `package.json` — update the `"version"` field to the new version.
-
-### 5. Update the vendored test fixture
-
-```bash
-cp ../feedzero-landing/releases.xml tests/fixtures/release-feed.xml
-```
-
-Then verify the parser contract test passes:
-```bash
-npx vitest run tests/core/parser/release-feed-fixture.test.ts
-```
-
-This test parses the vendored fixture through the app's parser and asserts the fields the app consumes (title, siteUrl, articles with title/link/content/publishedAt/guid). If the landing-side generator changed its format in a way that breaks the parser, this test catches it.
-
-### 6. Take a fresh screenshot of the app
-
-```bash
-cd ../feedzero-landing
-node take-screenshot.mjs
-```
-
-This launches the Vite dev server from the feedzero repo, captures the Explore tab at 1440x900, and saves `screenshot.png`. The `` on the landing page renders it with rounded corners (`border-radius: 12px`) and a subtle shadow (`box-shadow`).
-
-If the dev server is already running, use `--url`:
-```bash
-node take-screenshot.mjs --url http://localhost:3000
-```
-
-For a feeds view with articles loaded:
-```bash
-node take-screenshot.mjs --scene feeds
-```
-
-### 7. (Optional) Create a bento box page
-
-If the user wants a social media visual for LinkedIn/Twitter, create a bento page:
-
-```bash
-mkdir -p ../feedzero-landing/releases//
-```
-
-Create `../feedzero-landing/releases//index.html`. Use `../feedzero-landing/releases/0.5.0/index.html` as a reference. The page must:
-- Be a fixed 1200x630 landscape card (LinkedIn image dimensions), no scrolling required.
-- Use the **same visual language as the landing page**: white background, 1px `#e5e7eb` borders, `#f8fafc` panel headers, slate text (`#0f172a`), gray descriptions (`#64748b`), eyebrow labels, `.tag` elements, `kbd` elements, `.word` passphrase pills, `.tile` illustration boxes. See `index.html`'s CSS for the exact values.
-- Follow the writing style: plain, factual, verb-led. Each cell title is one short sentence ending with a period. No marketing verbs, no emojis, no exclamation marks.
-- Include mini illustration tiles in each cell (sidebar mock-ups, tag pipelines, code snippets) — not just text. These fill the space and give visual texture.
-- Have a stats footer row with key numbers (tests, feeds in catalog, encryption, etc.).
-
-### 8. Commit and push — LANDING FIRST
-
-**Deployment order matters.** The feedzero app fetches `https://feedzero.app/releases.xml` on first launch. If feedzero deploys before the landing site, new users see a 404 on auto-subscribe (swallowed by try/catch, non-fatal, but they won't see the release feed until next refresh).
-
-```bash
-# Landing repo — commit and push FIRST
-cd ../feedzero-landing
-git add releases.mjs releases.xml index.html screenshot.png
-# Also add releases//index.html if a bento page was created
-git commit -m "release: v — "
-git push origin main
-```
-
-Wait for the landing site to deploy. Verify:
-```bash
-curl -sSL "https://feedzero.app/releases.xml" | head -15
-# Should show the new version as the first
-```
-
-```bash
-# Feedzero repo — commit and push SECOND
-cd /home/DeadEye3164/builder/kindle/feedzero
-git add package.json tests/fixtures/release-feed.xml
-git commit -m "release: bump version to , update release feed fixture"
-git push origin main
-```
-
-### 9. Verify the live feed
-
-```bash
-curl -sSL "https://feedzero.app/releases.xml" | grep "feedzero:release:"
-```
-
-If this returns the entry ID, the release is live. Existing users who refresh their release feed will see the new entry. New users auto-subscribing on first launch will get the full feed.
-
-### 10. (Optional) Draft a LinkedIn post
-
-If the user wants a social post, draft it in builder/maker tone:
-- First-person, "here's what I shipped" energy
-- Short, punchy paragraphs — one feature per paragraph
-- Include the bento page URL for the visual
-- Include the app URL
-- Cover everything since the LAST LinkedIn post (ask the user which version that was)
-
-## Important notes
-
-- **Preserve entry IDs.** The `` values in the Atom feed (`feedzero:release:`) must never change after publishing. Changing them makes every existing subscriber re-import the entry as new.
-- **The `feedzero:changelog` feed ID** in the `` element must also never change.
-- **The `package.json` version** was historically out of sync (stuck at 0.2.1 while the release notes were at 0.4.0). Keep it in sync going forward.
-- **The vendored fixture** (`tests/fixtures/release-feed.xml`) must be updated with every release so the parser contract test covers the latest format.
-- **Run `npm test` in the feedzero repo** after updating the fixture to verify everything passes before pushing.
-- **Docker images publish automatically** on every `v*.*.*` tag push via `.github/workflows/docker-publish.yml`. The workflow pushes to `ghcr.io/forcingfx/feedzero` always and additionally mirrors to `docker.io/forcingfx/feedzero` when the `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` repo secrets are set. No manual step during the release cut — once the tag is pushed in step 8, the workflow handles both registries.
diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md
new file mode 100644
index 0000000..41b7d17
--- /dev/null
+++ b/.claude/skills/release/SKILL.md
@@ -0,0 +1,63 @@
+---
+name: release
+description: Cut a FeedZero release end-to-end and unattended — draft curated landing notes, publish the changelog feed (landing first), then trigger CI to bump, tag, and publish the Docker image. Version is auto-derived from conventional commits.
+argument-hint: "[X.Y.Z to force a version] [--dry-run]"
+---
+
+# /release
+
+Cut a release with one command. Supersedes the old `/new-release` (which
+never created a git tag and had stale paths).
+
+## Inputs
+- Optional explicit version `X.Y.Z` (else derived from conventional commits).
+- `--dry-run`: compute + draft + lint and print everything; make NO writes.
+
+## Preconditions (ABORT if any fail)
+1. feedzero working tree clean, on `main`, up to date (`git fetch && git status`).
+2. `npm test` and `npx -p typescript@6.0.3 tsc --noEmit` both green.
+3. Landing repo present at `../feedzero-landing`, clean, on `main`.
+
+## Steps
+
+1. **Last version**:
+ `node -e "import('../feedzero-landing/releases.mjs').then(m=>console.log(m.releases[0].version))"`.
+2. **Commits since**: find the boundary — the commit that bumped to the last
+ version (search `git log` for `release: v` or the tag `v`),
+ then `git log --pretty=%s ..HEAD`. Collect the subject lines.
+3. **Version**: import `scripts/release/compute-version.mjs`;
+ `computeVersion(last, subjects)`. If `null` → ABORT "nothing to release".
+ If an explicit `X.Y.Z` arg was given, use it instead.
+4. **Draft notes**: `draftNotes(subjects, { version, date: })` from
+ `scripts/release/draft-notes.mjs`.
+5. **Lint**: `lintNotes(entry)` from `scripts/release/lint-notes.mjs`.
+ Auto-fix `fixable` violations (append period, em-dash→comma, strip `!`);
+ if any non-fixable remain → ABORT and show them. Hand-edit the draft entry
+ for tone before continuing — the lint only enforces mechanics.
+6. **--dry-run?** print the version + entry + planned git ops and STOP here.
+7. **Write landing**: prepend the entry object to the `releases` array in
+ `../feedzero-landing/releases.mjs`, then
+ `cd ../feedzero-landing && node build-releases.mjs`. Verify `releases.xml`'s
+ first `` is the new version.
+8. **Push landing FIRST**:
+ `cd ../feedzero-landing && git add releases.mjs releases.xml index.html && git commit -m "release: v — " && git push origin main`.
+9. **Wait for landing live**: poll `https://feedzero.app/releases.xml` (every
+ 15s, up to ~5 min) until it contains `feedzero:release:`. Timeout
+ → ABORT (do NOT trigger feedzero; the landing-first invariant must hold).
+10. **Trigger feedzero CI**:
+ `gh workflow run release.yml --repo forcingfx/feedzero -f version=`.
+11. **Report**: print the landing commit, the feed URL, and the CI run link
+ (`gh run list --workflow=release.yml -L1`). Done — the user can walk away;
+ CI bumps `package.json`, refreshes the fixture, tests, tags, and publishes.
+
+## Notes
+- NEVER change existing `` values (`feedzero:release:*`,
+ `feedzero:changelog`) — that makes every subscriber re-import old entries.
+- Notes are editable after the fact: edit `releases.mjs`, re-run
+ `build-releases.mjs`, push landing.
+- **Resume after partial failure**: if `releases.mjs` already has an entry for
+ ``, skip steps 4–8 and resume at step 9 (poll) → 10 (trigger CI).
+- Screenshot / bento card / LinkedIn post are out of scope here — run those
+ manually if wanted.
+- One-time setup (Actions write permission, or PAT fallback) is documented in
+ `docs/operations/self-host-image-publishing.md`.
From fba6b432164eb46e1fabe5d791ce235d3e54ec93 Mon Sep 17 00:00:00 2001
From: "github.sudoku"
Date: Sun, 31 May 2026 21:08:20 +0200
Subject: [PATCH 10/11] docs(release): document /release flow + one-time setup
---
docs/operations/self-host-image-publishing.md | 36 +++++++++++++++++++
1 file changed, 36 insertions(+)
diff --git a/docs/operations/self-host-image-publishing.md b/docs/operations/self-host-image-publishing.md
index 39b166e..d1d8d06 100644
--- a/docs/operations/self-host-image-publishing.md
+++ b/docs/operations/self-host-image-publishing.md
@@ -63,3 +63,39 @@ publishes to GHCR only (zero-config, nothing breaks).
registry entirely.
3. Portainer users: Repository stack method, not web-editor paste — see
`docs/self-hosting.md` (Deploying with Portainer).
+
+## Automated releases (`/release`)
+
+`/release` (the Claude skill in `.claude/skills/release/`) cuts a release
+end to end and replaces the old `/new-release`:
+
+1. **Local skill** — derives the version from conventional commits, drafts +
+ lints the `releases.mjs` entry, runs `build-releases.mjs`, commits and
+ pushes **landing first**, then polls until `feedzero.app/releases.xml`
+ shows the new entry. Only then does it fire the feedzero CI.
+2. **CI (`release.yml`)** — bumps `package.json`, refreshes the vendored
+ fixture from the live feed, re-verifies the version lock, runs
+ `tsc` + `npm test`, commits the bump, pushes the `vX.Y.Z` tag, and calls
+ the reusable `docker-publish.yml` to publish the multi-arch image.
+
+The four version touchpoints stay locked: landing notes → vendored fixture
+(`release-version-sync.test.ts`) → `package.json` → git tag
+(`docker-publish.yml` drift guard).
+
+### One-time setup
+
+`release.yml` commits the version bump and pushes the tag from CI, so GitHub
+Actions needs write access:
+
+- **GitHub → Settings → Actions → General → Workflow permissions → "Read and
+ write permissions"** (and allow Actions to create/approve PRs is not
+ needed). This lets `release.yml` push the bump commit + tag.
+- **Fallback** if you prefer not to grant Actions write to `main`: create a
+ fine-scoped PAT (`contents: write` on this repo), store it as the secret
+ `RELEASE_TAG_TOKEN`, and change `release.yml`'s checkout + push steps to use
+ it (`actions/checkout@v6` with `token: ${{ secrets.RELEASE_TAG_TOKEN }}`).
+ The tag it pushes then also triggers `docker-publish.yml` directly, so you
+ could drop the `publish` job — at the cost of managing a token.
+
+`workflow_call` means `docker-publish.yml` is invoked by `release.yml`; its
+original `push: tags: v*.*.*` trigger still works for hand-pushed tags.
From a0faf61ed046636629a9283d587bb3c5a4b53b41 Mon Sep 17 00:00:00 2001
From: "github.sudoku"
Date: Sun, 31 May 2026 21:12:17 +0200
Subject: [PATCH 11/11] refactor(release): type pure modules as .ts (strict
tsc)
---
...compute-version.mjs => compute-version.ts} | 24 +++++---
scripts/release/draft-notes.mjs | 42 -------------
scripts/release/draft-notes.ts | 60 +++++++++++++++++++
.../release/{lint-notes.mjs => lint-notes.ts} | 26 ++++++--
tests/scripts/release/compute-version.test.ts | 2 +-
tests/scripts/release/draft-notes.test.ts | 2 +-
tests/scripts/release/lint-notes.test.ts | 12 +++-
7 files changed, 109 insertions(+), 59 deletions(-)
rename scripts/release/{compute-version.mjs => compute-version.ts} (64%)
delete mode 100644 scripts/release/draft-notes.mjs
create mode 100644 scripts/release/draft-notes.ts
rename scripts/release/{lint-notes.mjs => lint-notes.ts} (70%)
diff --git a/scripts/release/compute-version.mjs b/scripts/release/compute-version.ts
similarity index 64%
rename from scripts/release/compute-version.mjs
rename to scripts/release/compute-version.ts
index 778c15e..f61772c 100644
--- a/scripts/release/compute-version.mjs
+++ b/scripts/release/compute-version.ts
@@ -3,9 +3,19 @@
// Used by the /release skill to derive the next version automatically:
// feat!/BREAKING CHANGE -> major, feat -> minor, fix/perf -> patch,
// nothing releasable -> null (the skill aborts with "nothing to release").
-const RELEASE_BUMP = { feat: "minor", fix: "patch", perf: "patch" };
-export function parseConventional(subject) {
+export type Bump = "major" | "minor" | "patch";
+
+export interface ParsedCommit {
+ type: string | null;
+ scope: string | null;
+ breaking: boolean;
+ description: string;
+}
+
+const RELEASE_BUMP: Record = { feat: "minor", fix: "patch", perf: "patch" };
+
+export function parseConventional(subject: string): ParsedCommit {
const m = /^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/s.exec(subject.trim());
if (!m) {
return { type: null, scope: null, breaking: false, description: subject.trim() };
@@ -19,19 +29,19 @@ export function parseConventional(subject) {
};
}
-export function computeBump(subjects) {
- let bump = null;
+export function computeBump(subjects: string[]): Bump | null {
+ let bump: Bump | null = null;
for (const s of subjects) {
const c = parseConventional(s);
if (c.breaking) return "major";
- const b = RELEASE_BUMP[c.type];
+ const b = c.type ? RELEASE_BUMP[c.type] : undefined;
if (b === "minor") bump = "minor";
else if (b === "patch" && bump !== "minor") bump = "patch";
}
return bump;
}
-export function nextVersion(last, bump) {
+export function nextVersion(last: string, bump: Bump | null): string | null {
const [maj, min, pat] = last.split(".").map(Number);
if (bump === "major") return `${maj + 1}.0.0`;
if (bump === "minor") return `${maj}.${min + 1}.0`;
@@ -39,6 +49,6 @@ export function nextVersion(last, bump) {
return null;
}
-export function computeVersion(last, subjects) {
+export function computeVersion(last: string, subjects: string[]): string | null {
return nextVersion(last, computeBump(subjects));
}
diff --git a/scripts/release/draft-notes.mjs b/scripts/release/draft-notes.mjs
deleted file mode 100644
index f461ab5..0000000
--- a/scripts/release/draft-notes.mjs
+++ /dev/null
@@ -1,42 +0,0 @@
-// Pure: conventional-commit subjects -> a releases.mjs entry object. No I/O.
-//
-// Maps commit types to Keep-a-Changelog sections, rewrites each subject to a
-// verb-led past-tense sentence in the landing house style, and derives a
-// title/subtitle. The result is editable after the fact (entry IDs are
-// preserved by build-releases.mjs), so an imperfect auto-title is fine.
-import { parseConventional } from "./compute-version.mjs";
-
-const SECTION_BY_TYPE = { feat: "added", fix: "fixed", perf: "changed", refactor: "changed" };
-
-export function toHouseStyle(description) {
- let s = description.trim().replace(/\s*—\s*/g, ", ").replace(/!+/g, "");
- s = s.charAt(0).toUpperCase() + s.slice(1);
- if (!/[.]$/.test(s)) s += ".";
- return s;
-}
-
-export function draftNotes(subjects, { version, date }) {
- const buckets = { added: [], changed: [], fixed: [] };
- for (const subj of subjects) {
- const parsed = parseConventional(subj);
- const section = SECTION_BY_TYPE[parsed.type];
- if (!section) continue; // chore/docs/test/ci/build/style are not user-facing
- buckets[section].push(toHouseStyle(parsed.description));
- }
- const lead =
- buckets.added[0] ?? buckets.changed[0] ?? buckets.fixed[0] ?? "Maintenance release.";
- const counts = [];
- for (const k of ["added", "changed", "fixed"]) {
- if (buckets[k].length) counts.push(`${buckets[k].length} ${k}`);
- }
- const entry = {
- version,
- date,
- title: lead.replace(/\.$/, ""),
- subtitle: counts.join(", ") + ".",
- };
- for (const k of ["added", "changed", "fixed"]) {
- if (buckets[k].length) entry[k] = buckets[k];
- }
- return entry;
-}
diff --git a/scripts/release/draft-notes.ts b/scripts/release/draft-notes.ts
new file mode 100644
index 0000000..d9c76d0
--- /dev/null
+++ b/scripts/release/draft-notes.ts
@@ -0,0 +1,60 @@
+// Pure: conventional-commit subjects -> a releases.mjs entry object. No I/O.
+//
+// Maps commit types to Keep-a-Changelog sections, rewrites each subject to a
+// verb-led past-tense sentence in the landing house style, and derives a
+// title/subtitle. The result is editable after the fact (entry IDs are
+// preserved by build-releases.mjs), so an imperfect auto-title is fine.
+import { parseConventional } from "./compute-version.ts";
+
+export interface ReleaseEntry {
+ version: string;
+ date: string;
+ title: string;
+ subtitle: string;
+ added?: string[];
+ changed?: string[];
+ fixed?: string[];
+ removed?: string[];
+}
+
+type Section = "added" | "changed" | "fixed";
+
+const SECTION_BY_TYPE: Record = {
+ feat: "added",
+ fix: "fixed",
+ perf: "changed",
+ refactor: "changed",
+};
+
+export function toHouseStyle(description: string): string {
+ let s = description.trim().replace(/\s*—\s*/g, ", ").replace(/!+/g, "");
+ s = s.charAt(0).toUpperCase() + s.slice(1);
+ if (!/[.]$/.test(s)) s += ".";
+ return s;
+}
+
+export function draftNotes(
+ subjects: string[],
+ { version, date }: { version: string; date: string },
+): ReleaseEntry {
+ const buckets: Record = { added: [], changed: [], fixed: [] };
+ for (const subj of subjects) {
+ const parsed = parseConventional(subj);
+ const section = parsed.type ? SECTION_BY_TYPE[parsed.type] : undefined;
+ if (!section) continue; // chore/docs/test/ci/build/style are not user-facing
+ buckets[section].push(toHouseStyle(parsed.description));
+ }
+ const sections: Section[] = ["added", "changed", "fixed"];
+ const lead =
+ buckets.added[0] ?? buckets.changed[0] ?? buckets.fixed[0] ?? "Maintenance release.";
+ const counts: string[] = [];
+ for (const k of sections) if (buckets[k].length) counts.push(`${buckets[k].length} ${k}`);
+ const entry: ReleaseEntry = {
+ version,
+ date,
+ title: lead.replace(/\.$/, ""),
+ subtitle: counts.join(", ") + ".",
+ };
+ for (const k of sections) if (buckets[k].length) entry[k] = buckets[k];
+ return entry;
+}
diff --git a/scripts/release/lint-notes.mjs b/scripts/release/lint-notes.ts
similarity index 70%
rename from scripts/release/lint-notes.mjs
rename to scripts/release/lint-notes.ts
index 54a0b95..4b10c4a 100644
--- a/scripts/release/lint-notes.mjs
+++ b/scripts/release/lint-notes.ts
@@ -4,6 +4,19 @@
// em-dashes into commas, strip exclamation marks) and ABORTS on the rest
// (lowercase start, emoji, marketing verbs) so a clumsy auto-drafted line
// can never reach users' "What's new" feed unattended.
+
+export interface Violation {
+ rule: string;
+ fixable: boolean;
+}
+
+export interface NoteViolation extends Violation {
+ field: string;
+ index: number;
+}
+
+type NoteSection = "added" | "changed" | "fixed" | "removed";
+
const BANNED = [
"seamlessly",
"effortlessly",
@@ -19,8 +32,8 @@ const BANNED = [
const EMOJI =
/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}\u{FE00}-\u{FE0F}]/u;
-export function lintBullet(text) {
- const v = [];
+export function lintBullet(text: string): Violation[] {
+ const v: Violation[] = [];
if (!/^[A-Z]/.test(text)) v.push({ rule: "capitalized-start", fixable: false });
if (!/[.]$/.test(text)) v.push({ rule: "ends-with-period", fixable: true });
if (/—/.test(text)) v.push({ rule: "no-em-dash", fixable: true });
@@ -32,9 +45,12 @@ export function lintBullet(text) {
return v;
}
-export function lintNotes(entry) {
- const out = [];
- for (const field of ["added", "changed", "fixed", "removed"]) {
+export function lintNotes(
+ entry: Partial>,
+): NoteViolation[] {
+ const out: NoteViolation[] = [];
+ const fields: NoteSection[] = ["added", "changed", "fixed", "removed"];
+ for (const field of fields) {
(entry[field] ?? []).forEach((text, index) => {
for (const v of lintBullet(text)) out.push({ field, index, ...v });
});
diff --git a/tests/scripts/release/compute-version.test.ts b/tests/scripts/release/compute-version.test.ts
index c14bd58..2eb3736 100644
--- a/tests/scripts/release/compute-version.test.ts
+++ b/tests/scripts/release/compute-version.test.ts
@@ -4,7 +4,7 @@ import {
computeBump,
nextVersion,
computeVersion,
-} from "../../../scripts/release/compute-version.mjs";
+} from "../../../scripts/release/compute-version.ts";
describe("parseConventional", () => {
it("parses type, scope, breaking, description", () => {
diff --git a/tests/scripts/release/draft-notes.test.ts b/tests/scripts/release/draft-notes.test.ts
index 2e07a6b..917aef0 100644
--- a/tests/scripts/release/draft-notes.test.ts
+++ b/tests/scripts/release/draft-notes.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { toHouseStyle, draftNotes } from "../../../scripts/release/draft-notes.mjs";
+import { toHouseStyle, draftNotes } from "../../../scripts/release/draft-notes.ts";
describe("toHouseStyle", () => {
it("capitalizes, ensures terminal period, replaces em-dash, drops exclamation", () => {
diff --git a/tests/scripts/release/lint-notes.test.ts b/tests/scripts/release/lint-notes.test.ts
index 16a375f..9a95adc 100644
--- a/tests/scripts/release/lint-notes.test.ts
+++ b/tests/scripts/release/lint-notes.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { lintBullet, lintNotes } from "../../../scripts/release/lint-notes.mjs";
+import { lintBullet, lintNotes } from "../../../scripts/release/lint-notes.ts";
describe("lintBullet", () => {
it("passes a clean past-tense bullet", () =>
@@ -14,8 +14,14 @@ describe("lintBullet", () => {
fixable: false,
}));
it("flags marketing verbs and emoji (fatal)", () => {
- expect(lintBullet("Seamlessly improved sync.").some((v) => v.rule === "no-marketing-verb")).toBe(true);
- expect(lintBullet("Added sparkle ✨.").some((v) => v.rule === "no-emoji")).toBe(true);
+ expect(
+ lintBullet("Seamlessly improved sync.").some(
+ (v: { rule: string }) => v.rule === "no-marketing-verb",
+ ),
+ ).toBe(true);
+ expect(
+ lintBullet("Added sparkle ✨.").some((v: { rule: string }) => v.rule === "no-emoji"),
+ ).toBe(true);
});
});