fix(release): correct highlight parsing, card layout and gate reliability

Seven defects found by an adversarial pass over the new tooling, each with
a regression test:

- `highlight:` was silently truncated at the first ` #`. The comment strip
  was written when every field was a slug, enum or number; it now applies
  only to those closed-vocabulary keys. `highlight: Sources #N chip` kept
  parsing as "Sources" and shipped that to all four surfaces.
- Quote stripping removed a leading or trailing quote independently, so
  `highlight: "Up Next" rail` lost its opening quote. Both ends must now
  carry the same quote character.
- A whitespace-only quoted value passed validation and then crashed the
  card generator mid-run, after PNGs were already written. Values are
  trimmed after unquoting, so it fails validation instead; the hero card
  also no longer indexes wrapText's empty result blindly.
- Feature cards painted the opaque screenshot frame over the last body
  line whenever the headline wrapped to two lines. The body's line budget
  is now derived from the space actually left above the frame.
- Un-highlighted breaking changes were folded into "…plus N more fixes and
  improvements" on Telegram and never shown. They are always spelled out.
- Both CLI guards compared a non-realpath'd argv[1] against a realpath'd
  import.meta.url, so reaching either script through a symlinked path (on
  macOS, anything under /tmp or /var) made it a silent exit-0 no-op — which
  for the publication gate reads exactly like a pass.
- spawnSync reports a missing gh binary and a signalled child as
  `status: null`, both of which were blamed on the build.

The highlight cap drops from 80 to 60 characters: 60 is the hero card's
single-line budget, so a valid highlight now always renders in full rather
than being silently ellipsized.

Docs: new docs/architecture/release-pipeline.md carries the full contract —
surfaces, the ordering constraint around --consume, highlight semantics,
card layout rules, and the 27-asset draft verification table. release-cut
now references it instead of restating the asset list (474/500 words), and
CLAUDE.md, AGENTS.md and .changes/README.md point at it; the README's
release sequence no longer omits the announcement and card steps.

172 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-27 09:17:18 +02:00
1 parent 4b30e0613a
commit 9bd1fa54b0
16 files changed
+462 -69

No files matched your search

+8 -3
View File
@@ -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
+12 -11
View File
@@ -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/<vX-Y>/`. 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`.
+1 -1
View File
@@ -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.
+12 -11
View File
@@ -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/<vX-Y>/`. 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`.
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -66,10 +66,10 @@ This file provides guidance to coding agents working in this repository.
- Name it `<area>-<short-slug>.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: <short headline>` (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: <short headline>` (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/<vX-Y>/`. 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.
+2 -2
View File
@@ -32,10 +32,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Name it `<area>-<short-slug>.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: <short headline>` (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: <short headline>` (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/<vX-Y>/`. 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.
+163
View File
@@ -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/<area>-<slug>.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/<vX-Y>-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/<vX-Y>/` |
| Screenshots | `release:screenshots` | `apps/website/public/blog/<vX-Y>/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 (<area>)`.
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
```
+18 -4
View File
@@ -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) => {
+31 -6
View File
@@ -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(
`<circle cx="74" cy="${y - 10}" r="6" fill="${BRAND.accentBright}"/>`
);
// 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(
`<text x="100" y="${y}" font-family="${FONT_STACK}" font-size="30" font-weight="600" fill="${BRAND.text}">${escapeXml(wrapText(headline, 60, 1)[0])}</text>`
`<text x="100" y="${y}" font-family="${FONT_STACK}" font-size="30" font-weight="600" fill="${BRAND.text}">${escapeXml(line)}</text>`
);
});
+38
View File
@@ -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(/<text[^>]*\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',
+10 -6
View File
@@ -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 [
@@ -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' })], {
+47 -16
View File
@@ -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())];
}
/**
+67 -2
View File
@@ -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/
);
});
+32 -4
View File
@@ -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) => {