From ffe96586c79e315d1e147cd1d9a6c0e006a52627 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Mon, 25 May 2026 19:00:38 +0200 Subject: [PATCH] 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 --- .github/workflows/release.yml | 42 +++++++--- CHANGELOG.md | 127 ++++++++++++++++++++++++++++ CLAUDE.md | 16 ++-- CONTRIBUTING.md | 41 +++++---- package.json | 1 + scripts/release.sh | 151 ++++++++++++++++++++++++++++++++++ 6 files changed, 343 insertions(+), 35 deletions(-) create mode 100644 CHANGELOG.md create mode 100755 scripts/release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f65c41..e41be13 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,23 +21,41 @@ jobs: - run: npm ci - # The tag is the source of truth for a release version. main always carries - # a `-develop` pre-release suffix, so strip it here (in the ephemeral CI - # checkout only — never committed) so the built bundle reports the bare - # X.Y.Z. Guard against tagging the wrong commit: the tag's base must match - # package.json's base version. - - name: Align package.json version to the tag + # The tagged commit is the release: `npm run release` commits the bare + # X.Y.Z to it (main otherwise carries a `-develop` suffix). Verify the tag + # matches that committed version exactly — this catches tagging the wrong + # commit (e.g. a `-develop` one) without rewriting anything. + - name: Verify package.json matches the tag env: TAG_NAME: ${{ github.ref_name }} run: | VERSION="${TAG_NAME#v}" - PKG_BASE="$(node -p 'require("./package.json").version.split("-")[0]')" - if [ "$VERSION" != "$PKG_BASE" ]; then - echo "Tag $TAG_NAME (base $VERSION) does not match package.json base ($PKG_BASE)." >&2 - echo "Tag the commit whose package.json is ${VERSION}-develop." >&2 + PKG="$(node -p 'require("./package.json").version')" + if [ "$VERSION" != "$PKG" ]; then + echo "Tag $TAG_NAME does not match package.json ($PKG)." >&2 + echo "The tagged commit must carry the bare release version ($VERSION)." >&2 + echo "Cut releases with: npm run release $VERSION" >&2 exit 1 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 @@ -59,5 +77,5 @@ jobs: TAG_NAME: ${{ github.ref_name }} BUNDLE_PATH: ${{ steps.bundle.outputs.path }} 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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..978addb --- /dev/null +++ b/CHANGELOG.md @@ -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/` 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 `` 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 diff --git a/CLAUDE.md b/CLAUDE.md index dcface8..1a24518 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -203,20 +203,24 @@ MSW (`msw/node`) handles external HTTP mocks. Tests that hit validation paths in ## 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. -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). -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. +```bash +npm run release X.Y.Z # next dev cycle defaults to next minor +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 **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` - `INSTALL.md` (setup, deployment, and configuration guide) - `setup.sh` (if setup/deploy assumptions changed) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e84a53b..47ba32b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 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 -mistaken for a shipped one — `0.3.0-develop` sorts _below_ `0.3.0` per SemVer, -meaning "heading toward 0.3.0, not yet released". +`-develop` pre-release suffix (e.g. `0.4.0-develop`) so a dev build is never +mistaken for a shipped one — `0.4.0-develop` sorts _below_ `0.4.0` per SemVer, +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 -`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`): +**Cut releases with one command — never by hand:** ```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 -npm version <next>-develop --no-git-tag-version # e.g. 0.4.0-develop (or 0.3.1-develop for a patch line) -# commit + push -``` +1. promotes the `## [Unreleased]` section of `CHANGELOG.md` to `## [X.Y.Z]`, +2. commits the **bare** `X.Y.Z` to `main` (a real release commit) and tags it, +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 diff --git a/package.json b/package.json index a908d2f..dede7f7 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit && npm run typecheck:client", "typecheck:client": "tsc -p src/scripts/client/tsconfig.json --noEmit", + "release": "bash scripts/release.sh", "prepare": "husky && npm run build:client" }, "lint-staged": { diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..f311cef --- /dev/null +++ b/scripts/release.sh @@ -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."