chore(release): add CHANGELOG and scripted release pipeline

Introduce CHANGELOG.md (Keep a Changelog) as the single source of release
notes, and scripts/release.sh (npm run release X.Y.Z) which promotes the
Unreleased section, commits the bare version as a real release commit, tags
it, and reopens the next -develop cycle. The Release workflow now verifies the
tagged commit's version equals the tag and publishes the CHANGELOG section as
the release notes instead of auto-generated commit lists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-25 19:00:38 +02:00
parent 3242f0e3f1
commit ffe96586c7
6 changed files with 343 additions and 35 deletions
+30 -12
View File
@@ -21,23 +21,41 @@ jobs:
- run: npm ci - run: npm ci
# The tag is the source of truth for a release version. main always carries # The tagged commit is the release: `npm run release` commits the bare
# a `-develop` pre-release suffix, so strip it here (in the ephemeral CI # X.Y.Z to it (main otherwise carries a `-develop` suffix). Verify the tag
# checkout only — never committed) so the built bundle reports the bare # matches that committed version exactly — this catches tagging the wrong
# X.Y.Z. Guard against tagging the wrong commit: the tag's base must match # commit (e.g. a `-develop` one) without rewriting anything.
# package.json's base version. - name: Verify package.json matches the tag
- name: Align package.json version to the tag
env: env:
TAG_NAME: ${{ github.ref_name }} TAG_NAME: ${{ github.ref_name }}
run: | run: |
VERSION="${TAG_NAME#v}" VERSION="${TAG_NAME#v}"
PKG_BASE="$(node -p 'require("./package.json").version.split("-")[0]')" PKG="$(node -p 'require("./package.json").version')"
if [ "$VERSION" != "$PKG_BASE" ]; then if [ "$VERSION" != "$PKG" ]; then
echo "Tag $TAG_NAME (base $VERSION) does not match package.json base ($PKG_BASE)." >&2 echo "Tag $TAG_NAME does not match package.json ($PKG)." >&2
echo "Tag the commit whose package.json is ${VERSION}-develop." >&2 echo "The tagged commit must carry the bare release version ($VERSION)." >&2
echo "Cut releases with: npm run release $VERSION" >&2
exit 1 exit 1
fi fi
npm version "$VERSION" --no-git-tag-version --allow-same-version
# Release notes come from the CHANGELOG section for this version, which is
# written incrementally and reviewed in PRs — never hand-typed at release.
- name: Extract release notes from CHANGELOG
env:
TAG_NAME: ${{ github.ref_name }}
run: |
VERSION="${TAG_NAME#v}"
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" {grab=1; next}
/^## \[/ && grab {exit}
grab { if (!started && $0 ~ /^[[:space:]]*$/) next; started=1; print }
' CHANGELOG.md > release-notes.md
if [ ! -s release-notes.md ]; then
echo "No CHANGELOG section found for $VERSION." >&2
exit 1
fi
echo "Release notes for $VERSION:"
cat release-notes.md
- run: npm run build - run: npm run build
@@ -59,5 +77,5 @@ jobs:
TAG_NAME: ${{ github.ref_name }} TAG_NAME: ${{ github.ref_name }}
BUNDLE_PATH: ${{ steps.bundle.outputs.path }} BUNDLE_PATH: ${{ steps.bundle.outputs.path }}
run: | run: |
gh release create "$TAG_NAME" --generate-notes --verify-tag || true gh release create "$TAG_NAME" --notes-file release-notes.md --verify-tag || true
gh release upload "$TAG_NAME" "$BUNDLE_PATH" --clobber gh release upload "$TAG_NAME" "$BUNDLE_PATH" --clobber
+127
View File
@@ -0,0 +1,127 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Keep the `## [Unreleased]` section up to date **as part of every change** (the
same rule as the rest of the docs). At release time `npm run release X.Y.Z`
promotes this section to `## [X.Y.Z]` and the Release workflow publishes it
verbatim as the GitHub Release notes — so what you write here is what ships.
## [Unreleased]
### Fixed
- Feed self link (RSS/Atom/JSON) is derived from the configured domain instead
of the request host — it no longer leaks the `workers.dev` host when a feed is
reached directly, and now matches the alternate link.
## [0.3.0] - 2026-05-25
### Added
- **Native feed detection** — incoming newsletters are inspected for a
self-advertised Atom/RSS/JSON feed (`rel=alternate` links in the email HTML);
discovered feeds are stored per sender on the Feed aggregate and surfaced as
chips on the feed detail page, a dashboard pill, and (read-only) on the REST
`Feed` schema, with a dismissable notice.
- **Subscription confirmation surfacing** — confirmation emails ("click to
confirm your subscription") are detected at ingestion and flagged on the feed;
the admin UI surfaces the confirmation link, a badge, a dashboard pill, and an
inline banner (all dismissable), tightened against false positives via a
weak-signal heuristic.
- **JSON Feed 1.1** output (`/json/:feedId`).
- **OPML export** of all feeds (`/admin/opml`).
- **Conditional GET** (ETag / Last-Modified / 304) on the feed routes.
- Per-feed **Subscribe chips** for RSS/Atom/JSON with copy / open / validate
actions, reused across dashboard and feed detail page.
- Email detail page links to its public entry page; land on the feed's emails
page right after creation.
- Optional **per-feed "sender in title"** toggle.
- Running **version** shown in the admin/status footer, `/health`, and
`/api/v1/stats`.
### Changed
- **Read/write identity decoupling (privacy)** — the public read id (`FeedId`,
used in `/rss/:feedId`) is fully decoupled from the inbound email address
(`MailboxId`, `noun.noun.NN`); a feed's read URL never reveals its inbound
alias and vice-versa (reading `/rss/<noun.noun.NN>` 404s).
- Sender display name, site URL and parsing now owned by the `EmailAddress`
value object (DDD cleanup).
- Release version is derived from the git tag; CI guards against tagging the
wrong commit.
## [0.2.1] - 2026-05-24
### Added
- Optional `FALLBACK_FORWARD_ADDRESS`: forward non-feed mail to a verified
address so a domain catch-all can point at kill-the-news without swallowing
personal mail (forwarded mail is counted in the stats dashboard).
### Changed
- Feed, entry, and attachment responses send `X-Robots-Tag: noindex`; a new
`/robots.txt` disallows `/rss`, `/atom`, `/entries`, `/files`, and `/admin`
private feeds and emails stay out of search engines.
- Relative links/images in email bodies are absolutized against the sender's
site; lazy-loaded images are promoted so they don't render blank.
- Feed `<title>` is plain text (HTML stripped, entities decoded).
- Sender-site derivation moved onto the `EmailAddress` value object
(`siteBaseUrl`).
### Fixed
- XML-illegal control characters are stripped from generated feeds (valid astral
characters such as emoji preserved).
## [0.2.0] - 2026-05-24
### Added
- Versioned REST API (`/api/v1/feeds*`) with an OpenAPI 3.1 spec
(`/api/openapi.json`) and rendered reference docs via Scalar (`/api/docs`).
- `/api/v1/stats` as the canonical public stats endpoint (JSON + CORS).
- Optional R2 attachment storage with a config toggle, storage metrics, download
links on the email/admin views, and inline `cid:` image rendering.
- Project favicon (`/favicon.svg`, `/favicon.ico`) and per-feed favicon derived
from the last sender's domain (`/favicon/:feedId`).
- RFC 8058 one-click unsubscribe dispatched when a feed is deleted.
### Changed
- Large internal refactor toward a clean domain-driven architecture; redesigned
landing/status page.
### Removed
- The deprecated `/api/stats` endpoint (use `/api/v1/stats`).
## [0.1.0] - 2026-05-22
### Added
- **Atom feed format** (`/atom/:feedId`) alongside RSS 2.0.
- **WebSub push notifications** advertised via `Link` header for real-time
delivery instead of polling.
- **HTML email processing** — bodies sanitized via `linkedom` + `escape-html`
(XSS prevention, MSO style stripping, plain-text fallback).
- **Email attachments as RSS enclosures**, stored in R2 and served at
`/files/:attachmentId/:filename`.
- **Sender blocklist** with 4-level priority matching and a quick-add dropdown.
- **`EMAIL_DOMAIN`** env var to separate web domain and email domain.
- **Authelia / reverse-proxy auth** via trusted headers (`Remote-User`,
`X-Forwarded-User`).
- Demo environment auto-deployed to `demo.kill-the.news` with a nightly KV
reset.
- Admin UI redesign (Inter font, orange theme), client scripts compiled via
esbuild, templates on `hono/jsx`.
[Unreleased]: https://github.com/juherr/kill-the-news/compare/v0.3.0...HEAD
[0.3.0]: https://github.com/juherr/kill-the-news/compare/v0.2.1...v0.3.0
[0.2.1]: https://github.com/juherr/kill-the-news/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/juherr/kill-the-news/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/juherr/kill-the-news/releases/tag/v0.1.0
+10 -6
View File
@@ -203,20 +203,24 @@ MSW (`msw/node`) handles external HTTP mocks. Tests that hit validation paths in
## Releasing (read before cutting a release) ## Releasing (read before cutting a release)
`package.json` `version` is inlined at build time as `APP_VERSION` (`src/config/version.ts`) and surfaced in the admin/status footer, `/health`, and `/api/v1/stats`. **`main` always carries a `-develop` pre-release suffix** (e.g. `0.3.0-develop`) so a dev build is never mistaken for a shipped one. `package.json` `version` is inlined at build time as `APP_VERSION` (`src/config/version.ts`) and surfaced in the admin/status footer, `/health`, and `/api/v1/stats`. **`main` always carries a `-develop` pre-release suffix** (e.g. `0.4.0-develop`) so a dev build is never mistaken for a shipped one.
When asked to "release X.Y.Z", the **git tag is the source of truth** — do **not** commit a bare `X.Y.Z` to `main`: When asked to "release X.Y.Z", **run the script — never tag/bump/write notes by hand**:
1. Confirm `main`'s `package.json` reads `X.Y.Z-develop` (its base must match the release). If you're bumping the target, that's a separate `-develop` bump. ```bash
2. `git tag vX.Y.Z && git push origin vX.Y.Z` — the Release workflow (`.github/workflows/release.yml`) strips the `-develop` suffix in its ephemeral checkout, builds the bundle reporting the bare `X.Y.Z`, and publishes the GitHub Release. It **fails fast** if the tag base ≠ `package.json` base (wrong-commit guard). npm run release X.Y.Z # next dev cycle defaults to next minor
3. After the release, reopen the next cycle: `npm version <next>-develop --no-git-tag-version` on `main` (next minor by default, or `X.Y.Z+1-develop` for a patch line), then commit + push. npm run release X.Y.Z A.B.C # ...or pass an explicit next dev base (e.g. a patch line)
```
Full flow lives in [CONTRIBUTING.md](CONTRIBUTING.md) under "Releasing". `X.Y.Z` must equal `main`'s current `X.Y.Z-develop` base. `scripts/release.sh` guards (clean tree, on `main`, synced with origin, version match, **non-empty `## [Unreleased]`**), then atomically: promotes `CHANGELOG.md`'s `## [Unreleased]``## [X.Y.Z]`, commits the **bare** `X.Y.Z` as a real release commit, tags it, opens the next `-develop` cycle (fresh `## [Unreleased]` + bump), and pushes `main` + the tag after a confirmation prompt.
The `v*` tag triggers the Release workflow (`.github/workflows/release.yml`), which **verifies** the tagged commit's `package.json` equals the tag exactly (wrong/`-develop`-commit guard), builds, and publishes a GitHub Release whose notes are the `## [X.Y.Z]` CHANGELOG section. **Release notes are never hand-typed** — they come from `CHANGELOG.md`, which you keep current under `## [Unreleased]` as part of every change (treat it like the other docs). Full flow in [CONTRIBUTING.md](CONTRIBUTING.md) under "Releasing".
## When changing behavior ## When changing behavior
**Always document evolutions** — treat docs as part of the change, not a follow-up. When you add or change a feature, update the relevant docs in the same change: **Always document evolutions** — treat docs as part of the change, not a follow-up. When you add or change a feature, update the relevant docs in the same change:
- `CHANGELOG.md` — add a bullet under `## [Unreleased]` for any user-facing change (this is what the next release notes are built from; never deferred to release time)
- `README.md` - `README.md`
- `INSTALL.md` (setup, deployment, and configuration guide) - `INSTALL.md` (setup, deployment, and configuration guide)
- `setup.sh` (if setup/deploy assumptions changed) - `setup.sh` (if setup/deploy assumptions changed)
+24 -17
View File
@@ -76,29 +76,36 @@ Common types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`.
The running version is read from `package.json` `version` and inlined at build The running version is read from `package.json` `version` and inlined at build
time (footer, `/health`, `/api/v1/stats`). `main` **always** carries a time (footer, `/health`, `/api/v1/stats`). `main` **always** carries a
`-develop` pre-release suffix (e.g. `0.3.0-develop`) so a dev build is never `-develop` pre-release suffix (e.g. `0.4.0-develop`) so a dev build is never
mistaken for a shipped one — `0.3.0-develop` sorts _below_ `0.3.0` per SemVer, mistaken for a shipped one — `0.4.0-develop` sorts _below_ `0.4.0` per SemVer,
meaning "heading toward 0.3.0, not yet released". meaning "heading toward 0.4.0, not yet released".
**The git tag is the source of truth for a release version**, not a commit on **Cut releases with one command — never by hand:**
`main`. The Release workflow (`.github/workflows/release.yml`) triggers on a
`v*` tag, strips the `-develop` suffix in its ephemeral checkout so the published
bundle reports the bare `X.Y.Z`, then builds and creates the GitHub Release. It
fails fast if the tag's base doesn't match `package.json`'s base version, which
catches tagging the wrong commit. You never commit a bare `X.Y.Z` to `main`.
To cut release `X.Y.Z` (its base must equal `main`'s current `X.Y.Z-develop`):
```bash ```bash
git tag vX.Y.Z && git push origin vX.Y.Z # the workflow aligns + builds + publishes npm run release X.Y.Z # next dev cycle defaults to the next minor
npm run release X.Y.Z A.B.C # ...or pass an explicit next dev base (e.g. a patch line)
``` ```
Then reopen the next cycle on `main`: `X.Y.Z` must equal `main`'s current `X.Y.Z-develop` base. The script
(`scripts/release.sh`) guards (clean tree, on `main`, in sync with `origin`,
version match, non-empty changelog), then in one shot:
```bash 1. promotes the `## [Unreleased]` section of `CHANGELOG.md` to `## [X.Y.Z]`,
npm version <next>-develop --no-git-tag-version # e.g. 0.4.0-develop (or 0.3.1-develop for a patch line) 2. commits the **bare** `X.Y.Z` to `main` (a real release commit) and tags it,
# commit + push 3. opens the next `-develop` cycle (a fresh `## [Unreleased]` + bumped version),
``` 4. pushes `main` + the tag (after showing you the notes and asking to confirm).
The `v*` tag triggers the Release workflow (`.github/workflows/release.yml`),
which **verifies** the tagged commit's `package.json` equals the tag exactly
(catching a wrong or `-develop` commit), builds the bundle, and publishes a
GitHub Release whose notes are the `## [X.Y.Z]` section of `CHANGELOG.md` — so the
changelog you maintained in-repo is what ships. Keep `## [Unreleased]` up to date
**as part of every change**; the release notes are never hand-typed.
If you ever release manually, the tagged commit must carry the bare `X.Y.Z` in
`package.json` and the matching `## [X.Y.Z]` section must exist in
`CHANGELOG.md` — the workflow fails fast otherwise.
## Reporting bugs and requesting features ## Reporting bugs and requesting features
+1
View File
@@ -18,6 +18,7 @@
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit && npm run typecheck:client", "typecheck": "tsc --noEmit && npm run typecheck:client",
"typecheck:client": "tsc -p src/scripts/client/tsconfig.json --noEmit", "typecheck:client": "tsc -p src/scripts/client/tsconfig.json --noEmit",
"release": "bash scripts/release.sh",
"prepare": "husky && npm run build:client" "prepare": "husky && npm run build:client"
}, },
"lint-staged": { "lint-staged": {
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env bash
#
# Cut a release. Usage:
#
# npm run release X.Y.Z [NEXT_DEV_BASE]
#
# X.Y.Z the version to release (must equal main's current X.Y.Z-develop base)
# NEXT_DEV_BASE optional base to open next (defaults to next minor, e.g. 0.4.0 -> 0.5.0)
#
# It guards, then in one shot:
# 1. promotes CHANGELOG "## [Unreleased]" -> "## [X.Y.Z] - <date>"
# 2. sets package.json to the bare X.Y.Z and commits the release commit
# 3. tags vX.Y.Z on that commit
# 4. opens the next "-develop" cycle (package.json + fresh Unreleased) and commits
# 5. pushes main + the tag (after an explicit confirmation) -> triggers the Release workflow
#
# The tag points at a commit whose package.json reads exactly X.Y.Z, so the
# published bundle and the git history agree on the version. CI verifies the
# match and publishes the promoted CHANGELOG section as the release notes.
set -euo pipefail
die() {
echo "release: $*" >&2
exit 1
}
semver_re='^[0-9]+\.[0-9]+\.[0-9]+$'
VERSION="${1:-}"
[ -n "$VERSION" ] || die "missing version. Usage: npm run release X.Y.Z [NEXT_DEV_BASE]"
[[ "$VERSION" =~ $semver_re ]] || die "version '$VERSION' is not X.Y.Z"
# Default next dev base: bump the minor, reset patch.
if [ -n "${2:-}" ]; then
NEXT_BASE="$2"
[[ "$NEXT_BASE" =~ $semver_re ]] || die "next dev base '$NEXT_BASE' is not X.Y.Z"
else
IFS='.' read -r MA MI _PA <<<"$VERSION"
NEXT_BASE="${MA}.$((MI + 1)).0"
fi
NEXT_DEV="${NEXT_BASE}-develop"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
# --- Guards ----------------------------------------------------------------
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
[ "$BRANCH" = "main" ] || die "must be on 'main' (currently on '$BRANCH')"
[ -z "$(git status --porcelain)" ] || die "working tree is not clean — commit or stash first"
git fetch --quiet origin main || die "could not fetch origin/main"
LOCAL="$(git rev-parse @)"
REMOTE="$(git rev-parse '@{u}')"
[ "$LOCAL" = "$REMOTE" ] || die "local main is not in sync with origin/main — pull/push first"
PKG_BASE="$(node -p 'require("./package.json").version.split("-")[0]')"
[ "$PKG_BASE" = "$VERSION" ] || die "package.json base is $PKG_BASE, expected $VERSION — bump main to ${VERSION}-develop first (or release $PKG_BASE)"
git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null && die "tag v$VERSION already exists"
[ -f CHANGELOG.md ] || die "CHANGELOG.md not found"
# The Unreleased section must carry content — an empty changelog ships empty notes.
UNRELEASED_BODY="$(awk '
/^## \[Unreleased\]/ {grab=1; next}
/^## / && grab {exit}
grab {print}
' CHANGELOG.md | grep -v '^[[:space:]]*$' || true)"
[ -n "$UNRELEASED_BODY" ] || die "CHANGELOG '## [Unreleased]' is empty — write the release notes there first"
# --- Plan ------------------------------------------------------------------
DATE="$(date +%Y-%m-%d)"
echo "Release plan:"
echo " version : $VERSION (tag v$VERSION)"
echo " release date : $DATE"
echo " next cycle : $NEXT_DEV"
echo
echo "Unreleased notes that will become the v$VERSION release notes:"
echo "$UNRELEASED_BODY" | sed 's/^/ | /'
echo
read -r -p "Proceed (commits, tag, and PUSH to origin)? [y/N] " ANSWER
case "$ANSWER" in
y | Y | yes | YES) ;;
*) die "aborted" ;;
esac
# --- 1. Promote CHANGELOG Unreleased -> this version -----------------------
node - "$VERSION" "$DATE" <<'NODE'
const fs = require("fs");
const [version, date] = process.argv.slice(2);
const file = "CHANGELOG.md";
let text = fs.readFileSync(file, "utf8");
if (text.includes(`## [${version}]`)) {
console.error(`release: CHANGELOG already has a [${version}] section`);
process.exit(1);
}
// Replace the Unreleased heading with a fresh empty Unreleased + the new version.
text = text.replace(
/^## \[Unreleased\][^\n]*\n/m,
`## [Unreleased]\n\n## [${version}] - ${date}\n`,
);
// Refresh the link reference block at the bottom, if present.
const repo = "https://github.com/juherr/kill-the-news";
const unreleasedLink = `[Unreleased]: ${repo}/compare/v${version}...HEAD`;
if (/^\[Unreleased\]:/m.test(text)) {
text = text.replace(
/^\[Unreleased\]:.*$/m,
`${unreleasedLink}\n[${version}]: ${repo}/compare/PREV...v${version}`,
);
// Best-effort: point the new version diff at the previous tagged version.
const prev = [...text.matchAll(/^\[(\d+\.\d+\.\d+)\]:/gm)]
.map((m) => m[1])
.find((v) => v !== version);
if (prev) {
text = text.replace("compare/PREV...", `compare/v${prev}...`);
} else {
text = text.replace(`/compare/PREV...v${version}`, `/releases/tag/v${version}`);
}
}
fs.writeFileSync(file, text);
console.log(`Updated CHANGELOG.md for ${version}`);
NODE
# --- 2. Release commit (bare version) + 3. tag -----------------------------
npm version "$VERSION" --no-git-tag-version --allow-same-version >/dev/null
git add package.json package-lock.json CHANGELOG.md
git commit -m "chore(release): $VERSION" >/dev/null
git tag "v$VERSION"
echo "Committed release v$VERSION and tagged it."
# --- 4. Open the next develop cycle ----------------------------------------
npm version "$NEXT_DEV" --no-git-tag-version >/dev/null
git add package.json package-lock.json
git commit -m "chore: open $NEXT_BASE develop cycle" >/dev/null
echo "Opened next cycle: $NEXT_DEV."
# --- 5. Push ----------------------------------------------------------------
git push origin main "v$VERSION"
echo
echo "Pushed main + v$VERSION. The Release workflow will publish the GitHub Release."