diff --git a/.changes/README.md b/.changes/README.md index a73a5972b..b6df9a890 100644 --- a/.changes/README.md +++ b/.changes/README.md @@ -32,7 +32,7 @@ of the current season, watch progress, and click-to-play inline. | `area` | yes | lowercase slug, same as the conventional-commit scope | | `issues` | no | issue numbers this closes — `[1187]` or `1187` | | `screenshot` | no | slug from `tools/release/screenshots.manifest.json` | -| `highlight` | no | short headline (max 80 chars) marking a release highlight | +| `highlight` | no | short headline (max 60 chars) marking a release highlight | There is **no version field**. The release version is chosen deliberately at release time, not derived from these files. @@ -52,7 +52,8 @@ The body is capped at 400 characters — depth belongs in the blog post. - ✅ "Series whose title carries a season marker no longer show the wrong season" `highlight` marks the change as one of the release's headline features and -gives it a short, poster-worthy name. Highlights lead the Telegram/Reddit +gives it a short, poster-worthy name. The 60-character cap is the hero card's +single-line budget, so a valid highlight always renders in full. Highlights lead the Telegram/Reddit announcements (everything else collapses into a "+N more" counter) and become ready-made section headings in the blog scaffold. Set it on the two or three changes worth announcing — a release where everything is a highlight has none. @@ -109,7 +110,11 @@ explanation on stderr, leave stdout empty, and exit 0 — the same shape `extract-changelog-section.mjs --public` uses for its empty public body. The release sequence is: bump the version → `release:notes:changelog` → -`release:notes:blog` → `--consume` → commit → tag → push. The tag build then +`release:notes:blog` → `release:screenshots` → `release:notes:telegram` / +`release:notes:reddit` → `release:cards:generate` → `--consume` → commit → tag +→ push → `release:verify:draft`. Everything reading `highlight:` comes before +`--consume`, because that step deletes the only copy of it. Full contract: +`docs/architecture/release-pipeline.md`. The tag build then extracts the new `CHANGELOG.md` section into the GitHub release body (`tools/release/extract-changelog-section.mjs`) and **fails the release** if the section is missing — a tag cut without the changelog step cannot silently diff --git a/.claude/skills/release-cut/SKILL.md b/.claude/skills/release-cut/SKILL.md index 0030a7094..3ac74118d 100644 --- a/.claude/skills/release-cut/SKILL.md +++ b/.claude/skills/release-cut/SKILL.md @@ -5,6 +5,8 @@ description: Use when preparing, cutting, tagging, publishing, or verifying an I # Release Cut +Full contract, asset table and rationale: `docs/architecture/release-pipeline.md`. + The tag workflow authors the public GitHub body with `node tools/release/extract-changelog-section.mjs --public "${VERSION}"`. Keep the full changelog, including internal notes, committed before tagging. @@ -30,17 +32,17 @@ pnpm run i18n:check 4. Capture required manifest screenshots only against mock servers: `pnpm nx run electron-backend:build-e2e`, then `pnpm run release:screenshots`. -5. Render announcement drafts and save the output outside the repository: +5. Render the announcement drafts, saving output outside the repository: `pnpm run release:notes:telegram` and `pnpm run release:notes:reddit`. - They must run before `--consume` — the `highlight:` metadata lives only in - the note files. Publishing them is manual and happens after the release. 6. Render highlight cards after the screenshots: - `pnpm run release:cards:generate` writes branded 1200×630 cards plus a - hero to `dist/release-highlight-cards//`. Review them; copy `hero.jpg` - into the blog post's asset directory if it should ship as the hero image. + `pnpm run release:cards:generate`. Review them; copy `hero.jpg` into the + blog post's asset directory if it should ship as the hero image. 7. Consume notes only after reviewing all generated output: `node tools/release/build-release-notes.mjs --consume`. +Steps 5 and 6 must precede `--consume`: `highlight:` exists only in the note +files it deletes. Publishing announcements is manual, after the release. + The consume command is the destructive boundary: it deletes the direct note files. Stage only release-owned files, including exact website post/assets and `git add -A -- .changes`, then commit and create the exact tag. @@ -63,11 +65,10 @@ git push upstream v0.25.1 Master and `v*` pushes can publish Docker images. The tag build creates a draft GitHub release. Run `pnpm run release:verify:draft` — it waits for the tag -build via `gh run watch`, then checks draft status, the authored body, and the -complete required asset set (macOS, Windows, DEB, RPM, Pacman, AppImage, Snap, -Flatpak, updater metadata, blockmaps, and -`linux-frame-copy-runtime-sources.tar.xz`). It is read-only and never -publishes. Still review the authored text and generated commits by eye. +build, then checks draft status, warns on an empty authored body, and verifies +the complete 27-asset set documented in `docs/architecture/release-pipeline.md`. +It is read-only, and fails on an already-published release. Still review the +authored text and generated commits by eye. After verification, manually publish the GitHub release. That publication automatically verifies its Snap assets and uploads them to `edge`. diff --git a/.claude/skills/release-notes/SKILL.md b/.claude/skills/release-notes/SKILL.md index bd2d825ba..ad87b9119 100644 --- a/.claude/skills/release-notes/SKILL.md +++ b/.claude/skills/release-notes/SKILL.md @@ -26,7 +26,7 @@ Stalker series now resume the correct episode. `type` is `breaking`, `feature`, `fix`, `perf`, or `internal`. Omit optional fields instead of inventing values. Never add a version or PR number. -`highlight` (max 80 characters, never on `internal`) names a headline feature: +`highlight` (max 60 characters, never on `internal`) names a headline feature: it leads the Telegram/Reddit announcement drafts and becomes the blog section heading. Reserve it for the two or three changes worth announcing. diff --git a/.codex/skills/release-cut/SKILL.md b/.codex/skills/release-cut/SKILL.md index 0030a7094..3ac74118d 100644 --- a/.codex/skills/release-cut/SKILL.md +++ b/.codex/skills/release-cut/SKILL.md @@ -5,6 +5,8 @@ description: Use when preparing, cutting, tagging, publishing, or verifying an I # Release Cut +Full contract, asset table and rationale: `docs/architecture/release-pipeline.md`. + The tag workflow authors the public GitHub body with `node tools/release/extract-changelog-section.mjs --public "${VERSION}"`. Keep the full changelog, including internal notes, committed before tagging. @@ -30,17 +32,17 @@ pnpm run i18n:check 4. Capture required manifest screenshots only against mock servers: `pnpm nx run electron-backend:build-e2e`, then `pnpm run release:screenshots`. -5. Render announcement drafts and save the output outside the repository: +5. Render the announcement drafts, saving output outside the repository: `pnpm run release:notes:telegram` and `pnpm run release:notes:reddit`. - They must run before `--consume` — the `highlight:` metadata lives only in - the note files. Publishing them is manual and happens after the release. 6. Render highlight cards after the screenshots: - `pnpm run release:cards:generate` writes branded 1200×630 cards plus a - hero to `dist/release-highlight-cards//`. Review them; copy `hero.jpg` - into the blog post's asset directory if it should ship as the hero image. + `pnpm run release:cards:generate`. Review them; copy `hero.jpg` into the + blog post's asset directory if it should ship as the hero image. 7. Consume notes only after reviewing all generated output: `node tools/release/build-release-notes.mjs --consume`. +Steps 5 and 6 must precede `--consume`: `highlight:` exists only in the note +files it deletes. Publishing announcements is manual, after the release. + The consume command is the destructive boundary: it deletes the direct note files. Stage only release-owned files, including exact website post/assets and `git add -A -- .changes`, then commit and create the exact tag. @@ -63,11 +65,10 @@ git push upstream v0.25.1 Master and `v*` pushes can publish Docker images. The tag build creates a draft GitHub release. Run `pnpm run release:verify:draft` — it waits for the tag -build via `gh run watch`, then checks draft status, the authored body, and the -complete required asset set (macOS, Windows, DEB, RPM, Pacman, AppImage, Snap, -Flatpak, updater metadata, blockmaps, and -`linux-frame-copy-runtime-sources.tar.xz`). It is read-only and never -publishes. Still review the authored text and generated commits by eye. +build, then checks draft status, warns on an empty authored body, and verifies +the complete 27-asset set documented in `docs/architecture/release-pipeline.md`. +It is read-only, and fails on an already-published release. Still review the +authored text and generated commits by eye. After verification, manually publish the GitHub release. That publication automatically verifies its Snap assets and uploads them to `edge`. diff --git a/.codex/skills/release-notes/SKILL.md b/.codex/skills/release-notes/SKILL.md index bd2d825ba..ad87b9119 100644 --- a/.codex/skills/release-notes/SKILL.md +++ b/.codex/skills/release-notes/SKILL.md @@ -26,7 +26,7 @@ Stalker series now resume the correct episode. `type` is `breaking`, `feature`, `fix`, `perf`, or `internal`. Omit optional fields instead of inventing values. Never add a version or PR number. -`highlight` (max 80 characters, never on `internal`) names a headline feature: +`highlight` (max 60 characters, never on `internal`) names a headline feature: it leads the Telegram/Reddit announcement drafts and becomes the blog section heading. Reserve it for the two or three changes worth announcing. diff --git a/AGENTS.md b/AGENTS.md index e45dbd3b6..1a5b8d9fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,10 +66,10 @@ This file provides guidance to coding agents working in this repository. - Name it `-.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time. - Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post. - `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body. -- `highlight: ` (max 80 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none. +- `highlight: ` (max 60 characters — the hero card's single-line budget — and rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none. - Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label. - CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt). -- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. +- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`. - Validate before finishing: `pnpm run release:notes:validate`. - Announcement drafts and highlight cards are built from the same notes: `pnpm run release:notes:telegram` and `pnpm run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards//`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically. - Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release. diff --git a/CLAUDE.md b/CLAUDE.md index e7109e7c5..b95fe6998 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,10 +32,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Name it `-.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time. - Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post. - `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body. -- `highlight: ` (max 80 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none. +- `highlight: ` (max 60 characters — the hero card's single-line budget — and rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none. - Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label. - CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt). -- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. +- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`. - Validate before finishing: `pnpm run release:notes:validate`. - Announcement drafts and highlight cards are built from the same notes: `pnpm run release:notes:telegram` and `pnpm run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards//`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically. - Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release. diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md new file mode 100644 index 000000000..88cedf581 --- /dev/null +++ b/docs/architecture/release-pipeline.md @@ -0,0 +1,163 @@ +# Release Pipeline + +How a release is assembled, from the note an author writes during an ordinary +PR to the draft GitHub release a human publishes. + +The agent-facing entry points are the `release-notes` skill (writing notes) and +the `release-cut` skill (running a release). This document is the contract they +reference: the asset set, the ordering constraints, and the reasons behind +them. The skills stay short on purpose; the detail lives here. + +## Two phases + +**During ordinary PRs** every user-visible change adds one +`.changes/-.md` note, written while the context is fresh. CI's +"Release note gate" enforces it. Format and field table: `.changes/README.md`. + +**At release time** `tools/release/build-release-notes.mjs` fans those notes +out into every surface, then deletes them. Nothing derives the version — it is +chosen deliberately by bumping `package.json`. + +## Surfaces built from one set of notes + +| Surface | Command | Writes | +| --- | --- | --- | +| `CHANGELOG.md` section | `release:notes:changelog` | the file | +| Website blog scaffold | `release:notes:blog` | `apps/website/src/content/blog/-release-notes.mdx` | +| GitHub release body | (tag build) | via `extract-changelog-section.mjs --public` | +| Telegram announcement | `release:notes:telegram` | stdout | +| Reddit announcement | `release:notes:reddit` | stdout | +| Highlight cards | `release:cards:generate` | `dist/release-highlight-cards//` | +| Screenshots | `release:screenshots` | `apps/website/public/blog//screenshots/` | + +### The ordering constraint that matters + +`build-release-notes.mjs --consume` is the destructive boundary: it deletes the +note files. The `CHANGELOG.md` keeps every entry's text, but **`highlight:` +lives only in the note files** and is not recoverable afterwards. Every surface +that reads it — both announcements and the cards — must therefore run before +`--consume`. The cards additionally need `release:screenshots` to have already +published its frames, since they composite them. + +## `highlight:` — what leads a release + +An optional note field naming one of the release's two or three headline +changes. It is rejected on `type: internal`, and capped at **60 characters** +because that is the hero card's single-line budget (`wrapText(headline, 60, 1)`) +— a longer value would be silently ellipsized on an image nobody re-reads. + +Highlights drive three behaviors: + +- **Telegram** leads with them and folds everything else into a "…plus N more" + counter. A `type: breaking` note is never folded, highlighted or not: + announcing a breaking change as "fixes and improvements" is worse than a + longer post. +- **Reddit** gives each one an `## Highlights` subsection, with the remaining + changes grouped below. +- **The blog scaffold** uses the highlight as a ready `###` heading instead of + emitting `TODO headline ()`. + +Prose fields keep `#`. `parseFrontmatterLine` strips trailing `# comment` text +only from closed-vocabulary fields (`type`, `area`, `issues`, `screenshot`), +whose values can never contain one — `highlight: Sources #N chip` is a headline. + +### Internal-only releases + +A release whose notes are all `type: internal` is a legal shape: the authored +GitHub body is empty and GitHub's generated commit list carries the detail. +Both announcement formats then print an explanation on stderr, leave stdout +empty, and exit 0 — the same shape `extract-changelog-section.mjs --public` +already uses for its empty public body. + +## Highlight cards + +`tools/release/highlight-cards.mjs` plans and lays out; `generate-highlight-cards.mjs` +renders through sharp. Output is 1200×630 (Open Graph), matching the website +palette in `apps/website/tailwind.config.mjs`. + +- One card per highlight, plus a release hero card written as both `hero.png` + and the `hero.jpg` the blog scaffold's frontmatter references. +- A highlight naming a `screenshot:` gets a framed screenshot strip along the + bottom; one without gets a typographic layout instead. The frame is opaque + and painted after the text, so the body's line budget is **derived from the + space left above it**, never assumed — a fixed count sliced the last line in + half whenever the headline wrapped to two lines. +- Card filenames come from the note filename, never the screenshot slug: two + highlights may legitimately share one manifest shot, and naming cards after + it made the second overwrite the first. + +Screenshots come only from the capture script running against the mock servers. +Never publish one taken from a real playlist or account — streams, logos and +metadata are copyrighted, and credentials must never reach a published image. + +Output lands in `dist/`, outside version control. Copying a card into the +website tree is a deliberate manual act. + +## Draft verification + +The `v*` tag build creates a **draft** GitHub release. +`pnpm run release:verify:draft` (`tools/release/verify-draft-release.mjs`) is +the gate that runs before a human publishes it. It is strictly read-only: it +never publishes, edits or deletes. + +1. **Find the run.** `gh run list` reports what is indexed *right now* — its + `--limit` caps how many runs come back, it does not wait for one to appear, + and a tag pushed seconds ago routinely is not indexed yet. The verifier + polls (10 attempts, 6 s apart) before concluding the tag was never pushed. +2. **Wait for it.** An in-progress run is streamed through + `gh run watch --exit-status`. A completed run with a non-success conclusion + fails immediately. A missing `gh` binary and an interrupted watch are + reported as themselves, not as a build failure — `spawnSync` surfaces both + as `status: null`. +3. **Check the release.** Draft status, a non-empty authored body (empty is a + warning, since an internal-only release legitimately has one), and the + complete asset set below. + +An already-published release still gets its asset report — auditing one after +the fact is useful — but **never a success exit**. Reporting a pass for a +pre-publication gate after publication would claim a boundary already crossed. + +### Required asset set + +27 assets, verified against a real complete matrix build. When the build matrix +in `.github/workflows/build-and-make.yaml` gains or loses a target, update +`requiredAssetRules()` in the same PR. + +| Platform | Assets | +| --- | --- | +| macOS | `-mac-{x64,arm64}.{dmg,zip}` + a `.blockmap` for each (8) | +| Windows | `-windows-x64-setup.exe` + `.blockmap` (2) | +| DEB | `-linux-{amd64,arm64,armv7l}.deb` (3) | +| AppImage | `-linux-{x86_64,arm64,armv7l}.AppImage` (3) | +| Snap | `-linux-{amd64,armhf}.snap` (2) | +| RPM | `-linux-x86_64.rpm` (1) | +| Flatpak | `-linux-x86_64.flatpak` (1) | +| Pacman | `-linux-x64.pacman` **or** `-linux-x86_64.pkg.tar.*` (1) | +| Updater metadata | `latest.yml`, `latest-mac.yml`, `latest-linux.yml`, `latest-linux-arm.yml`, `latest-linux-arm64.yml` (5) | +| Source compliance | `linux-frame-copy-runtime-sources.tar.xz` (1) | + +Electron Builder has shipped both pacman artifact shapes, so either satisfies +that rule. Rules compare plain strings rather than a regex built from the +version — `requiredAssetRules()` is exported, and escaping an interpolated +value correctly would be a standing trap. + +An asset no rule claims is reported as a `NOTE:` and does **not** fail the run: +a new build target should surface for a human to notice, not block a release +until the rules catch up. + +## After verification + +Publishing the GitHub release is manual. That publication automatically +verifies its Snap assets and uploads them to `edge`; installed-Snap smoke and +candidate/stable promotion remain manual (see +`tools/packaging/validate-snap-release-boundary.mjs`). Keep the blog post a +draft during artifact verification, then publish it in a follow-up commit and +verify the website deployment. + +## Validation + +```bash +pnpm run release:notes:validate # every note parses and satisfies the schema +pnpm nx run release-tools:test # the tooling's own unit tests +pnpm nx run release-tools:lint +``` diff --git a/tools/release/generate-highlight-cards.mjs b/tools/release/generate-highlight-cards.mjs index 9cf9f61fd..e775529af 100644 --- a/tools/release/generate-highlight-cards.mjs +++ b/tools/release/generate-highlight-cards.mjs @@ -14,7 +14,7 @@ * committing a card into the website tree is a deliberate manual act. */ -import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -259,9 +259,23 @@ async function main() { ); } -const isDirectRun = - process.argv[1] && - path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +// realpath on both sides: Node resolves symlinks for `import.meta.url` but +// not for argv[1], so reaching this script through a symlinked path would +// otherwise exit 0 having rendered nothing. +const isDirectRun = (() => { + if (!process.argv[1]) { + return false; + } + + try { + return ( + realpathSync(process.argv[1]) === + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +})(); if (isDirectRun) { main().catch((error) => { diff --git a/tools/release/highlight-cards.mjs b/tools/release/highlight-cards.mjs index 24ea5490a..3fef9c768 100644 --- a/tools/release/highlight-cards.mjs +++ b/tools/release/highlight-cards.mjs @@ -21,6 +21,14 @@ export const SHOT_TOP = 330; export const SHOT_LEFT = (CARD_WIDTH - SHOT_WIDTH) / 2; export const SHOT_RADIUS = 14; +/** Feature-card text block, laid out to always clear the screenshot frame. */ +const HEADLINE_TOP = 168; +const HEADLINE_LINE_HEIGHT = 58; +const BODY_LINE_HEIGHT = 32; +const BODY_GAP = 8; +/** Lowest permitted body baseline: the frame starts at SHOT_TOP - 2. */ +export const TEXT_BOTTOM = SHOT_TOP - 24; + const BRAND = { backgroundTop: '#0a0a08', backgroundBottom: '#141412', @@ -210,26 +218,39 @@ export function buildFeatureCardSvg(job, version) { if (job.screenshotPath) { const headline = wrapText(job.headline, 34, 2); - const body = wrapText(job.body, 88, 2); + // The body's line budget is derived from the space actually left above + // the screenshot frame, never assumed: the frame is opaque and painted + // after the text, so a fixed line count silently sliced the last line + // in half whenever the headline wrapped to two lines. + const bodyTop = + HEADLINE_TOP + headline.length * HEADLINE_LINE_HEIGHT + BODY_GAP; + const bodyLines = Math.max( + 1, + Math.min( + 3, + Math.floor((TEXT_BOTTOM - bodyTop) / BODY_LINE_HEIGHT) + 1 + ) + ); + const body = wrapText(job.body, 88, bodyLines); parts.push( textLines(headline, { x: 64, - y: 180, + y: HEADLINE_TOP, size: 52, weight: 800, fill: BRAND.text, - lineHeight: 62, + lineHeight: HEADLINE_LINE_HEIGHT, }) ); parts.push( textLines(body, { x: 64, - y: 180 + headline.length * 62, + y: bodyTop, size: 23, weight: 400, fill: BRAND.muted, - lineHeight: 32, + lineHeight: BODY_LINE_HEIGHT, }) ); // Frame stroke sits behind the composited screenshot; the strip is @@ -297,8 +318,12 @@ export function buildHeroCardSvg(hero) { parts.push( `` ); + // wrapText yields no lines for whitespace-only input; validation + // rejects that upstream, but a card run must not die half-written. + const [line = ''] = wrapText(headline, 60, 1); + parts.push( - `${escapeXml(wrapText(headline, 60, 1)[0])}` + `${escapeXml(line)}` ); }); diff --git a/tools/release/highlight-cards.test.mjs b/tools/release/highlight-cards.test.mjs index 9ffa6cb10..c141c0d3e 100644 --- a/tools/release/highlight-cards.test.mjs +++ b/tools/release/highlight-cards.test.mjs @@ -12,6 +12,8 @@ import { CARD_WIDTH, escapeXml, planHighlightCards, + SHOT_TOP, + TEXT_BOTTOM, wrapText, } from './highlight-cards.mjs'; import { @@ -208,6 +210,42 @@ describe('card SVGs', () => { assert.doesNotMatch(without, /fill="#2e2e28"/); }); + it('keeps every body line clear of the opaque screenshot frame', () => { + // A two-line headline plus a long body used to push the last body + // line under the frame, which is painted after the text. + const svg = buildFeatureCardSvg( + { + headline: 'Advanced subtitles with external files and styling', + body: 'External subtitle files load from disk, timing shifts in half-second steps, and you can set caption size and colour — the settings stick between episodes.', + screenshotPath: '/shots/x-dark.png', + }, + '0.24.0' + ); + const baselines = [...svg.matchAll(/]*\sy="(\d+)"/g)].map( + (match) => Number(match[1]) + ); + + assert.ok(baselines.length > 0); + for (const baseline of baselines) { + assert.ok( + baseline <= TEXT_BOTTOM, + `baseline ${baseline} overlaps the frame at ${SHOT_TOP - 2}` + ); + } + }); + + it('survives a whitespace-only headline instead of dying half-written', () => { + assert.doesNotThrow(() => + buildHeroCardSvg({ + fileName: 'hero.png', + version: '0.24.0', + releaseSlug: 'v0-24', + headlines: [' '], + counts: '1 feature', + }) + ); + }); + it('lists at most four highlights on the hero and counts the rest', () => { const svg = buildHeroCardSvg({ fileName: 'hero.png', diff --git a/tools/release/release-announcements.mjs b/tools/release/release-announcements.mjs index 714b0771f..1bd7d4787 100644 --- a/tools/release/release-announcements.mjs +++ b/tools/release/release-announcements.mjs @@ -47,7 +47,7 @@ function blogUrl(version) { * preserved inside both halves; `internal` is dropped entirely. * * @param {object[]} notes - * @returns {{ highlights: object[], rest: object[] }} + * @returns {{ ordered: object[], highlights: object[], rest: object[] }} */ export function splitHighlights(notes) { const ordered = groupNotes(notes) @@ -55,6 +55,7 @@ export function splitHighlights(notes) { .flatMap((group) => group.notes); return { + ordered, highlights: ordered.filter((note) => note.highlight), rest: ordered.filter((note) => !note.highlight), }; @@ -76,9 +77,13 @@ function moreLine(count) { * or null for an internal-only release with nothing public to announce */ export function renderTelegramPost(notes, { version }) { - const { highlights, rest } = splitHighlights(notes); - const lead = highlights.length > 0 ? highlights : rest; + const { ordered, highlights, rest } = splitHighlights(notes); const leadIsHighlights = highlights.length > 0; + // A breaking change is never folded into the counter, highlighted or not: + // announcing one as "fixes and improvements" is worse than a longer post. + const lead = leadIsHighlights + ? ordered.filter((note) => note.highlight || note.type === 'breaking') + : rest; // An internal-only release is a legal shape — its authored GitHub body is // empty too — and there is simply nothing to announce publicly. @@ -101,9 +106,8 @@ export function renderTelegramPost(notes, { version }) { : `${emoji} ${body}`; }); - const hiddenCount = - (leadIsHighlights ? rest.length : 0) + - (lead.length - visibleLead.length); + // Everything public that this post does not spell out. + const hiddenCount = ordered.length - visibleLead.length; const more = hiddenCount > 0 ? moreLine(hiddenCount) : null; return [ diff --git a/tools/release/release-announcements.test.mjs b/tools/release/release-announcements.test.mjs index 538a1c728..c106b5372 100644 --- a/tools/release/release-announcements.test.mjs +++ b/tools/release/release-announcements.test.mjs @@ -126,6 +126,24 @@ describe('renderTelegramPost', () => { assert.match(post, /…plus \d+ more fixes and improvements\./); }); + it('never folds a breaking change into the counter', () => { + const post = renderTelegramPost( + [ + note({ highlight: 'Up Next rail' }), + note({ + type: 'breaking', + body: 'Legacy playlist storage is removed.', + }), + note({ type: 'fix', body: 'Stalker resume works again.' }), + ], + { version: '0.24.0' } + ); + + assert.match(post, /⚠️ Legacy playlist storage is removed\./); + assert.match(post, /…plus 1 more fix or improvement\./); + assert.doesNotMatch(post, /Stalker resume/); + }); + it('returns null for an internal-only release instead of throwing', () => { assert.equal( renderTelegramPost([note({ type: 'internal' })], { diff --git a/tools/release/release-notes.mjs b/tools/release/release-notes.mjs index 8b1c751c1..b2e4a3035 100644 --- a/tools/release/release-notes.mjs +++ b/tools/release/release-notes.mjs @@ -40,8 +40,12 @@ const DELIMITER = '---'; /** Keeps entries to a sentence or three; essays belong in the blog post. */ const MAX_BODY_LENGTH = 400; -/** A highlight is a headline, not a paragraph. */ -const MAX_HIGHLIGHT_LENGTH = 80; +/** + * A highlight is a headline, not a paragraph — and the limit is the hero + * card's single-line budget (`wrapText(headline, 60, 1)`). Anything longer is + * silently ellipsized there, so it is rejected at the source instead. + */ +const MAX_HIGHLIGHT_LENGTH = 60; /** * Splits a note into its frontmatter lines and body. @@ -70,36 +74,63 @@ function splitFrontmatter(content) { }; } +/** + * Free-form fields, where `#` is ordinary punctuation rather than a comment + * marker. Everything else in this schema is a closed vocabulary — enum, slug, + * issue numbers — whose values can never contain one. + */ +const PROSE_KEYS = new Set(['highlight']); + +/** + * Strips a surrounding quote pair. Both ends must carry the SAME quote + * character: `"Up Next" rail` is prose containing quotes, and stripping its + * ends independently would silently eat the opening one. + * + * @param {string} value + * @returns {string} + */ +function unquote(value) { + const quote = value[0]; + const isQuoted = + value.length >= 2 && + (quote === "'" || quote === '"') && + value.endsWith(quote); + + return (isQuoted ? value.slice(1, -1) : value).trim(); +} + /** * Parses a single `key: value` frontmatter line. * - * Trailing `# comment` text is stripped: no value in this schema (enum slug, - * area slug, issue numbers) can legitimately contain `#`, and the documented - * examples carry explanatory comments. + * Trailing `# comment` text is stripped from closed-vocabulary fields, whose + * documented examples carry explanatory comments. Prose fields keep it — + * `highlight: Sources #N chip` is a headline, not a commented-out value, and + * stripping there truncated the headline on every announcement surface. * * @param {string} line - * @returns {[string, string] | null} key/value pair, or null for blank lines + * @returns {[string, string] | null} key/value pair, or null for blank and + * whole-line comment lines */ function parseFrontmatterLine(line) { - const withoutComment = line.replace(/\s+#.*$/, '').trim(); + const trimmed = line.trim(); - if (!withoutComment) { + if (!trimmed || trimmed.startsWith('#')) { return null; } - const separatorIndex = withoutComment.indexOf(':'); + const separatorIndex = trimmed.indexOf(':'); if (separatorIndex === -1) { - throw new Error(`frontmatter line is not \`key: value\`: "${line.trim()}"`); + throw new Error(`frontmatter line is not \`key: value\`: "${trimmed}"`); } - const key = withoutComment.slice(0, separatorIndex).trim(); - const value = withoutComment - .slice(separatorIndex + 1) - .trim() - .replace(/^['"]|['"]$/g, ''); + const key = trimmed.slice(0, separatorIndex).trim(); + const rawValue = trimmed.slice(separatorIndex + 1); + const value = PROSE_KEYS.has(key) + ? rawValue + : rawValue.replace(/\s+#.*$/, ''); - return [key, value]; + return [key, unquote(value.trim())]; } /** diff --git a/tools/release/release-notes.test.mjs b/tools/release/release-notes.test.mjs index 0b5c729d9..61d30915c 100644 --- a/tools/release/release-notes.test.mjs +++ b/tools/release/release-notes.test.mjs @@ -119,6 +119,68 @@ describe('parseNote', () => { assert.equal(note().highlight, null); }); + it('keeps `#` inside a highlight but still strips comments elsewhere', () => { + const parsed = parseNote( + [ + '---', + 'type: feature # one of feature | fix | perf', + 'area: portal', + 'highlight: Sources #N chip for alternative copies', + '---', + 'Body.', + ].join('\n'), + 'x.md' + ); + + assert.equal(parsed.type, 'feature'); + assert.equal( + parsed.highlight, + 'Sources #N chip for alternative copies' + ); + }); + + it('skips whole-line comments', () => { + const parsed = parseNote( + ['---', '# a note to the author', 'type: fix', 'area: m3u', '---', 'B.'].join( + '\n' + ), + 'x.md' + ); + + assert.equal(parsed.type, 'fix'); + assert.deepEqual(parsed.unknownKeys, []); + }); + + it('only strips a balanced quote pair', () => { + const parse = (value) => + parseNote( + ['---', 'type: feature', 'area: p', `highlight: ${value}`, '---', 'B.'].join( + '\n' + ), + 'x.md' + ).highlight; + + assert.equal(parse('"Up Next" rail'), '"Up Next" rail'); + assert.equal(parse('"Up Next rail"'), 'Up Next rail'); + assert.equal(parse("'Up Next rail'"), 'Up Next rail'); + assert.equal(parse('"'), '"'); + }); + + it('reduces a whitespace-only quoted value to empty', () => { + const parsed = parseNote( + ['---', 'type: feature', 'area: p', 'highlight: " "', '---', 'B.'].join( + '\n' + ), + 'x.md' + ); + + assert.equal(parsed.highlight, ''); + assert.match( + validateNote(parsed)[0], + /`highlight` is present but empty/ + ); + }); + it('records unknown keys instead of dropping them silently', () => { const parsed = parseNote( ['---', 'type: fix', 'area: m3u', 'scope: m3u', '---', 'Body.'].join( @@ -201,9 +263,12 @@ describe('validateNote', () => { validateNote(note({ highlight: '' }))[0], /`highlight` is present but empty/ ); + // The cap is the hero card's single-line budget, so a valid highlight + // always renders in full on every surface. + assert.deepEqual(validateNote(note({ highlight: 'x'.repeat(60) })), []); assert.match( - validateNote(note({ highlight: 'x'.repeat(81) }))[0], - /max 80/ + validateNote(note({ highlight: 'x'.repeat(61) }))[0], + /max 60/ ); }); diff --git a/tools/release/verify-draft-release.mjs b/tools/release/verify-draft-release.mjs index f890c7b8f..5f4884cd0 100644 --- a/tools/release/verify-draft-release.mjs +++ b/tools/release/verify-draft-release.mjs @@ -18,7 +18,7 @@ */ import { execFileSync, spawnSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -340,6 +340,19 @@ const liveIo = { { stdio: 'inherit' } ); + // spawnSync reports a missing binary and a signalled child as + // `status: null` rather than throwing. Blaming the build for either + // would send the release manager after a build that is fine. + if (result.error) { + throw new Error(`could not run gh: ${result.error.message}`); + } + + if (result.signal) { + throw new Error( + `gh run watch was interrupted (${result.signal}) — run ${runId} was not judged` + ); + } + if (result.status !== 0) { throw new Error( `tag build run ${runId} failed — fix the build before verifying assets` @@ -398,9 +411,24 @@ async function main() { process.exit(exitCode); } -const isDirectRun = - process.argv[1] && - path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +// realpath on both sides: Node resolves symlinks for `import.meta.url` but +// not for argv[1], so a checkout reached through a symlinked path (macOS +// /tmp and /var are symlinks) made this publication gate a silent exit-0 +// no-op — which reads exactly like a pass. +const isDirectRun = (() => { + if (!process.argv[1]) { + return false; + } + + try { + return ( + realpathSync(process.argv[1]) === + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +})(); if (isDirectRun) { main().catch((error) => {