feat(release): render a branded changelog thumbnail in the release ritual

Add a 1200x630 changelog thumbnail as a fourth release artifact, generated
from a shared brand template that mirrors the marketing-site hero / OG image:
warm cream to lavender wash, ink dot-grid, an Instrument Serif headline with a
hand-drawn violet squiggle, a segmented 'CHANGELOG | <version>' sticker badge,
and colored ink-bordered theme chips with solid offset shadows.

- .claude/release-assets/: render-thumbnail.mjs (Playwright renderer),
  thumbnail.template.html (brand template), logo.png (TryPost wordmark)
- .claude/commands/release.md: new Step 5b renders the thumbnail; it is
  versioned in the artifacts PR alongside the changelog and email
- releases/v1.0.6/thumbnail.png: image for the latest release
This commit is contained in:
Paulo Castellano 2026-07-18 16:56:38 -03:00
parent 501de51327
commit 805a628f66
5 changed files with 269 additions and 13 deletions

View file

@ -3,13 +3,14 @@
allowed-tools: Bash, Write, Read, Skill allowed-tools: Bash, Write, Read, Skill
--- ---
You are running the Friday release ritual for TryPost. Three artifacts are produced: You are running the Friday release ritual for TryPost. Four artifacts are produced:
1. A git tag (semver) 1. A git tag (semver)
2. A GitHub release with the **auto-generated** changelog (PR list + authors via GitHub's native generator — flat, technical, for developers) 2. A GitHub release with the **auto-generated** changelog (PR list + authors via GitHub's native generator — flat, technical, for developers)
3. A **customer-facing email draft** in Cal.com style (themed prose, end-user voice, no commit/PR references) 3. A **customer-facing email draft** in Cal.com style (themed prose, end-user voice, no commit/PR references)
4. A **changelog thumbnail** (1200×630 PNG) rendered from the email's headline and themes, using the TryPost brand template
Plus local mirrors in `releases/<version>/`. Plus local mirrors in `releases/<version>/` (changelog, email, and thumbnail), versioned via the artifacts PR.
**Always confirm with the user before any push/tag/release.** **Always confirm with the user before any push/tag/release.**
@ -189,18 +190,45 @@ ### Step 5 — Humanize the email prose
The humanizer skill itself covers all patterns. Trust it. The humanizer skill itself covers all patterns. Trust it.
### Step 5b — Render the changelog thumbnail
Derive these inputs from the release. The **headline and the chips play different roles — never make one restate the other**:
- **version** — always pass the release version (e.g. `v1.0.6`). It is stamped in the badge as a mono segment (`★ CHANGELOG | v1.0.6`) so every release image is consistent. Not optional.
- **headline** — a crafted marketing hero line (2-6 words, at most two lines), in the voice of the marketing site's hero (`Run your social media on autopilot`). It sells the *benefit* of the release's biggest wins; it is the loudest thing on the image. Do **NOT** paste the subject line, and do **NOT** just list the theme/chip words — the chips already name the areas, so the headline sits one level above them. Read the email's themes **and** the "New features" bullets, find the strongest story, and write a fresh benefit line. A little rhythm helps (a parallel pair reads well, e.g. `Speak every language, reach every reader`). Sentence case, no version number in the headline itself (it lives in the badge), no period.
- ❌ `Your language, mobile, and per-image alt text` (this is just the chip labels)
- ✅ `Speak every language, reach every reader` (benefit-driven, distinct from the chips)
- **underline** — a short emphasis phrase *inside* the headline (1-3 words) to carry the hand-drawn violet squiggle, usually the last / most important phrase (e.g. `every reader`). Must appear in the headline verbatim. Optional; omit for no squiggle.
- **themes** — 2-4 short chip labels naming the concrete areas that shipped, condensed to 1-2 words each (e.g. `Languages`, `Mobile`, `Alt text & previews`). These are secondary supporting labels, rendered smaller than the headline; the template auto-colors them (violet / green / sky / orange / rose, in order). Keep them concrete and distinct from the headline's wording.
Create the directory and render the thumbnail so the user can preview it before confirming:
```bash
mkdir -p releases/<version>
node .claude/release-assets/render-thumbnail.mjs \
--version "<version>" \
--headline "<headline>" \
--underline "<emphasis phrase>" \
--themes "<label 1>,<label 2>,<label 3>" \
--out releases/<version>/thumbnail.png
```
This uses the shared brand template (`.claude/release-assets/thumbnail.template.html`) + TryPost logo. It mirrors the marketing-site hero / OG image: a warm cream→lavender wash, an ink dot-grid, an Instrument Serif headline with a hand-drawn violet squiggle under the emphasis phrase, an amber "Changelog" sticker badge with the version stamped in mono, and colored ink-bordered theme chips with solid offset shadows. 1200×630. It needs Playwright + chromium (already installed for browser tests). If the render fails, report and stop before tagging.
### Step 6 — Confirm with the user ### Step 6 — Confirm with the user
Show: Show:
1. **Proposed version** (e.g., `v1.0.9 → v1.1.0` — sequential rollover at 9). 1. **Proposed version** (e.g., `v1.0.9 → v1.1.0` — sequential rollover at 9).
2. **Changelog preview** (Step 3 output). 2. **Changelog preview** (Step 3 output).
3. **Email preview**: subject line + full body (post-humanizer). 3. **Email preview**: subject line + full body (post-humanizer).
4. **Files that will be created/pushed**: 4. **Thumbnail**: `releases/<version>/thumbnail.png` (already rendered in Step 5b — tell the user they can open it to preview).
5. **Files that will be created/pushed**:
- Tag `<version>` (pushed to origin) - Tag `<version>` (pushed to origin)
- GitHub release `<version>` - GitHub release `<version>`
- `releases/<version>/changelog.md` - `releases/<version>/changelog.md`
- `releases/<version>/email.md` - `releases/<version>/email.md`
- Branch `chore/release-<version>-artifacts` + a PR versioning the two files above - `releases/<version>/thumbnail.png`
- Branch `chore/release-<version>-artifacts` + a PR versioning the three files above
Then ask: **"Create the tag, publish the release, and open the PR with the artifacts?"** Then ask: **"Create the tag, publish the release, and open the PR with the artifacts?"**
@ -210,36 +238,37 @@ ### Step 7 — Execute
After confirmation, in this exact order. Steps 46 (tag + release) run from `main` so the tag stays on the released `main` commit; steps 78 version the artifacts on a separate branch and open a PR. After confirmation, in this exact order. Steps 46 (tag + release) run from `main` so the tag stays on the released `main` commit; steps 78 version the artifacts on a separate branch and open a PR.
1. Create local directory: `mkdir -p releases/<version>` 1. Create local directory: `mkdir -p releases/<version>` (already created in Step 5b).
2. Write `releases/<version>/changelog.md` with the Step 3 content (raw GitHub markdown). 2. Write `releases/<version>/changelog.md` with the Step 3 content (raw GitHub markdown).
3. Write `releases/<version>/email.md` with frontmatter + humanized body. 3. Write `releases/<version>/email.md` with frontmatter + humanized body.
(`releases/<version>/thumbnail.png` was already rendered in Step 5b.)
4. From `main`, create the annotated tag: `git tag -a <version> -m "Release <version>"` 4. From `main`, create the annotated tag: `git tag -a <version> -m "Release <version>"`
5. Push tag: `git push origin <version>` 5. Push tag: `git push origin <version>`
6. Create the GitHub release using the changelog file as body: 6. Create the GitHub release using the changelog file as body:
```bash ```bash
gh release create <version> --title "<version>" --notes-file releases/<version>/changelog.md gh release create <version> --title "<version>" --notes-file releases/<version>/changelog.md
``` ```
7. Version the artifacts on a branch (the two files carry over from the working tree) and push: 7. Version the artifacts on a branch (the three files carry over from the working tree) and push:
```bash ```bash
git checkout -b chore/release-<version>-artifacts git checkout -b chore/release-<version>-artifacts
git add releases/<version>/changelog.md releases/<version>/email.md git add releases/<version>/changelog.md releases/<version>/email.md releases/<version>/thumbnail.png
git commit -m "chore(release): add <version> changelog + customer email artifacts" git commit -m "chore(release): add <version> changelog, customer email, and thumbnail artifacts"
git push -u origin chore/release-<version>-artifacts git push -u origin chore/release-<version>-artifacts
``` ```
8. Open the PR against `main` (body: what the two files are + a link to the GitHub release): 8. Open the PR against `main` (body: what the three files are + a link to the GitHub release):
```bash ```bash
gh pr create --base main --head chore/release-<version>-artifacts \ gh pr create --base main --head chore/release-<version>-artifacts \
--title "chore(release): <version> changelog + customer email artifacts" \ --title "chore(release): <version> changelog, customer email + thumbnail artifacts" \
--body "<short body the two artifact files + link to the release>" --body "<short body the three artifact files + link to the release>"
``` ```
9. Report to the user: 9. Report to the user:
- GitHub release URL (from `gh release` output) - GitHub release URL (from `gh release` output)
- PR URL (from `gh pr create` output) - PR URL (from `gh pr create` output)
- Local paths: `releases/<version>/changelog.md`, `releases/<version>/email.md` - Local paths: `releases/<version>/changelog.md`, `releases/<version>/email.md`, `releases/<version>/thumbnail.png`
### On failure ### On failure
- `git push origin <version>` fails: report the exact error, leave the local tag in place, do not retry destructively. - `git push origin <version>` fails: report the exact error, leave the local tag in place, do not retry destructively.
- `gh release create` fails: the tag is already pushed; tell the user they can recreate manually with `gh release create <version> --title "<version>" --notes-file releases/<version>/changelog.md`. - `gh release create` fails: the tag is already pushed; tell the user they can recreate manually with `gh release create <version> --title "<version>" --notes-file releases/<version>/changelog.md`.
- `git push` / `gh pr create` for the artifacts branch fails: the tag and GitHub release are already live; report the error and tell the user they can open the PR manually from `chore/release-<version>-artifacts`. - `git push` / `gh pr create` for the artifacts branch fails: the tag and GitHub release are already live; report the error and tell the user they can open the PR manually from `chore/release-<version>-artifacts`.
- `Skill` or `Write` failure during artifact prep: report and stop. Do not push the tag without the artifacts being prepared. - `Skill`, `Write`, or thumbnail render failure during artifact prep: report and stop. Do not push the tag without the artifacts being prepared.

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,99 @@
// Render a release changelog thumbnail (1200x630 PNG) from the shared template.
//
// Usage:
// node .claude/release-assets/render-thumbnail.mjs \
// --headline "Your language, mobile, and per-image alt text" \
// --underline "alt text" \
// --themes "Languages,Mobile,Alt text & previews" \
// --out releases/v1.0.6/thumbnail.png
//
// Optional:
// --version "v1.0.6" — stamped in the badge as a mono version segment.
// --badge "Changelog" (default) — the amber sticker label, top-right.
// --underline "phrase" — a phrase inside the headline to get the hand-drawn
// violet squiggle (mirrors the marketing-site hero).
//
// Playwright is resolved from the repo's node_modules, so the script works
// regardless of where it is invoked.
import { createRequire } from 'module';
import { readFileSync, writeFileSync, unlinkSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { tmpdir } from 'os';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '..', '..');
const require = createRequire(join(repoRoot, 'package.json'));
const { chromium } = require('playwright');
const args = {};
for (let i = 2; i < process.argv.length; i += 2) {
args[process.argv[i].replace(/^--/, '')] = process.argv[i + 1];
}
if (!args.headline || !args.out) {
console.error('usage: render-thumbnail.mjs --headline "..." --themes "a,b,c" --out path.png [--version "v1.0.6"] [--underline "phrase"] [--badge "Changelog"]');
process.exit(1);
}
const escapeHtml = (value) =>
String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
// Hand-drawn violet squiggle under an emphasis phrase — same path the hero uses.
const squiggle = (phrase) =>
`<span class="ul">${phrase}<svg class="squiggle" viewBox="0 0 200 12" preserveAspectRatio="none" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" aria-hidden="true"><path d="M 5 6 Q 25 0, 50 6 T 100 6 T 150 6 T 195 6" /></svg></span>`;
let headlineHtml = escapeHtml(args.headline);
if (args.underline) {
const escapedPhrase = escapeHtml(args.underline);
if (headlineHtml.includes(escapedPhrase)) {
headlineHtml = headlineHtml.replace(escapedPhrase, squiggle(escapedPhrase));
}
}
const themes = (args.themes ?? '')
.split(',')
.map((theme) => theme.trim())
.filter(Boolean);
const chips = themes
.map((theme) => `<span class="chip">${escapeHtml(theme)}</span>`)
.join('\n ');
const star = `<svg class="star" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2l2.9 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l7.1-1.01z" /></svg>`;
let badgeInner = `<span class="label">${star}${escapeHtml(args.badge ?? 'Changelog')}</span>`;
if (args.version) {
badgeInner += `<span class="ver">${escapeHtml(args.version)}</span>`;
}
const logoDataUri = `data:image/png;base64,${readFileSync(join(here, 'logo.png')).toString('base64')}`;
const html = readFileSync(join(here, 'thumbnail.template.html'), 'utf8')
.replace('{{LOGO}}', logoDataUri)
.replace('{{BADGE_INNER}}', badgeInner)
.replace('{{HEADLINE}}', headlineHtml)
.replace('{{CHIPS}}', chips);
const tmpFile = join(tmpdir(), `trypost-thumbnail-${process.pid}.html`);
writeFileSync(tmpFile, html);
const browser = await chromium.launch();
try {
const page = await browser.newPage({
viewport: { width: 1200, height: 630 },
deviceScaleFactor: 2,
});
await page.goto(`file://${tmpFile}`, { waitUntil: 'networkidle' });
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: args.out });
} finally {
await browser.close();
unlinkSync(tmpFile);
}
console.log('wrote', args.out);

View file

@ -0,0 +1,128 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Figtree:wght@300..900&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
<style>
/* TryPost brand thumbnail — mirrors the marketing-site hero / OG image:
warm cream + ink + signature violet, soft peach→lavender wash, ink
dot-grid, Instrument Serif headline with a hand-drawn violet squiggle,
and ink-bordered sticker cards with Gumroad-style solid offset shadows.
Tokens from trypost-site assets/css/tailwind.css. */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 1200px; height: 630px; }
body {
font-family: 'Figtree', ui-sans-serif, system-ui, sans-serif;
position: relative;
overflow: hidden;
background: #faf8f5;
color: #0a0a0a;
-webkit-font-smoothing: antialiased;
}
/* Broad peach (bottom-left) → lavender (top-right) wash. */
.wash {
position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(118deg,
rgba(254,215,170,0.34) 0%,
rgba(250,248,245,0) 40%,
rgba(250,248,245,0) 58%,
rgba(196,181,253,0.46) 100%);
}
/* Soft glow blobs — violet top-right, warm amber bottom-left. */
.blob { position: absolute; border-radius: 999px; pointer-events: none; filter: blur(36px); }
.blob-violet { top: -160px; right: -120px; width: 560px; height: 560px;
background: radial-gradient(circle, rgba(196,181,253,0.60) 0%, rgba(196,181,253,0) 70%); }
.blob-warm { bottom: -190px; left: -150px; width: 540px; height: 540px;
background: radial-gradient(circle, rgba(253,215,170,0.55) 0%, rgba(253,215,170,0) 70%); }
.blob-fuchsia { bottom: 60px; left: 120px; width: 320px; height: 320px;
background: radial-gradient(circle, rgba(245,208,254,0.35) 0%, rgba(245,208,254,0) 70%); }
/* Ink dot-grid (over cream, softened by the blobs). */
.grid {
position: absolute; inset: 0; pointer-events: none; opacity: 0.07;
background-image: radial-gradient(circle, #0a0a0a 1px, transparent 1px);
background-size: 28px 28px;
}
.frame {
position: relative; z-index: 1;
height: 100%; padding: 74px 84px;
display: flex; flex-direction: column; justify-content: space-between;
}
.top { display: flex; align-items: center; justify-content: space-between; }
.logo { height: 36px; width: auto; }
/* "Changelog" sticker — segmented ticket: amber label + mono version, one
ink-bordered pill with an offset shadow (mirrors the site's gold
"OPEN SOURCE" sticker). The version is always stamped for consistency. */
.badge {
display: inline-flex; align-items: stretch;
border: 2.5px solid #0a0a0a; border-radius: 10px;
box-shadow: 4px 4px 0 0 #0a0a0a; overflow: hidden;
}
.badge .label {
display: inline-flex; align-items: center; gap: 9px;
background: #fde68a; color: #0a0a0a;
padding: 10px 17px;
font-size: 15px; font-weight: 900;
text-transform: uppercase; letter-spacing: 0.16em;
}
.badge .star { width: 16px; height: 16px; display: block; }
.badge .ver {
display: inline-flex; align-items: center;
background: #ffffff; color: #0a0a0a;
padding: 10px 15px;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 15px; font-weight: 700; letter-spacing: 0.01em;
border-left: 2.5px solid #0a0a0a;
}
.body { max-width: 1000px; }
h1 {
font-family: 'Instrument Serif', Georgia, serif;
font-weight: 400; font-size: 88px; line-height: 1.04;
letter-spacing: -0.02em; color: #0a0a0a; text-wrap: balance;
}
/* Emphasis phrase carrying the hand-drawn squiggle. */
.ul { position: relative; display: inline-block; white-space: nowrap; }
.ul > .squiggle {
position: absolute; left: 0; right: 0; bottom: -0.16em;
width: 100%; height: 0.34em; color: #8b5cf6;
}
.themes { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }
/* Theme chips — colored sticker cards (brand pastels), ink border, offset
shadow, ink text, hand-placed tilt (echoes the floating platform / composer
stickers in the hero — bg-violet-300 ... text-foreground). Secondary to the
headline: smaller and lighter in weight. */
.chip {
padding: 10px 18px; border-radius: 10px;
color: #0a0a0a;
border: 2.5px solid #0a0a0a;
box-shadow: 3px 3px 0 0 #0a0a0a;
font-size: 18px; font-weight: 600;
}
.chip:nth-child(1) { background: #ddd6fe; transform: rotate(-2deg); } /* violet-200 */
.chip:nth-child(2) { background: #bbf7d0; transform: rotate(1.5deg); } /* green-200 */
.chip:nth-child(3) { background: #bae6fd; transform: rotate(-1deg); } /* sky-200 */
.chip:nth-child(4) { background: #fed7aa; transform: rotate(2deg); } /* orange-200 */
.chip:nth-child(5) { background: #fecdd3; transform: rotate(-1.5deg); }/* rose-200 */
</style>
</head>
<body>
<div class="wash"></div>
<div class="blob blob-violet"></div>
<div class="blob blob-warm"></div>
<div class="blob blob-fuchsia"></div>
<div class="grid"></div>
<div class="frame">
<div class="top">
<img class="logo" src="{{LOGO}}" alt="TryPost" />
<span class="badge">{{BADGE_INNER}}</span>
</div>
<div class="body">
<h1>{{HEADLINE}}</h1>
</div>
<div class="themes">{{CHIPS}}</div>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB