mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
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:
1 parent
4b30e0613a
commit
9bd1fa54b0
16 files changed
+462
-69
No files matched your search
+8
-3
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' })], {
|
||||
|
||||
@@ -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())];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user