mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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:
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Executable
+151
@@ -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."
|
||||||
Reference in New Issue
Block a user