diff --git a/.agents/skills/release-video b/.agents/skills/release-video new file mode 120000 index 000000000..a540e73ac --- /dev/null +++ b/.agents/skills/release-video @@ -0,0 +1 @@ +../../.claude/skills/release-video \ No newline at end of file diff --git a/.claude/skills/release-video/SKILL.md b/.claude/skills/release-video/SKILL.md new file mode 100644 index 000000000..3a90ed705 --- /dev/null +++ b/.claude/skills/release-video/SKILL.md @@ -0,0 +1,33 @@ +--- +name: release-video +description: Plans and renders the release video, one scene per feature shipped since the last release, with its music and interface sounds. Use when the user wants to prepare the release announcement video, change its music, or the release video check reports missing scenes. Not for publishing the video or writing release notes. +argument-hint: release version | music +--- + +# Release video + +```mermaid +flowchart LR + source([release]) --> plan --> render --> done([release-video.mp4]) + render -->|"scene to fix"| plan + track([music]) --> compose-music --> render +``` + +## Actions + +Run the flow above. Read only the next action file. + +| Action | Does | +| --- | --- | +| plan | cover every shipped feature with a scene, or a reason to skip it | +| render | turn the plan into the MP4 and check it on screen | +| compose-music | write a new track when the user asks for other music | + +## Transversal rules + +- The plan lives in `aidd_docs/release-video/scenes.json`; the check and the renderer are in `assets/`, their tests in `scripts/__tests__/`. +- Show only what the product does; mark every invented demo value as an example on screen. +- A viewer may watch with the sound off and never read the release notes: the picture carries the message, the sound only makes it lively. +- Never reuse the audio of a published video: it carries that video's own sounds, which then play at moments that mean nothing here. +- Keep product choices with the user, music included: it is judged by listening, which is the user's. +- Stop at the MP4: attaching it to a release and announcing it are the user's. diff --git a/.claude/skills/release-video/actions/01-plan.md b/.claude/skills/release-video/actions/01-plan.md new file mode 100644 index 000000000..91641523d --- /dev/null +++ b/.claude/skills/release-video/actions/01-plan.md @@ -0,0 +1,42 @@ +# 01 - Plan + +Cover every feature shipped since the last release with one scene, or one written reason. + +## Input + +The repository at the release candidate. + +## Output + +`aidd_docs/release-video/scenes.json` that `node .claude/skills/release-video/assets/check-release-video.js` accepts; the next step is [render](02-render.md). + +## Process + +1. **List.** Run the check. Each surface under "Scènes manquantes" needs a scene or a skip. +2. **Read.** Open the skill or command each surface names; take its real behavior, never a guess. +3. **Draft.** Fill the [plan template](../assets/scenes-template.json), one scene per surface, with the templates, rows and fields of [scenes.md](../references/scenes.md). + - Write every title, lead, tag and highlight in everyday words, outcome first: what changes for the user, not how it works. `Count tokens in Codex too` becomes `See what your AI costs, in Codex too`. + - Only the demo window shows real commands. +4. **Skip.** Move a surface to `skipped` with a reason only when a user would not notice it. +5. **Fixes.** List the `fix:` commits since the last release tag. A fix is worth a scene only when its before and after can be shown on screen; a fix that only makes figures or states more reliable has none. Group the ones that qualify into one `bullet-grid` scene; the usual outcome is no scene. +6. **Contributors.** Name who shipped the release with `gh api repos/{owner}/{repo}/compare/...next --jq '[.commits[].author.login] | unique'`. Drop bots, keep every human author whatever the type of the commit (a docs commit counts). Put the logins in one `contributors` scene, last. +7. **Intro.** Fill `intro.highlights`, the tags that name the release's big features, so that someone who never used AIDD sees at a glance what it brings: up to three, each a short clear sentence of five words at most, starting from what the user gets (`Telemetry now covers Codex`, not `Report by tool`). Leave `tagline` out: a phrase above the tags says less than they do. Take the facts from the release announcement, so the video and the message say the same thing. +8. **YouTube text.** Fill `youtube` in French, in the words of the published videos: `themes` name the release's themes, `programme` has one line per covered surface saying what it does, as in `compter ce que coûte votre travail, par tâche`. `assets/youtube-metadata.js` assembles the title and the description in their published layout. +9. **Judge.** Read the plan against [judge.md](../references/judge.md), which the check cannot do. +10. **Check.** Rerun the check until it passes, then show the plan and fold corrections. + +## Test + +| Case | Pass | +| --- | --- | +| Uncovered feature | the check names it; the plan gains a scene or a skip | +| Skip without reason | the check fails; a reason is added | +| Release with no `feat:` commit | no video; plan left empty | +| Invented demo value | marked as an example in the scene | +| `fix:` commits a user would notice | one grouped scene; internal ones left out | +| Contributor list | bots dropped; one `contributors` scene, last | +| Scene without a lead | refused on review: a viewer could not tell what it brings | +| Internal word or text over the limits | the check fails naming the word or the field and where it is | +| Theme split by another one | the check fails naming the theme; its scenes are brought together | +| Surface with no `programme` line, or a title over 100 characters | the check fails naming it | +| Plan read against the judge list | every rule answered, a failing scene rewritten or removed | diff --git a/.claude/skills/release-video/actions/02-render.md b/.claude/skills/release-video/actions/02-render.md new file mode 100644 index 000000000..e7dbee1a6 --- /dev/null +++ b/.claude/skills/release-video/actions/02-render.md @@ -0,0 +1,33 @@ +# 02 - Render + +Turn the accepted plan into the release video and look at it. + +## Input + +`aidd_docs/release-video/scenes.json` that the check accepts, and the version the release will carry, such as `5.13`: the next minor when a `feat:` shipped since the last tag, never a placeholder, because it is typed on screen. + +## Output + +`release-video.mp4` in the working directory. + +## Process + +1. **Check.** Run `node .claude/skills/release-video/assets/check-release-video.js`. On failure, return to [plan](01-plan.md). +2. **Render.** Run `node .claude/skills/release-video/assets/render-release-video.cjs --version --out release-video.mp4`. It needs `npx`, a network, ffmpeg and Chrome, and adds the music and the interface sounds of [sound.md](../references/sound.md). +3. **Look.** Extract one frame near the end of each scene with `ffmpeg -ss -i release-video.mp4 -frames:v 1` and read them. + - Fix overflow, cut text and wrong values in the plan, then render again. + - Judge each frame as a viewer with the sound off, who never read the release notes: the picture alone must say what the release brings. +4. **Measure.** Cut a second of the intro, of a scene and of the outro with `ffmpeg -ss -t 1 -i release-video.mp4 -af volumedetect -vn -f null -` and compare with the levels in [sound.md](../references/sound.md). You cannot listen: say it was measured, not heard. +5. **Report.** Give the path, the duration, and any avatar the script replaced by the logo. + +## Test + +| Case | Pass | +| --- | --- | +| Plan the check refuses | no render; back to plan | +| Account that no longer exists | video renders; the replaced avatar is reported | +| Text that does not fit a window | seen on a frame, fixed in the plan, rendered again | +| Version typed on screen | the real release version, not a placeholder | +| Frame read with the sound off | the picture alone says what the release brings | +| Levels measured | the hook is loud, the bed about 10 dB lower under the scenes and the outro, the ending no louder than the bed, the interface sounds stand out of the bed | +| Finished render | MP4 present, duration reported, nothing published | diff --git a/.claude/skills/release-video/actions/03-compose-music.md b/.claude/skills/release-video/actions/03-compose-music.md new file mode 100644 index 000000000..58a8fba59 --- /dev/null +++ b/.claude/skills/release-video/actions/03-compose-music.md @@ -0,0 +1,28 @@ +# 03 - Compose music + +Write a new original 8-bit track when the current one tires or does not fit. + +## Input + +What to change, in the user's words: slower, calmer, another mood. + +## Output + +`assets/music.mp3` in this skill, which every render then uses; the next step is [render](02-render.md). + +## Process + +1. **Shape.** Edit `assets/compose-music.py`: tempo, chords and levels, keeping the three parts of [sound.md](../references/sound.md). +2. **Compose.** Run `python3 .claude/skills/release-video/assets/compose-music.py `; it needs numpy. +3. **Encode.** Run `ffmpeg -i -b:a 128k .claude/skills/release-video/assets/music.mp3`. +4. **Hear.** Render per [render](02-render.md) and give the user the MP4 and the track to listen to. + - Keep the track only on the user's word. + +## Test + +| Case | Pass | +| --- | --- | +| Script run | a wav of about 44 seconds, with hook, bed and ending levels printed | +| Track used by a render | the measured levels keep the loud, calm, loud shape | +| Track from another video | refused: only an original composition is kept | +| User has not listened | the track is not declared good, only measured | diff --git a/.claude/skills/release-video/assets/check-release-video.js b/.claude/skills/release-video/assets/check-release-video.js new file mode 100644 index 000000000..e2f9d20c0 --- /dev/null +++ b/.claude/skills/release-video/assets/check-release-video.js @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/** + * Fails when a feature shipped since the last release has no scene in the release video, and + * no written reason for being left out. A release with no `feat:` commit needs no video. + * + * A surface is something a user can run: a plugin skill (`aidd-dev:10-batch`) or a CLI command + * (`aidd telemetry`). It is read from the files a `feat:` commit adds or modifies, so a + * rename shows the new name only. + */ + +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); +const { youtubeProblems } = require("./youtube-metadata.js"); + +const ROOT = path.resolve(__dirname, "../../../.."); +const PLAN_PATH = path.join(ROOT, "aidd_docs/release-video/scenes.json"); + +/** Files in the commands folder that are plumbing, not a command a user types. */ +const NOT_COMMANDS = new Set([ + "global-options", + "menu", + "spawn-cli-command", + "sync-native-activation", +]); + +const SKILL = /^plugins\/([^/]+)\/skills\/(\d{2}-[^/]+)\//; +const COMMAND = /^cli\/src\/presentation\/commands\/([^/]+)\.ts$/; + +function surfaceOf(file) { + const skill = SKILL.exec(file); + if (skill) return `${skill[1]}:${skill[2]}`; + const command = COMMAND.exec(file); + if (command && !NOT_COMMANDS.has(command[1])) return `aidd ${command[1]}`; + return null; +} + +function surfacesOf(files) { + const found = new Set(files.map(surfaceOf).filter(Boolean)); + return [...found].sort(); +} + +/** Where a surface lives, so a surface added then removed or renamed in the same cycle is dropped. */ +function pathOf(surface) { + if (surface.startsWith("aidd ")) { + return `cli/src/presentation/commands/${surface.slice(5)}.ts`; + } + const [plugin, skill] = surface.split(":"); + return `plugins/${plugin}/skills/${skill}`; +} + +function shown(plan) { + return new Set(plan.scenes.flatMap((scene) => scene.surfaces ?? [])); +} + +function missingScenes(surfaces, plan) { + const covered = shown(plan); + const skipped = new Set(plan.skipped.map((skip) => skip.surface)); + return surfaces.filter((s) => !covered.has(s) && !skipped.has(s)); +} + +function invalidSkips(plan) { + return plan.skipped + .filter((skip) => !String(skip.reason ?? "").trim()) + .map((skip) => skip.surface); +} + +/** Without GIT_DIR and the like: a hook sets them, and they would point git at the repository + * being committed instead of `cwd`. */ +const withoutGitEnv = () => + Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))); + +const MAX_TAGLINE_WORDS = 6; +const MAX_HIGHLIGHTS = 3; +const MAX_HIGHLIGHT_WORDS = 5; +const MAX_LEAD_WORDS = 14; + +const wordCount = (text) => String(text ?? "").trim().split(/\s+/).filter(Boolean).length; + +/** The first slide and each lead are read in a glance: a long one is refused, not trimmed. */ +function overlongText(plan) { + const found = []; + const { tagline, highlights = [] } = plan.intro ?? {}; + if (wordCount(tagline) > MAX_TAGLINE_WORDS) { + found.push(`intro tagline: ${wordCount(tagline)} words, ${MAX_TAGLINE_WORDS} at most`); + } + if (highlights.length > MAX_HIGHLIGHTS) { + found.push(`intro highlights: ${highlights.length}, ${MAX_HIGHLIGHTS} at most`); + } + highlights.forEach((text, i) => { + if (wordCount(text) > MAX_HIGHLIGHT_WORDS) { + found.push(`intro highlight ${i + 1}: ${wordCount(text)} words, ${MAX_HIGHLIGHT_WORDS} at most`); + } + }); + plan.scenes.forEach((scene, i) => { + if (wordCount(scene.lead) > MAX_LEAD_WORDS) { + found.push(`scene ${i + 1} lead: ${wordCount(scene.lead)} words, ${MAX_LEAD_WORDS} at most`); + } + }); + return found; +} + +/** Words only the people who build AIDD know. A viewer reads the outcome in plain words; the demo + * window may show the real commands. */ +const JARGON = /\b(tokens?|envelopes?|axis|transcripts?|payloads?|hooks?)\b/gi; + +function jargonIn(plan) { + const found = []; + const scan = (where, text) => { + for (const [word] of String(text ?? "").matchAll(JARGON)) found.push(`${where}: "${word.toLowerCase()}"`); + }; + const { tagline, highlights = [] } = plan.intro ?? {}; + scan("intro tagline", tagline); + highlights.forEach((text, i) => scan(`intro highlight ${i + 1}`, text)); + plan.scenes.forEach((scene, i) => { + for (const field of ["title", "accent", "lead"]) scan(`scene ${i + 1} ${field}`, scene[field]); + }); + return found; +} + +function git(cwd, ...args) { + return execFileSync("git", args, { cwd, encoding: "utf8", env: withoutGitEnv() }).trim(); +} + +/** Surfaces touched by `feat:` commits since the last release tag and still present at HEAD. */ +function featSurfaces(cwd = ROOT) { + const tag = git(cwd, "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"); + const log = git(cwd, "log", `${tag}..HEAD`, "--no-merges", "--format=%H%x09%s"); + const files = log + .split("\n") + .filter(Boolean) + .map((line) => line.split("\t")) + .filter(([, subject]) => /^feat(\([^)]*\))?!?:/.test(subject)) + .flatMap(([sha]) => + git(cwd, "show", "--name-only", "--diff-filter=AM", "--format=", sha).split("\n") + ); + return surfacesOf(files).filter((surface) => fs.existsSync(path.join(cwd, pathOf(surface)))); +} + +/** A theme (the pill, top left) groups what touches the same domain: its scenes follow each other. */ +function splitThemes(plan) { + const seen = new Set(); + const split = new Set(); + let current; + for (const { pill } of plan.scenes) { + if (pill !== current && seen.has(pill)) split.add(pill); + seen.add(pill); + current = pill; + } + return [...split]; +} + +function readPlan() { + if (!fs.existsSync(PLAN_PATH)) return { scenes: [], skipped: [] }; + const plan = JSON.parse(fs.readFileSync(PLAN_PATH, "utf8")); + return { intro: plan.intro, youtube: plan.youtube, scenes: plan.scenes ?? [], skipped: plan.skipped ?? [] }; +} + +function run({ + surfaces = featSurfaces(), + plan = readPlan(), + logger = console.error, + successLogger = console.log, +} = {}) { + const missing = missingScenes(surfaces, plan); + const unexplained = invalidSkips(plan); + const tooLong = overlongText(plan); + const technical = jargonIn(plan); + const split = splitThemes(plan); + const youtube = plan.scenes.length > 0 ? youtubeProblems(plan) : []; + + if (missing.length === 0 && unexplained.length === 0 && tooLong.length === 0 && technical.length === 0 && split.length === 0 && youtube.length === 0) { + successLogger(`✅ Release video: ${surfaces.length} surface(s) covered`); + return 0; + } + + if (missing.length > 0) logger(`❌ Scènes manquantes : ${missing.join(", ")}`); + if (unexplained.length > 0) { + logger(`❌ Surfaces ignorées sans raison : ${unexplained.join(", ")}`); + } + if (tooLong.length > 0) logger(`❌ Textes trop longs : ${tooLong.join(", ")}`); + if (technical.length > 0) logger(`❌ Mots techniques : ${technical.join(", ")}`); + if (split.length > 0) logger(`❌ Thèmes dispersés : ${split.join(", ")}`); + if (youtube.length > 0) logger(`❌ Texte YouTube : ${youtube.join(", ")}`); + return 1; +} + +module.exports = { featSurfaces, invalidSkips, jargonIn, missingScenes, overlongText, readPlan, run, splitThemes, surfacesOf }; + +if (require.main === module) { + process.exit(run()); +} diff --git a/.claude/skills/release-video/assets/compose-music.py b/.claude/skills/release-video/assets/compose-music.py new file mode 100644 index 000000000..bbb193bb2 --- /dev/null +++ b/.claude/skills/release-video/assets/compose-music.py @@ -0,0 +1,60 @@ +"""Compose the release video's 8-bit track: pulse waves and a triangle bass, no noise, no drums. + +Three parts the renderer relies on: a loud hook (3 bars), a calm bed (16 bars, a 4-bar loop) +that plays under the whole video, and a small ending of two soft bars that opens the outro slide: never a loud finale. Usage: python3 compose-music.py +Needs numpy. Turn the wav into the track with: ffmpeg -i -b:a 128k music.mp3 +""" +import sys +import numpy as np, wave +SR=44100; BPM=120; BEAT=60/BPM; BAR=4*BEAT; S16=BEAT/4 +HOOK_BARS, BED_BARS, END_BARS = 3, 16, 2 +total_s=(HOOK_BARS+BED_BARS+END_BARS)*BAR # the ending is the last 4 s: it opens the outro +N=int(total_s*SR)+SR//2 # half a second of true silence after the music, so its end is exact +def hz(name): + n={"C":0,"D":2,"E":4,"F":5,"G":7,"A":9,"B":11}[name[0]]; o=int(name[-1]) + return 440*2**((n+12*(o+1)-69)/12) +def pulse(f,dur,duty): + n=int(dur*SR); ph=(np.arange(n)*f/SR)%1.0 + return np.where(ph0: M[i:i+n]+=sig[:n] +CH=[("A2",["A4","C5","E5"]),("F2",["A4","C5","F5"]),("C3",["G4","C5","E5"]),("G2",["G4","B4","D5"])] +def bar(b,chord,lead,bass,arp,jump=False): + root,notes=chord; s=b*BAR + if bass>0: # triangle bass, eighth notes + for i in range(8): + put(tri(hz(root)*(2 if i%4==3 else 1),BEAT/2)*env(int(BEAT/2*SR),3)*bass,s+i*BEAT/2) + if arp>0: # fast pulse arpeggio, sixteenth notes + pat=[0,1,2,1]*4 if not jump else [0,1,2,1,0,2,1,2]*2 + for i,k in enumerate(pat): + f=hz(notes[k])*(2 if jump and i%4==3 else 1) + put(pulse(f,S16*1.6,0.25)*env(int(S16*1.6*SR),9)*arp,s+i*S16) + if lead>0: # simple tune on the 50% pulse, quarter notes + tune=[notes[2],notes[1],notes[2],notes[0]] + for i,nm in enumerate(tune): + put(pulse(hz(nm)/2*2,BEAT*0.9,0.5)*env(int(BEAT*0.9*SR),2.5)*lead,s+i*BEAT) +b=0 +for i in range(HOOK_BARS): bar(b,CH[i%4],0.22,0.30,0.20); b+=1 +for i in range(BED_BARS): bar(b,CH[i%4],0.0,0.12,0.07); b+=1 +# the ending, as long as the outro slide: four rising notes the moment it opens, then A minor held +# and left to die away +for i,nm in enumerate(["A4","C5","E5","A5"]): + put(pulse(hz(nm),S16*3,0.25)*env(int(S16*3*SR),6)*0.13,b*BAR+i*S16*2) +for nm in ("A3","E4"): + put(tri(hz(nm),BAR*1.8)*env(int(BAR*1.8*SR),1.6)*0.16,b*BAR+S16*8) +b+=END_BARS +end=N-SR//2; r=int(1.2*SR); fade=np.ones(N); fade[end-r:end]=np.linspace(1,0,r); fade[end:]=0; M*=fade +M/=np.abs(M).max() +M=np.round(M*31)/31 # 5-bit steps: the 8-bit grain +rms=lambda a,b: 20*np.log10(np.sqrt(np.mean(M[int(a*SR):int(b*SR)]**2))+1e-9) +he=HOOK_BARS*BAR; be=he+BED_BARS*BAR +print("seconds",round(total_s,1),"hook",round(rms(0,he),1),"bed",round(rms(he+2,be-1),1),"ending",round(rms(be,be+2*BAR),1)) +pcm=(np.stack([M,M],1)*0.89*32767).astype(np.int16) +with wave.open(sys.argv[1],"wb") as w: w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR); w.writeframes(pcm.tobytes()) diff --git a/.claude/skills/release-video/assets/fonts/inter-latin-700-normal.woff2 b/.claude/skills/release-video/assets/fonts/inter-latin-700-normal.woff2 new file mode 100644 index 000000000..a68fb101c Binary files /dev/null and b/.claude/skills/release-video/assets/fonts/inter-latin-700-normal.woff2 differ diff --git a/.claude/skills/release-video/assets/fonts/inter-latin-800-normal.woff2 b/.claude/skills/release-video/assets/fonts/inter-latin-800-normal.woff2 new file mode 100644 index 000000000..74a16d459 Binary files /dev/null and b/.claude/skills/release-video/assets/fonts/inter-latin-800-normal.woff2 differ diff --git a/.claude/skills/release-video/assets/fonts/jetbrains-mono-latin-500-normal.woff2 b/.claude/skills/release-video/assets/fonts/jetbrains-mono-latin-500-normal.woff2 new file mode 100644 index 000000000..be878e68f Binary files /dev/null and b/.claude/skills/release-video/assets/fonts/jetbrains-mono-latin-500-normal.woff2 differ diff --git a/.claude/skills/release-video/assets/fonts/jetbrains-mono-latin-700-normal.woff2 b/.claude/skills/release-video/assets/fonts/jetbrains-mono-latin-700-normal.woff2 new file mode 100644 index 000000000..3a4e333fc Binary files /dev/null and b/.claude/skills/release-video/assets/fonts/jetbrains-mono-latin-700-normal.woff2 differ diff --git a/.claude/skills/release-video/assets/logo.png b/.claude/skills/release-video/assets/logo.png new file mode 100644 index 000000000..a6afefb3f Binary files /dev/null and b/.claude/skills/release-video/assets/logo.png differ diff --git a/.claude/skills/release-video/assets/music.mp3 b/.claude/skills/release-video/assets/music.mp3 new file mode 100644 index 000000000..3f9675b71 Binary files /dev/null and b/.claude/skills/release-video/assets/music.mp3 differ diff --git a/.claude/skills/release-video/assets/render-release-video.cjs b/.claude/skills/release-video/assets/render-release-video.cjs new file mode 100644 index 000000000..56766ff08 --- /dev/null +++ b/.claude/skills/release-video/assets/render-release-video.cjs @@ -0,0 +1,538 @@ +#!/usr/bin/env node +/** + * Renders the release video from `aidd_docs/release-video/scenes.json`, locally, with no AI tool + * involved: `node .claude/skills/release-video/assets/render-release-video.cjs --version 5.13 [--plan ] [--music ] [--out ]`. + * + * Needs `npx`, a network (fonts and avatars are local, GSAP and the avatars are fetched) and + * ffmpeg + Chrome as Hyperframes does. The renderer is pinned: a new Hyperframes release + * changes pixels, so it changes the video only when someone bumps it here. + */ + +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const ROOT = path.resolve(__dirname, "../../../.."); +const PLAN = path.join(ROOT, "aidd_docs/release-video/scenes.json"); +const HYPERFRAMES = "hyperframes@0.8.162"; +const INTRO = 5; +const INTRO_WITH_HIGHLIGHTS = 6.5; +const OUTRO = 4; + +const CSS = ` +@font-face { font-family: "Inter"; font-weight: 700; src: url("assets/fonts/inter-latin-700-normal.woff2") format("woff2"); } +@font-face { font-family: "Inter"; font-weight: 800; src: url("assets/fonts/inter-latin-800-normal.woff2") format("woff2"); } +@font-face { font-family: "JetBrains Mono"; font-weight: 500; src: url("assets/fonts/jetbrains-mono-latin-500-normal.woff2") format("woff2"); } +@font-face { font-family: "JetBrains Mono"; font-weight: 700; src: url("assets/fonts/jetbrains-mono-latin-700-normal.woff2") format("woff2"); } +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { width: 1920px; height: 1080px; overflow: hidden; background: #070726; } +#root { position: relative; width: 1920px; height: 1080px; overflow: hidden; color: #eef0ff; font-family: "Inter", sans-serif; + background: radial-gradient(900px 600px at 12% 10%, rgba(40, 90, 120, 0.35), transparent 70%), + radial-gradient(900px 700px at 92% 95%, rgba(110, 60, 200, 0.30), transparent 70%), + linear-gradient(160deg, #0a0a30 0%, #070726 55%, #0d0b34 100%); } +.scene { position: absolute; inset: 0; } +.inner { position: absolute; inset: 0; padding: 78px 120px; display: flex; flex-direction: column; gap: 26px; } +.pill { align-self: flex-start; font-family: "JetBrains Mono", monospace; font-weight: 700; font-size: 30px; padding: 8px 26px; + border-radius: 40px; color: #07072b; background: linear-gradient(90deg, #5ee6a8, #8aa6ff 55%, #ff8fa8); } +h2 { font-size: 78px; line-height: 1.08; letter-spacing: -0.02em; font-weight: 800; } +.g { background: linear-gradient(90deg, #5ee6a8, #8aa6ff 55%, #c88bff 80%, #ff8fa8); -webkit-background-clip: text; background-clip: text; color: transparent; } +.win { background: #0b0b36; border: 2px solid #26265f; border-radius: 22px; overflow: hidden; box-shadow: 0 30px 80px rgba(0, 0, 20, 0.5); } +.bar { display: flex; align-items: center; gap: 12px; padding: 16px 22px; border-bottom: 2px solid #1c1c4d; font-family: "JetBrains Mono", monospace; font-weight: 500; font-size: 24px; color: #8a8fc0; } +.bar i { width: 18px; height: 18px; border-radius: 50%; display: block; } +.body { padding: 30px 36px; display: flex; flex-direction: column; gap: 18px; font-family: "JetBrains Mono", monospace; font-weight: 500; font-size: 32px; line-height: 1.4; } +.dim { color: #8a8fc0; } .grn { color: #5ee6a8; } .blu { color: #8aa6ff; } .pnk { color: #ff8fa8; } .amb { color: #ffd37a; } +.row.red { color: #ff9aa8; background: rgba(255,107,107,.14); border-left: 4px solid #ff6b6b; padding-left: 14px; } +.note { font-family: "JetBrains Mono", monospace; font-size: 22px; color: #8a8fc0; } +.chips { display: flex; flex-wrap: wrap; gap: 18px; } +.chip { font-size: 34px; padding: 12px 26px; border-radius: 12px; border: 2px solid; } +.c1 { color: #5ee6a8; border-color: #2f7a60; } .c2 { color: #8aa6ff; border-color: #3b4c99; } +.c3 { color: #c88bff; border-color: #5f3d8c; } .c4 { color: #ff8fa8; border-color: #8a4057; } +table { border-collapse: collapse; width: 100%; font-size: 28px; } +th { text-align: left; color: #8a8fc0; font-weight: 500; padding: 8px 14px; border-bottom: 2px solid #1c1c4d; } +td { padding: 14px 14px; border-bottom: 2px solid #15153f; vertical-align: top; } +.done { color: #07072b; background: #5ee6a8; padding: 3px 14px; border-radius: 8px; font-weight: 700; font-size: 24px; } +.code { white-space: pre; font-size: 30px; line-height: 1.5; } +.check { display: flex; gap: 20px; align-items: baseline; } +.flow { display: flex; align-items: stretch; gap: 16px; margin-top: 14px; } +.step { flex: 1; background: #0b0b36; border: 2px solid #26265f; border-radius: 22px; padding: 34px 32px; display: flex; flex-direction: column; gap: 14px; } +.step .n { font-family: "JetBrains Mono", monospace; font-size: 26px; color: #8a8fc0; } +.step .t { font-size: 56px; font-weight: 800; } +.step .p { font-family: "JetBrains Mono", monospace; font-size: 26px; padding: 8px 16px; border-radius: 10px; align-self: flex-start; background: rgba(255, 211, 122, 0.12); color: #ffd37a; border: 2px solid #7a6530; } +.arrow { align-self: center; font-size: 44px; color: #5b60a0; } +.pr { flex: none; width: 250px; background: rgba(94, 230, 168, 0.1); border-color: #2f7a60; } +.split { display: flex; gap: 34px; align-items: stretch; } +.split .win { min-width: 0; } +.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 24px; margin-top: 18px; } +.cell { background: #0b0b36; border: 2px solid #26265f; border-radius: 16px; padding: 18px 24px; display: flex; flex-direction: column; gap: 4px; } +.cell.wide { grid-column: span 2; } +.cell b { font-size: 31px; font-weight: 700; } +.cell small { font-family: "JetBrains Mono", monospace; font-size: 22px; color: #8a8fc0; } +.avs { display: grid; grid-template-columns: repeat(2, 1fr); gap: 34px 60px; margin-top: 20px; } +.av { display: flex; align-items: center; gap: 22px; } +.av img { width: 110px; height: 110px; border-radius: 50%; border: 3px solid #3a3a80; display: block; } +.av span { font-family: "JetBrains Mono", monospace; font-size: 32px; font-weight: 700; } +#intro .inner { flex-direction: row; align-items: center; justify-content: center; gap: 90px; } +#intro-logo { width: 420px; height: 420px; border-radius: 50%; border: 2px solid #2a2a66; } +.intro-text { display: flex; flex-direction: column; gap: 22px; min-width: 720px; } +.intro-top { display: flex; align-items: center; gap: 22px; } +.rel { font-family: "JetBrains Mono", monospace; font-size: 28px; letter-spacing: 0.12em; color: #8a8fc0; } +#ver { font-size: 250px; line-height: 1; font-weight: 800; letter-spacing: -0.03em; white-space: nowrap; } +#ver .dot { color: #5ee6a8; } +.tagline { font-family: "JetBrains Mono", monospace; font-size: 40px; color: #a6abe0; } +.tagline b { color: #fff; font-weight: 700; } +.hls { display: flex; flex-direction: column; align-items: flex-start; gap: 14px; margin-top: 10px; } +.hl { font-family: "JetBrains Mono", monospace; font-size: 28px; padding: 10px 22px; border-radius: 12px; border: 2px solid #3b4c99; color: #8aa6ff; } +.lead { font-size: 36px; line-height: 1.3; color: #a6abe0; max-width: 1500px; } +#outro .inner { align-items: center; justify-content: center; gap: 26px; } +.logo { width: 280px; height: 280px; border-radius: 50%; } +#outro h2 { font-size: 150px; text-align: center; } +.cmd { font-family: "JetBrains Mono", monospace; font-size: 34px; padding: 16px 30px; border: 2px solid #26265f; border-radius: 14px; background: #0b0b36; } +.url { font-family: "JetBrains Mono", monospace; font-size: 28px; color: #8a8fc0; } +`; + +const esc = (value) => + String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); + +const half = (seconds) => Math.ceil(seconds * 2) / 2; + +const cell = (value) => { + if (value?.status) return `${esc(value.status)}`; + if (value?.dim) return `${esc(value.dim)}`; + return `${esc(value)}`; +}; + +const row = { + prompt: (r) => `
> ${esc(r.prompt)}
`, + text: (r) => `
${esc(r.text)}
`, + chips: (r) => + `
${r.chips.map((c, i) => `${esc(c)}`).join("")}
`, + table: (r) => + `${r.table.head.map((h) => ``).join("")}` + + r.table.rows.map((cells) => `${cells.map(cell).join("")}`).join("") + + `
${esc(h)}
`, + code: (r) => `
${esc(r.code)}
`, + check: (r) => + `
${esc(r.mark ?? "✓")}${esc(r.check)}
`, +}; + +function rowHtml(r) { + const kind = Object.keys(row).find((k) => k in r); + if (!kind) throw new Error(`Unknown row: ${JSON.stringify(r)}`); + return row[kind](r); +} + +const DOTS = + ''; + +const windowHtml = ({ label, rows = [], flex }) => + `
${DOTS}${esc(label)}
` + + `
${rows.map(rowHtml).join("")}
`; + +const flowHtml = (steps) => + `
` + + steps + .map((s, i) => { + const arrow = i > 0 ? `→` : ""; + const box = s.final + ? `
 ${esc(s.title)}
` + : `
${esc(s.n)}${esc(s.title)}${esc(s.sub)}
`; + return arrow + box; + }) + .join("") + + `
`; + +const noteHtml = (s) => (s.note ? `

${esc(s.note)}

` : ""); + +const rowsIn = (rows = []) => rows.reduce((n, r) => n + (r.table ? 1 + r.table.rows.length : 1), 0); + +/** Rows are revealed one by one; the note comes after the last. */ +const rowElements = (s) => + (s.window ? [s.window] : s.panels ?? []).reduce((n, w) => n + (w.rows ?? []).length, 0); + +const scene = { + "terminal-demo": { + items: (s) => rowsIn(s.window?.rows) + (s.flow ? 2 : 0), + body: (s) => windowHtml(s.window) + (s.flow ? flowHtml(s.flow) : "") + noteHtml(s), + }, + "two-panels": { + items: (s) => (s.panels ?? []).reduce((n, p) => n + rowsIn(p.rows), 0), + body: (s) => `
${s.panels.map(windowHtml).join("")}
${noteHtml(s)}`, + }, + "bullet-grid": { + items: (s) => (s.cells ?? []).length, + body: (s) => + `
` + + s.cells + .map( + (c) => + `
${esc(c.title)}${esc(c.detail ?? "")}
` + ) + .join("") + + `
`, + }, + contributors: { + items: (s) => Math.ceil((s.logins ?? []).length / 2), + body: (s) => + `
` + + s.logins + .map((l) => `
@${esc(l)}
`) + .join("") + + `
`, + }, +}; + +/** Head (2.5 s), a second to read the lead, a beat per item, and a closing pause. */ +function sceneDuration(s) { + if (!scene[s.template]) throw new Error(`Unknown template: ${s.template}`); + return half(4.5 + (s.lead ? 1 : 0) + 0.7 * scene[s.template].items(s)); +} + +const headHtml = (s, id) => + `${esc(s.pill)}` + + `

${esc(s.title)}${s.accent ? ` ${esc(s.accent)}` : ""}

` + + (s.lead ? `

${esc(s.lead)}

` : ""); + +/** The tags say what the release brings; the phrase above them is optional, and only an intro with + * neither keeps a default one. */ +const hasTagline = (intro = {}) => Boolean(intro.tagline) || !intro.highlights?.length; + +/** The version is typed in by the timeline: until it starts, nothing of it is on screen. */ +function introHtml(intro = {}) { + const tagline = intro.tagline ? esc(intro.tagline) : "What's new, end to end."; + const highlights = (intro.highlights ?? []).map((h) => `${esc(h)}`).join(""); + return ( + `
` + + `
AIDD FrameworkNEW RELEASE
` + + `
` + + (hasTagline(intro) ? `

${tagline}

` : "") + + (highlights ? `
${highlights}
` : "") + + `
` + ); +} + +/** The fade lane sets the level itself: it is absolute, so it carries the music level too. */ +const MUSIC_LEVEL = 0.5; + +const musicHtml = (src, total) => { + const lane = { + version: 1, + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 0 }, + { t: 0.3, v: MUSIC_LEVEL }, + { t: total - 1, v: MUSIC_LEVEL }, + { t: total, v: 0 }, + ], + }, + ], + }; + return ``; +}; + +/** The interface sounds sit well under the picture and the music. */ +const SFX_LEVEL = 0.2; + +const sfxHtml = (src, total) => + ``; + +const introSeconds = (plan) => (plan.intro?.highlights?.length ? INTRO_WITH_HIGHLIGHTS : INTRO); + +const round2 = (x) => Math.round(x * 100) / 100; +const itemsIn = (html) => (html.match(/class="[^"]*\bit\b/g) ?? []).length; + +/** One sound per thing that appears, at the time the timeline shows it: a whoosh as a scene + * opens, a pop as a window or item slides in, a click per row, a key tick per typed character. */ +function soundCues(plan, { version }) { + const cues = []; + const cue = (kind, t) => cues.push({ kind, t: round2(t) }); + const chars = String(version).length + 1; + cue("pop", 0.1); + for (let k = 0; k < chars; k++) cue("key", 0.8 + (1.1 * k) / chars); + if (hasTagline(plan.intro)) cue("pop", 2.0); + (plan.intro?.highlights ?? []).forEach((_, k) => cue("pop", (hasTagline(plan.intro) ? 2.6 : 2) + 0.3 * k)); + + let t = introSeconds(plan); + for (const s of plan.scenes) { + cue("whoosh", t); + for (let k = 0; k < itemsIn(scene[s.template].body(s)); k++) cue("pop", t + 0.63 + 0.3 * k); + for (let k = 0; k < rowElements(s); k++) cue("click", t + 1.3 + 0.45 * k); + t += sceneDuration(s); + } + for (let k = 0; k < 2; k++) cue("pop", t + 0.63 + 0.2 * k); + return cues.sort((a, b) => a.t - b.t); +} + +const SFX_RATE = 44100; +const square = (phase, duty = 0.5) => (phase % 1 < duty ? 1 : -1); + +/** Each sound is a short 8-bit blip: a square wave with a fast decay, no noise. */ +const SOUNDS = { + key: { seconds: 0.025, wave: (x) => square(1400 * x) * Math.exp(-x * 120) }, + click: { seconds: 0.05, wave: (x) => square(2000 * x) * Math.exp(-x * 80) }, + pop: { seconds: 0.12, wave: (x) => square((480 + 2000 * x) * x) * Math.exp(-x * 25) }, + whoosh: { seconds: 0.26, wave: (x) => square((150 + 2400 * x) * x, 0.25) * Math.sin((Math.PI * x) / 0.26) * 0.6 }, +}; + +/** The sounds of the cues, laid on a silent mono 16-bit wav as long as the video. */ +function soundTrack(plan, { version }) { + const total = Math.round(totalDuration(plan) * SFX_RATE); + const mix = new Float64Array(total); + for (const { kind, t } of soundCues(plan, { version })) { + const { seconds, wave } = SOUNDS[kind]; + const from = Math.round(t * SFX_RATE); + for (let i = 0; i < Math.round(seconds * SFX_RATE) && from + i < total; i++) mix[from + i] += wave(i / SFX_RATE); + } + const wav = Buffer.alloc(44 + 2 * total); + wav.write("RIFF", 0, "latin1"); + wav.writeUInt32LE(36 + 2 * total, 4); + wav.write("WAVEfmt ", 8, "latin1"); + wav.writeUInt32LE(16, 16); + wav.writeUInt16LE(1, 20); + wav.writeUInt16LE(1, 22); + wav.writeUInt32LE(SFX_RATE, 24); + wav.writeUInt32LE(SFX_RATE * 2, 28); + wav.writeUInt16LE(2, 32); + wav.writeUInt16LE(16, 34); + wav.write("data", 36, "latin1"); + wav.writeUInt32LE(2 * total, 40); + for (let i = 0; i < total; i++) { + const steps = Math.round(Math.max(-1, Math.min(1, mix[i] * 0.8)) * 31) / 31; // 5-bit grain + wav.writeInt16LE(Math.round(steps * 32767), 44 + 2 * i); + } + return wav; +} + +/** What the root declares: the length a music track is cut to. */ +const totalDuration = (plan) => introSeconds(plan) + plan.scenes.reduce((n, s) => n + sceneDuration(s), 0) + OUTRO; + +function buildHtml(plan, { version, music, sfx }) { + const sections = []; + const script = []; + let t = 0; + const open = (id, duration, inner) => + sections.push( + `
${inner}
` + ); + + open("intro", introSeconds(plan), introHtml(plan.intro)); + script.push( + `enter("#intro-logo", 0.1, { y: 0, scale: 0.85, duration: 0.9 });`, + `enter("#intro-pill", 0.4, { y: 20 });`, + `enter("#intro-rel", 0.55, { y: 20 });`, + `const typed = { n: 0 }; const full = ${JSON.stringify(String(version))};`, + `tl.to(typed, { n: 1, duration: 1.1, ease: "none", onUpdate: () => { const len = Math.round(typed.n * (full.length + 1));` + + ` const [a, b = ""] = full.split(".");` + + ` document.getElementById("ver-t").textContent = ("v" + a).slice(0, Math.min(len, a.length + 1));` + + ` document.querySelector("#ver .dot").textContent = len > a.length + 1 ? "." : "";` + + ` document.getElementById("ver-n").textContent = len > a.length + 1 ? b.slice(0, len - a.length - 1) : ""; } }, 0.8);`, + ...(hasTagline(plan.intro) ? [`enter("#intro-tag", 2.0, { y: 20 });`] : []), + `enter("#intro .hl", ${hasTagline(plan.intro) ? 2.6 : 2}, { y: 16, duration: 0.5, stagger: 0.3 });`, + `cut("intro", ${introSeconds(plan)});` + ); + t += introSeconds(plan); + + plan.scenes.forEach((s, i) => { + const id = `s${i + 1}`; + const duration = sceneDuration(s); + open(id, duration, headHtml(s, id) + scene[s.template].body(s)); + script.push( + `enter("#${id}-pill", ${t} + 0.14, { y: 20 });`, + `enter("#${id}-h", ${t} + 0.28);`, + ...(s.lead ? [`enter("#${id}-lead", ${t} + 0.5, { y: 20 });`] : []), + `enter("#${id} .it", ${t} + 0.63, { y: 30, duration: 0.7, stagger: 0.3 });`, + `enter("#${id} .row", ${t} + 1.3, { y: 14, duration: 0.5, stagger: 0.45 });`, + ...(s.note ? [`enter("#${id} .fin", ${Math.round((t + 1.3 + 0.45 * rowElements(s) + 0.3) * 100) / 100}, { y: 10 });`] : []), + `cut("${id}", ${t + duration});` + ); + t += duration; + }); + + open( + "outro", + OUTRO, + `` + + `

AIDD v${esc(version)}

` + + `
$ npx @ai-driven-dev/cli@latest setup
` + + `

github.com/ai-driven-dev/framework

` + ); + script.push( + `enter("#outro-logo", ${t} + 0.07, { y: 0, scale: 0.85, duration: 0.8 });`, + `enter("#outro-h", ${t} + 0.28);`, + `enter("#outro .it", ${t} + 0.63, { y: 14, stagger: 0.2 });` + ); + t += OUTRO; + script.push(`tl.to("#outro .inner", { opacity: 0, duration: 0.4, ease: "power1.in" }, ${t} - 0.4);`); + + return ` + + + +
+${sections.join("\n")} +${music ? musicHtml(music, t) : ""} +${sfx ? sfxHtml(sfx, t) : ""} +
+ +`; +} + +const STEP = 0.5; +const HOOK_STARTS_WITHIN = 2; +const LOUD_ABOVE_MEDIAN = 8; + +/** The ending is the last stretch of a track before it falls silent, as long as the outro it opens: small, never louder than a hook. */ +const ENDING_SECONDS = 4; +const SILENT_BELOW = -55; + +/** Where a track is loud at its start (a hook, after at most two quiet seconds) and where its small + * ending is, from its energy in dB per half second. Null when it has no hook: such a track is only + * looped. */ +function musicSections(rms) { + const sorted = [...rms].sort((a, b) => a - b); + const loud = (v) => v > sorted[Math.floor(sorted.length / 2)] + LOUD_ABOVE_MEDIAN; + const first = rms.findIndex(loud); + let end = first; + while (end < rms.length && loud(rms[end])) end += 1; + const tailEnd = (rms.map((v) => v > SILENT_BELOW).lastIndexOf(true) + 1) * STEP; + const tailStart = tailEnd - ENDING_SECONDS; + if (first < 0 || first * STEP > HOOK_STARTS_WITHIN || tailStart <= end * STEP) return null; + return { headStart: first * STEP, headEnd: end * STEP, tailStart, tailEnd }; +} + +/** The hook under the intro, the bed under the scenes and the outro, the small ending last. */ +function musicSegments(sections, { intro, total }) { + const head = { part: "head", from: sections.headStart, length: Math.min(intro, sections.headEnd - sections.headStart), at: 0 }; + const tail = { part: "tail", from: sections.tailStart, length: sections.tailEnd - sections.tailStart }; + tail.at = total - tail.length; + const body = { part: "body", from: sections.headEnd, length: tail.at - head.length, at: head.length }; + return [head, body, tail]; +} + +function measureRms(source) { + const run = spawnSync( + "ffmpeg", + ["-hide_banner", "-loglevel", "error", "-i", source, "-af", + "aresample=48000,asetnsamples=n=24000:p=0,astats=metadata=1:reset=1,ametadata=print:key=lavfi.astats.Overall.RMS_level:file=-", + "-f", "null", "-"], + { encoding: "utf8" } + ); + return [...run.stdout.matchAll(/RMS_level=(-?[\d.]+|-inf)/g)].map((m) => (m[1] === "-inf" ? -100 : Number(m[1]))); +} + +const ffmpeg = (args) => { + const run = spawnSync("ffmpeg", ["-loglevel", "error", "-y", ...args], { stdio: "inherit" }); + if (run.status !== 0) throw new Error(`ffmpeg failed (${run.status})`); +}; + +/** The track shaped to the video: its hook under the intro, its bed looped under the scenes and the + * outro, its small ending closing the video. A track with no such structure is only looped to the video's length. */ +function fitMusic(source, target, plan) { + const total = totalDuration(plan); + const sections = musicSections(measureRms(source)); + if (!sections) { + ffmpeg(["-stream_loop", "-1", "-i", source, "-t", String(total), "-c:a", "libmp3lame", "-q:a", "2", target]); + return; + } + const [head, body, tail] = musicSegments(sections, { intro: introSeconds(plan), total }); + const bed = sections.tailStart - sections.headEnd; + const cut = (seg, label, fades) => + `[0:a]aresample=48000,atrim=start=${seg.from}:duration=${seg.length},asetpts=PTS-STARTPTS${fades}[${label}]`; + const loopedBed = + `[0:a]aresample=48000,atrim=start=${body.from}:duration=${Math.min(bed, body.length)},asetpts=PTS-STARTPTS,` + + `aloop=loop=-1:size=${Math.ceil(bed * 48000)},atrim=duration=${body.length},asetpts=PTS-STARTPTS[b]`; + const fadeOut = (seg) => `,afade=t=out:st=${Math.max(0, seg.length - 0.15)}:d=0.15`; + const filter = [ + cut(head, "h", `,afade=t=in:d=0.05${fadeOut(head)}`), + loopedBed, + cut(tail, "t", `,afade=t=in:d=0.05`), + `[h][b][t]concat=n=3:v=0:a=1,apad=whole_dur=${total},atrim=duration=${total}[a]`, + ].join(";"); + ffmpeg(["-i", source, "-filter_complex", filter, "-map", "[a]", "-c:a", "libmp3lame", "-q:a", "2", target]); +} + +function writeProject(dir, plan, version, musicSource) { + fs.mkdirSync(path.join(dir, "assets"), { recursive: true }); + fs.mkdirSync(path.join(dir, "assets"), { recursive: true }); + fs.cpSync(path.join(__dirname, "fonts"), path.join(dir, "assets/fonts"), { recursive: true }); + fs.copyFileSync(path.join(__dirname, "logo.png"), path.join(dir, "assets/logo.png")); + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ name: "release-video", private: true, type: "module" }) + ); + fs.writeFileSync(path.join(dir, "meta.json"), JSON.stringify({ id: "release-video", name: "release-video" })); + fs.writeFileSync(path.join(dir, "hyperframes.json"), JSON.stringify({ paths: { assets: "assets" } })); + let music; + if (musicSource) { + music = "assets/music-fit.mp3"; + fitMusic(musicSource, path.join(dir, music), plan); + } + const sfx = "assets/sfx.wav"; + fs.writeFileSync(path.join(dir, sfx), soundTrack(plan, { version })); + fs.writeFileSync(path.join(dir, "index.html"), buildHtml(plan, { version, music, sfx })); +} + +async function fetchAvatars(dir, plan) { + const logins = plan.scenes.flatMap((s) => s.logins ?? []); + for (const login of new Set(logins)) { + const target = path.join(dir, "assets", `av-${login}.png`); + const response = await fetch(`https://github.com/${encodeURIComponent(login)}.png?size=240`); + if (response.ok) { + fs.writeFileSync(target, Buffer.from(await response.arrayBuffer())); + } else { + console.warn(`⚠️ Avatar of ${login}: HTTP ${response.status}, logo used`); + fs.copyFileSync(path.join(dir, "assets", "logo.png"), target); + } + } +} + +function argument(name) { + const at = process.argv.indexOf(`--${name}`); + return at > -1 ? process.argv[at + 1] : undefined; +} + +async function main() { + const version = argument("version"); + if (!version) throw new Error("Usage: render-release-video.cjs --version [--plan ] [--music ] [--out ]"); + const plan = JSON.parse(fs.readFileSync(path.resolve(argument("plan") ?? PLAN), "utf8")); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "release-video-")); + + const kitMusic = path.join(__dirname, "music.mp3"); + const musicSource = argument("music") ?? (fs.existsSync(kitMusic) ? kitMusic : undefined); + writeProject(dir, plan, version, musicSource); + await fetchAvatars(dir, plan); + + const render = spawnSync("npx", ["--yes", HYPERFRAMES, "render", "--output", "release-video.mp4"], { + cwd: dir, + stdio: "inherit", + }); + if (render.status !== 0) throw new Error(`Hyperframes render failed (${render.status})`); + + const out = path.resolve(argument("out") ?? "release-video.mp4"); + fs.copyFileSync(path.join(dir, "release-video.mp4"), out); + console.log(`✅ ${out}`); +} + +module.exports = { buildHtml, musicSections, musicSegments, sceneDuration, soundCues, soundTrack, totalDuration }; + +if (require.main === module) { + main().catch((error) => { + console.error(`❌ ${error.message}`); + process.exit(1); + }); +} diff --git a/.claude/skills/release-video/assets/scenes-template.json b/.claude/skills/release-video/assets/scenes-template.json new file mode 100644 index 000000000..72aa8297c --- /dev/null +++ b/.claude/skills/release-video/assets/scenes-template.json @@ -0,0 +1,52 @@ +{ + "intro": { + "highlights": [ + "Feature in a few words", + "Feature in a few words", + "Feature in a few words" + ] + }, + "scenes": [ + { + "template": "terminal-demo", + "pill": "/plugin:NN-skill", + "title": "What it does,", + "accent": "in a few words.", + "surfaces": [ + "plugin:NN-skill" + ], + "window": { + "label": "Claude Code · ~/acme-app", + "rows": [ + { + "prompt": "/plugin:NN-skill a request" + }, + { + "text": "an output line", + "tone": "dim" + } + ] + }, + "note": "Example run.", + "lead": "One plain sentence: what it does for the user, and why it matters." + } + ], + "skipped": [ + { + "surface": "", + "reason": "" + } + ], + "youtube": { + "themes": [ + "Theme one", + "Theme two" + ], + "programme": [ + { + "surface": "aidd command", + "text": "what it does for the user, in French." + } + ] + } +} diff --git a/.claude/skills/release-video/assets/youtube-metadata.js b/.claude/skills/release-video/assets/youtube-metadata.js new file mode 100644 index 000000000..28c7ddb49 --- /dev/null +++ b/.claude/skills/release-video/assets/youtube-metadata.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/** + * The YouTube title and description of the release video, in the layout of the published ones: + * `node .claude/skills/release-video/assets/youtube-metadata.js --version 5.13 --tag v5.13.0 --repo [--out release-video.json]`. + */ +const fs = require("node:fs"); +const path = require("node:path"); + +const PLAN = path.resolve(__dirname, "../../../../aidd_docs/release-video/scenes.json"); +const MAX_TITLE = 100; + +const slash = (surface) => (surface.includes(":") ? `/${surface}` : surface); + +function titleOf(plan, version) { + const { themes } = plan.youtube; + const list = themes.length > 1 ? `${themes.slice(0, -1).join(", ")} et ${themes.at(-1)}` : themes[0]; + return `Framework - Release ${version} : ${list}`; +} + +function youtubeMetadata(plan, { version, tag, repo }) { + const contributors = plan.scenes.find((scene) => scene.template === "contributors")?.logins?.length; + const lines = [ + `Les nouveautés du framework AI-Driven Dev ${version}, démontrées de bout en bout.`, + "", + "Au programme :", + ...plan.youtube.programme.map(({ surface, text }) => `- ${slash(surface)} : ${text}`), + "", + ]; + if (contributors) lines.push(`Version livrée par ${contributors} contributeurs (crédits dans les notes de version).`, ""); + lines.push(`Notes de version : https://github.com/${repo}/releases/tag/${tag}`, `Code source : https://github.com/${repo}`); + return { title: titleOf(plan, version), description: lines.join("\n") }; +} + +/** What a plan lacks to be published: the block, a title that fits, a line for every scene's surface. */ +function youtubeProblems(plan) { + if (!plan.youtube) return ["youtube: missing"]; + const problems = []; + const title = titleOf(plan, "0.0"); + if (title.length > MAX_TITLE) problems.push(`title: ${title.length} characters, ${MAX_TITLE} at most`); + const covered = new Set(plan.youtube.programme.map((line) => line.surface)); + for (const surface of plan.scenes.flatMap((scene) => scene.surfaces ?? [])) { + if (!covered.has(surface)) problems.push(`youtube programme: no line for ${surface}`); + } + return problems; +} + +module.exports = { youtubeMetadata, youtubeProblems }; + +if (require.main === module) { + const argument = (name) => process.argv[process.argv.indexOf(`--${name}`) + 1]; + const [version, tag, repo] = ["version", "tag", "repo"].map(argument); + if (!version || !tag || !repo) throw new Error("Usage: youtube-metadata.js --version --tag --repo [--out ]"); + const plan = JSON.parse(fs.readFileSync(PLAN, "utf8")); + const problems = youtubeProblems(plan); + if (problems.length) throw new Error(problems.join("\n")); + const out = path.resolve(process.argv.includes("--out") ? argument("out") : "release-video.json"); + fs.writeFileSync(out, `${JSON.stringify(youtubeMetadata(plan, { version, tag, repo }), null, 2)}\n`); + console.log(`✅ ${out}`); +} diff --git a/.claude/skills/release-video/references/judge.md b/.claude/skills/release-video/references/judge.md new file mode 100644 index 000000000..324c1f3e6 --- /dev/null +++ b/.claude/skills/release-video/references/judge.md @@ -0,0 +1,14 @@ +# Judge + +What the check cannot read; each row is answered on a plan before it is accepted. + +| Question | Rewrite or remove when | +| --- | --- | +| Would a user notice this change? | it is a correction with nothing to see, such as a lost figure now flagged: no scene, it stays in the release notes | +| Does a two-word phrase need a glossary? | yes: say what the user can do now, or drop the scene | +| Is the feature shown in the tool the user works in? | a Codex feature appears in a terminal: put it in a Codex window | +| Is a story told in the order lived? | do it, then see the report by tool, in one scene each | +| Is a safety feature shown as it unfolds? | it only states the result: show the warning in red, then the question put to the user | +| Does the picture say it with the sound off? | a scene needs the music or a read of the notes to be understood | +| Does the demo look believable? | an invented value is not marked as an example, or the command is not a real one | +| Do the intro tags name this release's big features? | they could open another release, or need a phrase to be understood: rewrite them | diff --git a/.claude/skills/release-video/references/scenes.md b/.claude/skills/release-video/references/scenes.md new file mode 100644 index 000000000..a800b0a6c --- /dev/null +++ b/.claude/skills/release-video/references/scenes.md @@ -0,0 +1,53 @@ +# Scenes + +What a plan holds and how each part is drawn. + +| Template | Fits | Fields it reads | +| --- | --- | --- | +| `terminal-demo` | one command and its output | `window`, optional `flow` | +| `two-panels` | a request beside its result | `panels`, each with `label`, `flex`, `rows` | +| `bullet-grid` | several small changes | `cells`, each with `title`, optional `detail`, `wide` | +| `contributors` | who shipped the release | `logins` | + +| Plan field | Holds | +| --- | --- | +| `intro` | `highlights`, the tags shown first | +| `youtube` | `themes`, 1 to 4 short French theme names for the title; `programme`, one `{surface, text}` per surface a scene covers, `text` a French sentence that starts with a verb | + +| Scene field | Holds | +| --- | --- | +| `pill` | the theme, top left: `Telemetry`, `Rules`, never a command | +| `title`, `accent` | a short title; the accent takes the gradient | +| `lead` | one plain sentence under the title: what it does for the user | +| `surfaces` | the skills and commands the scene covers, for the check | +| `window` | `label` and `rows` | +| `note` | a line shown after the last row, such as `Example figures.` | + +| Row | Shape | +| --- | --- | +| prompt | `{"prompt": "…"}` | +| text | `{"text": "…", "tone": "dim"}` | +| chips | `{"chips": ["…"]}` | +| table | `{"table": {"head": [], "rows": []}}`, a cell may be `{"status": "…"}` or `{"dim": "…"}` | +| code | `{"code": "…"}` | +| check | `{"check": "…", "mark": "!"}` | + +| Tone | Means | +| --- | --- | +| `dim` | a quiet line | +| `grn` | success | +| `blu`, `pnk` | emphasis | +| `amb` | a question or a choice | +| `red` | a problem, drawn with a red bar | + +| Limit | Value | +| --- | --- | +| intro tagline | optional and left out by default; 6 words when used | +| intro highlights | 3, of 5 words each, drawn one per line: clear sentences, not labels | +| scene lead | 14 words | +| intro | 5 seconds, 6.5 with highlights | +| scene | 4.5 seconds, plus 1 with a lead, plus 0.7 per item, rounded up to a half second | +| outro | 4 seconds | +| refused words | tokens, envelope, axis, transcript, payload, hook; `telemetry` stays; the demo window is not read, it shows real commands and flags | +| themes | scenes of one theme follow each other | +| YouTube title | 100 characters, built from `youtube.themes` as `Framework - Release 5.12 : Batch, SDLC, Télémétrie et Review` | diff --git a/.claude/skills/release-video/references/sound.md b/.claude/skills/release-video/references/sound.md new file mode 100644 index 000000000..c4e4db157 --- /dev/null +++ b/.claude/skills/release-video/references/sound.md @@ -0,0 +1,30 @@ +# Sound + +The renderer adds two layers, both original. + +| Layer | What | Where | +| --- | --- | --- | +| Music | an 8-bit track in three parts: a loud hook, a calm bed, a small ending | `assets/music.mp3`, used by every render; `--music ` replaces it for one | +| Interface sounds | short 8-bit blips from square and triangle waves, no noise | `soundCues` and `soundTrack` in `assets/render-release-video.cjs` | + +| Moment | Sound | +| --- | --- | +| the version typed in the intro | a key tick per character | +| a window or item slides in | a pop | +| a row appears | a click | +| a scene opens | a whoosh | + +The sounds take their times from the animation: changing the plan moves them, nothing is placed by hand. + +| Part of the track | Plays under | Level in the video | +| --- | --- | --- | +| hook | the intro | about -19 dB | +| bed | the scenes and the outro, looped | about -29 dB, 10 dB under the hook | +| ending | the outro slide: four rising notes as it opens, then a held chord that dies away | about -26 dB, never louder than a few dB above the bed | +| interface sounds | everything | peaks about -12 dB, above the bed | + +| Fact | Detail | +| --- | --- | +| the shape | a track with no loud hook is only looped under everything; its ending is its last 4 seconds before silence, as long as the outro it opens, and stays quiet | +| the generator | `assets/compose-music.py`, numpy, 120 BPM, an A minor, F, C, G loop | +| taste | a track that tires is replaced by another original one; nothing else changes | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53903acb6..e930227b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,16 +85,6 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: gh pr merge "${{ fromJSON(steps.release.outputs.pr).number }}" --merge --admin --body "" --repo "${{ github.repository }}" - # GitHub marks whichever release is created last as "Latest", and release-please - # creates the umbrella and every plugin release in one unordered run — so a plugin - # release can outrank the marketplace version. Bare `tag_name` is the root tag, the one - # carrying the marketplace bundle, and it bumps every cycle. - - name: Pin umbrella release as latest - if: ${{ steps.release.outputs.release_created == 'true' }} - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: gh release edit "${{ steps.release.outputs.tag_name }}" --latest --repo "${{ github.repository }}" - # Credits commit authors; native `include-commit-authors` is broken (release-please#2761). # Remove with the script once #2892 ships. continue-on-error: never blocks the publish jobs. - name: Checkout @@ -144,6 +134,39 @@ jobs: "/tmp/aidd-framework-marketplace-${VERSION}.zip" \ --clobber + build-release-video: + name: Render and attach the release video + needs: [release-please] + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + # `gh release upload` writes a release asset, which GitHub treats as a contents change. + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" + - name: Install ffmpeg + run: sudo apt-get update && sudo apt-get install -y ffmpeg + # The plan is the one the promotion checked, so the video shows what this release holds. + # The video names the release by major.minor: 5.13.0 renders "5.13". + - name: Render the release video + run: | + VERSION="${{ needs.release-please.outputs.version }}" + MINOR="${VERSION%.*}" + node .claude/skills/release-video/assets/render-release-video.cjs --version "${MINOR}" + # The title and description published with the video, in the layout of the earlier ones. + - name: Attach the release video and its YouTube text to the release + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION="${{ needs.release-please.outputs.version }}" + MINOR="${VERSION%.*}" + TAG="${{ needs.release-please.outputs.tag_name }}" + node .claude/skills/release-video/assets/youtube-metadata.js --version "${MINOR}" --tag "${TAG}" --repo "${{ github.repository }}" + gh release upload "${TAG}" release-video.mp4 release-video.json --clobber + build-per-tool: name: Build and attach per-tool distribution needs: [release-please] @@ -321,3 +344,63 @@ jobs: rm -f .npmrc npm install -g npm@11.19.1 npm publish --access public + + # The umbrella release is created as a draft, so no `release` event reaches a subscriber + # before its bundle, distributions and video are attached. Publishing it sends the event + # with everything in place. A failed video does not hold the release back; a failed bundle + # or distribution does, and the release stays a draft for a person to see. + # `--latest` is set here: GitHub refuses it on a draft. GitHub marks whichever release is + # created last as "Latest", and release-please creates the umbrella and every plugin release + # in one unordered run, so a plugin release can outrank the marketplace version. Bare + # `tag_name` is the root tag and bumps every cycle. + # The App token, not GITHUB_TOKEN: only an App token re-fires the `release: published` workflows. + publish-release: + name: Publish the release + needs: [release-please, build-and-attach, build-per-tool, build-plugin, build-release-video] + if: | + always() && + needs.release-please.outputs.release_created == 'true' && + needs.build-and-attach.result == 'success' && + needs.build-per-tool.result == 'success' && + (needs.build-plugin.result == 'success' || needs.build-plugin.result == 'skipped') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + id: app-token + with: + app-id: ${{ secrets.AIDD_BOT_APP_ID }} + private-key: ${{ secrets.AIDD_BOT_PRIVATE_KEY }} + - name: Publish the umbrella release as the latest + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + TAG="${{ needs.release-please.outputs.tag_name }}" + gh release edit "${TAG}" --draft=false --latest --repo "${{ github.repository }}" + + # A recap of what ran and what broke, whatever the outcome, printed in the log. + # The Slack message is n8n's. + release-recap: + name: Recap the release + needs: [release-please, build-and-attach, build-per-tool, build-plugin, build-release-video, publish-cli, publish-release] + if: always() && needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" + - name: Print the recap + env: + GH_TOKEN: ${{ github.token }} + NEEDS: ${{ toJSON(needs) }} + TAG: ${{ needs.release-please.outputs.tag_name }} + REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: node scripts/release-recap.js + diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index b0e71a4a9..bc3740744 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -11,6 +11,11 @@ name: Promote next to main on: workflow_dispatch: + inputs: + skip_video: + description: Promote without checking the release video covers what ships. Urgent only. + type: boolean + default: false concurrency: group: promote @@ -39,6 +44,7 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} REPO: ${{ github.repository }} RUN_ID: ${{ github.run_id }} + SKIP_VIDEO: ${{ inputs.skip_video }} run: | set -euo pipefail @@ -53,6 +59,13 @@ jobs: exit 0 fi + # What ships needs a scene in the release video, or a written reason to be left out. + # Checked here, before anything is pushed, so a refusal leaves no branch or PR behind. + if [ "${SKIP_VIDEO}" != "true" ]; then + git checkout --detach origin/next + node .claude/skills/release-video/assets/check-release-video.js + fi + # A snapshot of next, never next itself: a PR headed by the live branch widens as # work merges in during review. The snapshot is next's exact tip, so the merge base # still refreshes. diff --git a/RELEASE.md b/RELEASE.md index 058eeefe8..2968ccc23 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -34,10 +34,12 @@ flowchart LR ## 🗓️ Weekly release, step by step -1. Open the [promotion `next` to `main`](https://github.com/ai-driven-dev/framework/actions/workflows/promote.yml) once the week's work is ready. -2. release-please opens a Release PR on `main`: it is auto-merged. -3. Tags and version bumps are created, release artifacts are attached. -4. `main` is back-merged into `next` automatically. +1. Plan the release video with the `release-video` skill (`.claude/skills/release-video`) until `node .claude/skills/release-video/assets/check-release-video.js` passes. The promotion refuses otherwise; `skip_video` lets an urgent one through. +2. Open the [promotion `next` to `main`](https://github.com/ai-driven-dev/framework/actions/workflows/promote.yml) once the week's work is ready. +3. release-please opens a Release PR on `main`: it is auto-merged. +4. Tags and version bumps are created. The umbrella release is a draft: the bundle, the distributions and the rendered video are attached to it, then it is published. Announcements fire on that publication, with everything in place. +5. The video is rendered by CI from the plan the promotion checked. A failed video does not hold the release; a failed bundle or distribution does, and the release stays a draft. Publish it by hand with `gh release edit --draft=false --latest` once fixed. +6. `main` is back-merged into `next` automatically. ## 🚑 Hotfix diff --git a/aidd_docs/memory/deployment.md b/aidd_docs/memory/deployment.md index 7a65597c2..bbffd7f0d 100644 --- a/aidd_docs/memory/deployment.md +++ b/aidd_docs/memory/deployment.md @@ -8,30 +8,21 @@ Where the project runs and how it ships: CI/CD, environments, and release. | Workflow | Runs | | --- | --- | -| `ci.yml` | commitlint on pull requests and on `main`'s tip, plus the PR title itself — the subject a squash merge uses — then release-please on `main` and the release jobs | -| `cli-ci.yml` | the `cli` and `kanban` gates — job list in the CLI bank. No `paths:` filter, deliberately: it runs on every push and pull request, and a `changes` job decides in bash whether the rest has anything to do — `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, the workflow file itself. Mutations skip only for a same-repository numeric `promote/next-to-main-*` snapshot whose `cli / gate` passed in a successful `next` push and whose PR merge tree equals that snapshot with `main` already its ancestor; the resulting `main` push reuses it only for that exact two-parent promotion merge when its tree, associated merged PR, and source snapshot all match. Missing, failed, unreadable, or mismatched Git/API proof keeps normal mutation scopes. All non-mutation checks still run on the current PR merge ref or `main` commit. | -| `validate.yml` | plugin and marketplace manifests against their schemas, plus the whole pre-commit over the whole tree | +| `ci.yml` | commitlint (pull requests, `main`'s tip, the PR title a squash merge uses), then release-please on `main` and the release jobs | +| `cli-ci.yml` | the `cli` and `kanban` gates, jobs in the CLI bank. No `paths:` filter: a `changes` job decides in bash what has work. Mutations skip only for a verified `promote/next-to-main-*` snapshot; anything unproven keeps normal scopes | +| `validate.yml` | manifests against their schemas, plus the whole pre-commit over the whole tree | | `codeql.yml` | code scanning | -| `promote.yml` | opens the `next` to `main` promote PR, merge auto-merge | -| `back-merge.yml` | folds `main` back into `next` after each release | +| `promote.yml` | checks the release video covers what ships (`skip_video` bypasses), opens the `next` to `main` PR, auto-merges | +| `back-merge.yml` | folds `main` into `next` after each release | | `dependabot-auto-merge.yml` | merges dependency PRs that pass | | `close-finished-milestones.yml` | closes a milestone once its issues are | | `star-history.yml` | refreshes the README star chart | -```mermaid -flowchart LR - Promote["promote.yml"] --> Push["push on main"] - Push --> RP["release-please PR"] - RP --> Release["release + tags"] - Release --> Build["build & publish"] - Release --> Back["back-merge.yml"] -``` - -Automatic on a push to `main`. Three workflows also accept a manual run: `promote.yml`, `close-finished-milestones.yml`, `star-history.yml`. +Push to `main` is automatic. Manual runs: `promote.yml`, `close-finished-milestones.yml`, `star-history.yml`. ## Environments -None — no server, no container, no IaC. What ships are release assets and published packages. +No server, container or IaC. What ships are release assets and packages. | Target | Where | | --- | --- | @@ -44,18 +35,34 @@ None — no server, no container, no IaC. What ships are release assets and publ Branch model in `vcs.md`, cadence and safety rules in [`RELEASE.md`](../../RELEASE.md). -1. release-please opens the Release PR. Only paths with commits bump; the root bumps every cycle. CI auto-merges it with `--merge --admin`, the only method `.github/rulesets/main.json`'s `pull_request` rule allows, so `main` never holds merged but unversioned code. -2. Merging creates the release and its tags — a root umbrella tag, `cli-v`, and one `-v` per plugin, `include-component-in-tag: true`. `scripts/credit-release-authors.cjs` then appends each line's commit author as `(@login)`: a workaround until [googleapis/release-please#2892](https://github.com/googleapis/release-please/pull/2892) ships. -3. Release jobs: `build-and-attach` (marketplace bundle), `build-per-tool` (nine distributions), `build-plugin` (one archive per released path), `publish-cli`. -4. Archives are staged outside the repo tree, uploaded with `gh release upload --clobber`. -5. `back-merge.yml` folds `main` into `next`. +```mermaid +flowchart TD + RP["release-please PR, auto-merged"] --> Tags["tags + umbrella release as draft"] + Tags --> Back["back-merge.yml: main into next"] + Tags --> Jobs["attach: bundle, 9 distributions, plugin archives, video"] + Tags --> Cli["publish-cli: npm"] + Jobs --> Pub["publish-release: draft to latest"] + Cli --> Recap + Pub --> Recap["release-recap: job log"] + Pub --> N8N["n8n workflow"] + N8N --> YT["YouTube"] + YT --> DC["Discord"] + YT --> SK["Skool"] + DC --> Slack["Slack recap"] + SK --> Slack +``` + +- The Release PR merges with `--merge --admin`: the only method `.github/rulesets/main.json` allows, so `main` never holds merged but unversioned code. +- Tags: umbrella, `cli-v`, `-v`. `scripts/credit-release-authors.cjs` appends each author as `(@login)`, a workaround until [googleapis/release-please#2892](https://github.com/googleapis/release-please/pull/2892) ships. +- The umbrella stays a draft until every asset is attached; its publication is the event n8n listens to. +- A failed video build does not block publication, and a failed YouTube upload does not stop Discord and Skool: they post without the video link. Config: `release-please-config.json`, eleven packages. Manifest: `.release-please-manifest.json`. ## Gotchas -- `build-per-tool` builds the CLI from this run's own checkout (`cd cli && pnpm install && pnpm build`, then `node cli/dist/cli.js translate`) rather than pinning a published version — no version to bump, and nothing can go stale the way the old `@ai-driven-dev/cli@5.1.1 framework build` pin did once `framework build` was replaced by `translate`. +- `build-per-tool` builds the CLI from this run's own checkout (`pnpm build`, then `node cli/dist/cli.js translate`), never a pinned published version: nothing to bump, nothing to go stale. ## Monitoring -None. Failures surface as a red run; `back-merge.yml` opens a tracking issue when it cannot push, so drift is never silent. +None. Failures are a red run; `back-merge.yml` opens an issue when it cannot push. diff --git a/aidd_docs/release-video/scenes.json b/aidd_docs/release-video/scenes.json new file mode 100644 index 000000000..48f32ffc5 --- /dev/null +++ b/aidd_docs/release-video/scenes.json @@ -0,0 +1,172 @@ +{ + "scenes": [ + { + "template": "terminal-demo", + "pill": "Telemetry", + "title": "Your Codex work,", + "accent": "measured too.", + "lead": "Ask Codex what your work cost and get the full report.", + "surfaces": [ + "aidd telemetry" + ], + "window": { + "label": "Codex · ~/acme-app", + "rows": [ + { + "prompt": "what did my work cost this week?" + }, + { + "text": "$ aidd telemetry report --tool codex --axis task", + "tone": "dim" + }, + { + "table": { + "head": [ + "Task", + "Requests", + "Usage" + ], + "rows": [ + [ + "fix the login bug", + "38", + "120k" + ], + [ + "add dark mode", + "41", + "190k" + ], + [ + "no task declared", + "17", + "100k" + ] + ] + } + } + ] + }, + "note": "Example figures." + }, + { + "template": "terminal-demo", + "pill": "Telemetry", + "title": "Every tool,", + "accent": "told apart.", + "lead": "See Claude and Codex side by side, each with its own total.", + "surfaces": [ + "aidd-telemetry:00-init" + ], + "window": { + "label": "Terminal · ~/acme-app", + "rows": [ + { + "text": "$ aidd telemetry report --axis tool" + }, + { + "table": { + "head": [ + "Tool", + "Requests", + "Usage" + ], + "rows": [ + [ + "Claude Code", + "412", + "1.8M" + ], + [ + "Codex", + "96", + "410k" + ] + ] + } + } + ] + }, + "note": "Example figures." + }, + { + "template": "terminal-demo", + "pill": "Rules", + "title": "No more rules", + "accent": "that clash.", + "surfaces": [ + "aidd-context:05-rule-generate" + ], + "window": { + "label": "Claude Code · ~/acme-app", + "rows": [ + { + "prompt": "/aidd-context:05-rule-generate always use named exports" + }, + { + "text": "checking your existing rules…", + "tone": "dim" + }, + { + "text": "Conflict: another rule allows default exports", + "tone": "red" + }, + { + "text": "Which one should win? 1 named exports 2 default exports", + "tone": "amb" + }, + { + "text": "nothing is saved until you decide", + "tone": "grn" + } + ] + }, + "note": "Example run.", + "lead": "Before saving a new rule, your AI checks it against the others." + }, + { + "template": "contributors", + "pill": "Team", + "title": "Shipped by", + "accent": "2 contributors.", + "logins": [ + "alexsoyes", + "blafourcade" + ] + } + ], + "skipped": [ + { + "surface": "aidd-telemetry:01-usage", + "reason": "internal report format change; the command and the answer a user reads are the same" + } + ], + "intro": { + "highlights": [ + "Telemetry now covers Codex", + "Telemetry report split by tool", + "Rules checked for conflicts" + ] + }, + "youtube": { + "themes": [ + "Télémétrie", + "Codex", + "Règles" + ], + "programme": [ + { + "surface": "aidd telemetry", + "text": "compter aussi ce que coûte le travail fait avec Codex, avec le rapport complet." + }, + { + "surface": "aidd-telemetry:00-init", + "text": "activer la mesure et distinguer le résultat par outil, Claude Code ou Codex." + }, + { + "surface": "aidd-context:05-rule-generate", + "text": "vérifier qu'une règle n'en contredit pas une autre, et décider laquelle garder." + } + ] + } +} diff --git a/release-please-config.json b/release-please-config.json index fafcbfab7..bd7d523ce 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -15,6 +15,8 @@ ], "packages": { ".": { + "draft": true, + "force-tag-creation": true, "extra-files": [ { "type": "json", diff --git a/scripts/__tests__/check-release-video.test.js b/scripts/__tests__/check-release-video.test.js new file mode 100644 index 000000000..eb0230efe --- /dev/null +++ b/scripts/__tests__/check-release-video.test.js @@ -0,0 +1,353 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + invalidSkips, + jargonIn, + missingScenes, + overlongText, + run, + splitThemes, + surfacesOf, +} = require("../../.claude/skills/release-video/assets/check-release-video.js"); + +test("surfacesOf names a plugin skill by plugin and skill folder", () => { + assert.deepEqual( + surfacesOf([ + "plugins/aidd-dev/skills/10-batch/SKILL.md", + "plugins/aidd-dev/skills/10-batch/actions/run.md", + "plugins/aidd-vcs/skills/05-resolve-conflict/SKILL.md", + ]), + ["aidd-dev:10-batch", "aidd-vcs:05-resolve-conflict"] + ); +}); + +test("surfacesOf names a CLI command by its binary subcommand", () => { + assert.deepEqual( + surfacesOf(["cli/src/presentation/commands/telemetry.ts"]), + ["aidd telemetry"] + ); +}); + +test("surfacesOf ignores files that are not a user-facing surface", () => { + assert.deepEqual( + surfacesOf([ + "README.md", + "plugins/aidd-dev/README.md", + "plugins/aidd-dev/CATALOG.md", + "plugins/aidd-dev/.claude-plugin/plugin.json", + "cli/src/presentation/commands/global-options.ts", + "cli/src/kernel/paths.ts", + "scripts/check-release-video.js", + ]), + [] + ); +}); + +test("missingScenes lists a surface that no scene shows and no skip explains", () => { + const plan = { + scenes: [{ template: "terminal-demo", surfaces: ["aidd telemetry"] }], + skipped: [{ surface: "aidd-vcs:05-resolve-conflict", reason: "internal" }], + }; + + assert.deepEqual( + missingScenes( + ["aidd telemetry", "aidd-dev:10-batch", "aidd-vcs:05-resolve-conflict"], + plan + ), + ["aidd-dev:10-batch"] + ); +}); + +test("invalidSkips rejects a skip with no reason", () => { + const plan = { + scenes: [], + skipped: [ + { surface: "aidd-dev:10-batch", reason: " " }, + { surface: "aidd-vcs:05-resolve-conflict", reason: "internal" }, + ], + }; + + assert.deepEqual(invalidSkips(plan), ["aidd-dev:10-batch"]); +}); + +test("run fails and names every missing scene", () => { + const errors = []; + const code = run({ + surfaces: ["aidd-dev:10-batch", "aidd telemetry"], + plan: { scenes: [], skipped: [] }, + logger: (line) => errors.push(line), + successLogger: () => {}, + }); + + assert.equal(code, 1); + assert.match(errors.join("\n"), /Scènes manquantes : aidd-dev:10-batch, aidd telemetry/); +}); + +test("run passes when there is nothing new to show", () => { + const code = run({ + surfaces: [], + plan: { scenes: [], skipped: [] }, + logger: () => {}, + successLogger: () => {}, + }); + + assert.equal(code, 0); +}); + +test("run fails on a skip with no reason even when every surface is covered", () => { + const errors = []; + const code = run({ + surfaces: ["aidd-dev:10-batch"], + plan: { + scenes: [], + skipped: [{ surface: "aidd-dev:10-batch", reason: "" }], + }, + logger: (line) => errors.push(line), + successLogger: () => {}, + }); + + assert.equal(code, 1); + assert.match(errors.join("\n"), /sans raison : aidd-dev:10-batch/); +}); + +/** A hook runs with GIT_DIR and GIT_INDEX_FILE set: a git command that inherits them acts on the + * repository being committed, not on the one a test made for itself. */ +const withoutGitEnv = () => + Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))); + +function makeRepo() { + const fs = require("node:fs"); + const os = require("node:os"); + const path = require("node:path"); + const { execFileSync } = require("node:child_process"); + + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "release-video-")); + const git = (...args) => + execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...args], { + cwd: repo, + env: withoutGitEnv(), + stdio: "pipe", + }); + const commit = (subject, file) => { + fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true }); + fs.writeFileSync(path.join(repo, file), subject); + git("add", "."); + git("commit", "-q", "-m", subject); + }; + + git("init", "-q"); + commit("feat: before the tag", "plugins/aidd-dev/skills/01-old/SKILL.md"); + git("tag", "v1.0.0"); + commit("feat(aidd-dev): after the tag", "plugins/aidd-dev/skills/10-batch/SKILL.md"); + commit("fix: not a feature", "plugins/aidd-pm/skills/02-user-stories/SKILL.md"); + return { repo, git, commit, remove: () => fs.rmSync(repo, { recursive: true, force: true }) }; +} + +test("featSurfaces reads only feat commits made since the last release tag", () => { + const { repo, remove } = makeRepo(); + const found = require("../../.claude/skills/release-video/assets/check-release-video.js").featSurfaces(repo); + remove(); + + assert.deepEqual(found, ["aidd-dev:10-batch"]); +}); + +test("featSurfaces drops a surface that no longer exists at HEAD", () => { + const { repo, git, remove } = makeRepo(); + git("rm", "-q", "-r", "plugins/aidd-dev/skills/10-batch"); + git("commit", "-q", "-m", "chore: remove the skill again"); + const found = require("../../.claude/skills/release-video/assets/check-release-video.js").featSurfaces(repo); + remove(); + + assert.deepEqual(found, []); +}); + +test("featSurfaces reads the repository it is given even when a hook set GIT_DIR", () => { + const { repo, remove } = makeRepo(); + const decoy = makeRepo(); + decoy.commit("feat: only the decoy has this", "plugins/aidd-vcs/skills/05-decoy/SKILL.md"); + process.env.GIT_DIR = `${decoy.repo}/.git`; + try { + assert.deepEqual(require("../../.claude/skills/release-video/assets/check-release-video.js").featSurfaces(repo), ["aidd-dev:10-batch"]); + } finally { + delete process.env.GIT_DIR; + remove(); + decoy.remove(); + } +}); + +test("a video that shows five of the twelve surfaces a real release shipped is refused", () => { + const plan = require("./fixtures/release-video-scenes.json"); + const shipped = [ + "aidd telemetry", + "aidd-context:00-onboard", + "aidd-context:12-cook", + "aidd-dev:01-plan", + "aidd-dev:10-batch", + "aidd-orchestrator:00-async-dev", + "aidd-orchestrator:01-sdlc", + "aidd-orchestrator:02-backlog", + "aidd-pm:04-spec", + "aidd-telemetry:00-init", + "aidd-telemetry:01-usage", + "aidd-vcs:05-resolve-conflict", + ]; + + assert.deepEqual(missingScenes(shipped, plan), [ + "aidd-context:00-onboard", + "aidd-context:12-cook", + "aidd-dev:01-plan", + "aidd-orchestrator:00-async-dev", + "aidd-orchestrator:02-backlog", + "aidd-pm:04-spec", + "aidd-telemetry:00-init", + ]); +}); + +const words = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(" "); + +test("overlongText accepts a short intro and a short lead", () => { + const plan = { + intro: { tagline: words(6), highlights: [words(5), words(5), words(5)] }, + scenes: [{ lead: words(14) }], + skipped: [], + }; + + assert.deepEqual(overlongText(plan), []); +}); + +test("overlongText names the intro tagline that is too long", () => { + assert.deepEqual(overlongText({ intro: { tagline: words(7) }, scenes: [], skipped: [] }), [ + "intro tagline: 7 words, 6 at most", + ]); +}); + +test("overlongText names a highlight that is too long and too many highlights", () => { + const plan = { intro: { highlights: [words(6), "a", "b", "c"] }, scenes: [], skipped: [] }; + + assert.deepEqual(overlongText(plan), ["intro highlights: 4, 3 at most", "intro highlight 1: 6 words, 5 at most"]); +}); + +test("overlongText names the scene whose lead is too long", () => { + const plan = { scenes: [{ lead: "short" }, { lead: words(15) }], skipped: [] }; + + assert.deepEqual(overlongText(plan), ["scene 2 lead: 15 words, 14 at most"]); +}); + +test("run fails on a text that is too long even when every surface is covered", () => { + const errors = []; + const code = run({ + surfaces: [], + plan: { intro: { tagline: words(9) }, scenes: [], skipped: [] }, + logger: (line) => errors.push(line), + successLogger: () => {}, + }); + + assert.equal(code, 1); + assert.match(errors.join("\n"), /Textes trop longs : intro tagline: 9 words, 6 at most/); +}); + +test("jargonIn names the technical word a viewer would not know, and where it is", () => { + const plan = { + intro: { tagline: "Count your tokens", highlights: ["Payloads for Codex"] }, + scenes: [{ title: "A clean", accent: "envelope.", lead: "Read the transcript." }], + skipped: [], + }; + + assert.deepEqual(jargonIn(plan), [ + 'intro tagline: "tokens"', + 'intro highlight 1: "payloads"', + 'scene 1 accent: "envelope"', + 'scene 1 lead: "transcript"', + ]); +}); + +test("jargonIn leaves plain words, the word telemetry and the demo window alone", () => { + const plan = { + intro: { tagline: "Know what your AI costs.", highlights: ["Telemetry for Claude and Codex"] }, + scenes: [{ title: "See the cost", lead: "One report.", window: { rows: [{ text: "$ aidd telemetry report" }] } }], + skipped: [], + }; + + assert.deepEqual(jargonIn(plan), []); +}); + +test("run fails on a technical word in the text a viewer reads", () => { + const errors = []; + const code = run({ + surfaces: [], + plan: { intro: { tagline: "Your tokens" }, scenes: [], skipped: [] }, + logger: (line) => errors.push(line), + successLogger: () => {}, + }); + + assert.equal(code, 1); + assert.match(errors.join("\n"), /Mots techniques : intro tagline: "tokens"/); +}); + +test("jargonIn catches every technical word it lists, singular or plural", () => { + const plan = { + scenes: [{ title: "axis payloads hooks", accent: "token envelope transcripts" }], + skipped: [], + }; + + assert.deepEqual(jargonIn(plan), [ + 'scene 1 title: "axis"', + 'scene 1 title: "payloads"', + 'scene 1 title: "hooks"', + 'scene 1 accent: "token"', + 'scene 1 accent: "envelope"', + 'scene 1 accent: "transcripts"', + ]); +}); + +test("splitThemes names a theme whose scenes are separated by another theme", () => { + const plan = { + scenes: [{ pill: "Telemetry" }, { pill: "Rules" }, { pill: "Telemetry" }, { pill: "Rules" }], + skipped: [], + }; + + assert.deepEqual(splitThemes(plan), ["Telemetry", "Rules"]); +}); + +test("splitThemes leaves a theme whose scenes follow each other", () => { + const plan = { scenes: [{ pill: "Telemetry" }, { pill: "Telemetry" }, { pill: "Rules" }], skipped: [] }; + + assert.deepEqual(splitThemes(plan), []); +}); + +test("run fails on a theme split in two parts of the video", () => { + const errors = []; + const code = run({ + surfaces: [], + plan: { scenes: [{ pill: "Telemetry" }, { pill: "Rules" }, { pill: "Telemetry" }], skipped: [] }, + logger: (line) => errors.push(line), + successLogger: () => {}, + }); + + assert.equal(code, 1); + assert.match(errors.join("\n"), /Thèmes dispersés : Telemetry/); +}); + +test("overlongText accepts an intro made of tags alone", () => { + assert.deepEqual(overlongText({ intro: { highlights: ["Telemetry for Codex"] }, scenes: [], skipped: [] }), []); +}); + +test("run fails when a plan with scenes has no YouTube text to publish", () => { + const errors = []; + const code = run({ + surfaces: ["aidd telemetry"], + plan: { scenes: [{ template: "terminal-demo", surfaces: ["aidd telemetry"] }], skipped: [] }, + logger: (line) => errors.push(line), + successLogger: () => {}, + }); + + assert.equal(code, 1); + assert.match(errors.join("\n"), /Texte YouTube : youtube: missing/); +}); + +test("the plan read from disk keeps its YouTube text", () => { + const { readPlan } = require("../../.claude/skills/release-video/assets/check-release-video.js"); + + assert.ok(readPlan().youtube?.themes.length > 0); +}); diff --git a/scripts/__tests__/ci-renders-the-release-video.test.js b/scripts/__tests__/ci-renders-the-release-video.test.js new file mode 100644 index 000000000..1a6e3a888 --- /dev/null +++ b/scripts/__tests__/ci-renders-the-release-video.test.js @@ -0,0 +1,96 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const yaml = require("js-yaml"); + +const ci = yaml.load(fs.readFileSync(path.resolve(__dirname, "../../.github/workflows/ci.yml"), "utf8")); +const job = ci.jobs["build-release-video"]; +const script = (job?.steps ?? []).map((step) => step.run ?? "").join("\n"); + +test("a release renders its video once the release exists", () => { + assert.deepEqual(job.needs, ["release-please"]); + assert.equal(job.if, "needs.release-please.outputs.release_created == 'true'"); +}); + +test("the video is rendered for the release's major.minor and attached to its tag", () => { + assert.match(script, /render-release-video\.cjs --version "\$\{MINOR\}"/); + assert.match(script, /MINOR="\$\{VERSION%\.\*\}"/); + assert.match(script, /gh release upload "\$\{TAG\}" release-video\.mp4 release-video\.json --clobber/); +}); + +test("the YouTube title and description are written for the tag and attached with the video", () => { + assert.match(script, /youtube-metadata\.js --version "\$\{MINOR\}" --tag "\$\{TAG\}" --repo "\$\{\{ github\.repository \}\}"/); +}); + +test("the job installs ffmpeg and may write the release asset", () => { + assert.match(script, /apt-get install -y ffmpeg/); + assert.equal(job.permissions.contents, "write"); +}); + +const config = require("../../release-please-config.json"); +const publish = ci.jobs["publish-release"]; +const publishScript = (publish?.steps ?? []).map((step) => step.run ?? "").join("\n"); +const pin = ci.jobs["release-please"].steps.find((step) => step.name === "Pin umbrella release as latest"); + +test("only the umbrella release is a draft, and its tag exists from the start", () => { + assert.equal(config.packages["."].draft, true); + assert.equal(config.packages["."]["force-tag-creation"], true); + for (const [name, options] of Object.entries(config.packages)) { + if (name !== ".") assert.equal(options.draft, undefined, `${name} must publish at once`); + } +}); + +test("a draft cannot be the latest release, so latest is set when it is published", () => { + assert.equal(pin, undefined); + assert.match(publishScript, /gh release edit "\$\{TAG\}" --draft=false --latest/); +}); + +test("the release is published once its bundle and distributions are attached, video or not", () => { + assert.deepEqual(publish.needs.sort(), [ + "build-and-attach", + "build-per-tool", + "build-plugin", + "build-release-video", + "release-please", + ]); + assert.match(publish.if, /always\(\)/); + assert.match(publish.if, /needs\.build-and-attach\.result == 'success'/); + assert.match(publish.if, /needs\.build-per-tool\.result == 'success'/); + assert.doesNotMatch(publish.if, /build-release-video/); +}); + +test("publishing uses the App token so the release event reaches the other workflows", () => { + const uses = publish.steps.map((step) => step.uses ?? "").join("\n"); + assert.match(uses, /create-github-app-token/); + assert.match(JSON.stringify(publish.steps), /steps\.app-token\.outputs\.token/); +}); + +const recapJob = ci.jobs["release-recap"]; + +test("every release leaves a recap, whatever happened to its jobs", () => { + assert.match(recapJob.if, /always\(\)/); + assert.match(recapJob.if, /release_created == 'true'/); + assert.deepEqual(recapJob.needs.sort(), [ + "build-and-attach", + "build-per-tool", + "build-plugin", + "build-release-video", + "publish-cli", + "publish-release", + "release-please", + ]); +}); + +test("the recap reads every job's result and posts nowhere, n8n owns the Slack message", () => { + const step = recapJob.steps.find((s) => (s.run ?? "").includes("scripts/release-recap.js")); + + assert.equal(step.env.NEEDS, "${{ toJSON(needs) }}"); + assert.equal(step.env.SLACK_WEBHOOK_URL, undefined); +}); + +test("the recap checks out the whole history so it can compare with the previous release", () => { + const checkout = recapJob.steps.find((s) => (s.uses ?? "").startsWith("actions/checkout")); + + assert.equal(checkout.with?.["fetch-depth"], 0); +}); diff --git a/scripts/__tests__/fixtures/release-video-scenes.json b/scripts/__tests__/fixtures/release-video-scenes.json new file mode 100644 index 000000000..7237f291d --- /dev/null +++ b/scripts/__tests__/fixtures/release-video-scenes.json @@ -0,0 +1,116 @@ +{ + "scenes": [ + { + "template": "terminal-demo", + "pill": "/aidd-telemetry:01-usage", + "title": "Know what your work costs,", + "accent": "in tokens.", + "surfaces": ["aidd telemetry", "aidd-telemetry:01-usage"], + "window": { + "label": "Claude Code · ~/acme-app", + "rows": [ + { "prompt": "/aidd-telemetry:01-usage how many tokens did ticket 412 use?" }, + { "text": "$ aidd telemetry report --json", "tone": "dim" }, + { "chips": ["input", "output", "cache read", "cache write"] }, + { "text": "unknown stays unknown, never zero.", "tone": "amb" } + ] + }, + "note": "Every figure comes from the local report. No estimate, no guess." + }, + { + "template": "terminal-demo", + "pill": "/aidd-orchestrator:01-sdlc", + "title": "Deliver end to end.", + "accent": "You approve three times.", + "surfaces": ["aidd-orchestrator:01-sdlc"], + "window": { + "label": "Claude Code · ~/acme-app", + "rows": [{ "prompt": "/aidd-orchestrator:01-sdlc interactive add a dark mode toggle" }] + }, + "flow": [ + { "n": "01", "title": "Frame", "sub": "pause · contract" }, + { "n": "02", "title": "Deliver", "sub": "pause · plan" }, + { "n": "03", "title": "Check", "sub": "pause · outcome" }, + { "title": "Draft PR", "final": true } + ] + }, + { + "template": "terminal-demo", + "pill": "/aidd-dev:10-batch", + "title": "One prompt, parallel agents,", + "accent": "one report.", + "surfaces": ["aidd-dev:10-batch"], + "window": { + "label": "Claude Code · ~/acme-app", + "rows": [ + { "prompt": "/aidd-dev:10-batch fix the README typo, add a --json flag, bump the lint rule" }, + { + "table": { + "head": ["Agent", "Task", "Status", "Result"], + "rows": [ + ["executor-1", "README typo", { "status": "Done" }, { "dim": "1 file changed" }], + ["executor-2", "--json flag", { "status": "Done" }, { "dim": "tests pass" }], + ["executor-3", "lint rule", { "status": "Done" }, { "dim": "lint clean" }] + ] + } + } + ] + }, + "note": "Example run. Renamed from todo; each agent's status and result is reported." + }, + { + "template": "two-panels", + "pill": "/aidd-vcs:05-resolve-conflict", + "title": "Git conflicts, resolved.", + "accent": "Never committed for you.", + "surfaces": ["aidd-vcs:05-resolve-conflict"], + "panels": [ + { "label": "config.yml", "flex": 0.8, "rows": [{ "code": "<<<<<<< HEAD\ntimeout: 30\n=======\ntimeout: 60\n>>>>>>> feat/retry" }] }, + { + "label": "resolve-conflict", + "flex": 1.6, + "rows": [ + { "check": "deterministic conflicts resolved" }, + { "check": "only resolved files staged" }, + { "check": "a real choice? it proposes, then stops", "mark": "!" }, + { "check": "stops before commit" } + ] + } + ] + }, + { + "template": "bullet-grid", + "pill": "docs", + "title": "Docs that", + "accent": "pick for you.", + "cells": [ + { "title": "MCP or CLI, by usage", "detail": "GitLab · Linear · Google Workspace · Sentry · Vercel · Supabase", "wide": true }, + { "title": "Cook recipes refreshed", "detail": "practical guides now validated" }, + { "title": "Rule generation covers OpenCode V1", "detail": "aidd-context" }, + { "title": "Agent instructions, lighter to read", "detail": "less cognitive load" } + ] + }, + { + "template": "bullet-grid", + "pill": "fixes from the community", + "title": "Reported by you,", + "accent": "fixed here.", + "cells": [ + { "title": "Biome crashed on Windows", "detail": "reported by @kevin-lozada-santos" }, + { "title": "Pre-commit failed in Git Bash", "detail": "reported by @goumies-creative" }, + { "title": "Memory README links 404 on Windows", "detail": "reported by @VincentMenard76" }, + { "title": "OpenCode v2: plugins failed to load", "detail": "reported by @ljulgold" }, + { "title": "setup --scope user crashed", "detail": "reported by @dkp-consult" }, + { "title": "Review skipped missing runtime evidence", "detail": "reported by @waewoo" } + ] + }, + { + "template": "contributors", + "pill": "release notes", + "title": "Shipped by", + "accent": "8 contributors.", + "logins": ["blafourcade", "alexsoyes", "kevin-lozada-santos", "goumies-creative", "VincentMenard76", "ljulgold", "dkp-consult", "waewoo"] + } + ], + "skipped": [] +} diff --git a/scripts/__tests__/promote-checks-the-release-video.test.js b/scripts/__tests__/promote-checks-the-release-video.test.js new file mode 100644 index 000000000..1890f74de --- /dev/null +++ b/scripts/__tests__/promote-checks-the-release-video.test.js @@ -0,0 +1,33 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const yaml = require("js-yaml"); + +const promote = yaml.load( + fs.readFileSync(path.resolve(__dirname, "../../.github/workflows/promote.yml"), "utf8"), +); +const open = promote.jobs.promote.steps.find( + (step) => step.name === "Open the promote PR, enable merge auto-merge", +); + +test("promote offers a way to skip the release video check", () => { + const input = promote.on.workflow_dispatch.inputs.skip_video; + + assert.equal(input.type, "boolean"); + assert.equal(input.default, false); + assert.equal(open.env.SKIP_VIDEO, "${{ inputs.skip_video }}"); +}); + +test("promote checks the release video on next's tip, before it pushes anything", () => { + const check = open.run.indexOf("node .claude/skills/release-video/assets/check-release-video.js"); + const push = open.run.indexOf("git push origin"); + + assert.ok(check > -1, "the check never runs"); + assert.ok(check < push, "the check runs after the snapshot is pushed"); + assert.match(open.run, /git checkout --detach origin\/next/); +}); + +test("the skip is the only way past the check", () => { + assert.match(open.run, /if \[ "\$\{SKIP_VIDEO\}" != "true" \]; then\n\s+git checkout --detach origin\/next\n\s+node \.claude\/skills\/release-video\/assets\/check-release-video\.js\nfi/); +}); diff --git a/scripts/__tests__/release-recap.test.js b/scripts/__tests__/release-recap.test.js new file mode 100644 index 000000000..4ea1e5c8a --- /dev/null +++ b/scripts/__tests__/release-recap.test.js @@ -0,0 +1,76 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { recap, changedVersions } = require("../release-recap.js"); + +const base = { + tag: "v5.13.0", + repo: "ai-driven-dev/framework", + runUrl: "https://github.com/ai-driven-dev/framework/actions/runs/1", + assets: ["bundle.zip", "release-video.mp4"], + needs: { + "build-and-attach": { result: "success" }, + "build-per-tool": { result: "success" }, + "build-plugin": { result: "skipped" }, + "build-release-video": { result: "success" }, + "publish-cli": { result: "skipped" }, + "publish-release": { result: "success" }, + }, +}; + +test("a clean release says it is published and links the release and the run", () => { + const text = recap({ ...base, published: true }); + + assert.match(text, /^Release v5\.13\.0 published/); + assert.ok(text.includes("https://github.com/ai-driven-dev/framework/releases/tag/v5.13.0")); + assert.match(text, /actions\/runs\/1/); + assert.doesNotMatch(text, /failed/); +}); + +test("every job that ran is listed with what happened to it, and skipped ones are left out", () => { + const text = recap({ ...base, published: true }); + + assert.match(text, /✅ Marketplace bundle/); + assert.match(text, /✅ Release video/); + assert.doesNotMatch(text, /Plugin archives/); + assert.doesNotMatch(text, /CLI on npm/); +}); + +test("a failed job is named as failed, and a release left as a draft says so", () => { + const needs = { ...base.needs, "build-per-tool": { result: "failure" }, "publish-release": { result: "skipped" } }; + const text = recap({ ...base, needs, published: false, assets: ["bundle.zip"] }); + + assert.match(text, /^Release v5\.13\.0 NOT published/); + assert.match(text, /❌ Distributions: failed/); + assert.match(text, /draft/); +}); + +test("a failed video is reported even though the release went out", () => { + const needs = { ...base.needs, "build-release-video": { result: "failure" } }; + const text = recap({ ...base, needs, published: true, assets: ["bundle.zip"] }); + + assert.match(text, /^Release v5\.13\.0 published/); + assert.match(text, /❌ Release video: failed/); + assert.match(text, /No release video attached/); +}); + +test("the versions that shipped are one line, the root named framework and the CLI named CLI", () => { + const text = recap({ ...base, published: true, versions: ["framework 5.13.0", "CLI 5.6.0", "aidd-dev 2.8.0"] }); + + assert.match(text, /^Versions: framework 5\.13\.0 · CLI 5\.6\.0 · aidd-dev 2\.8\.0$/m); +}); + +test("no versions leaves no versions line", () => { + assert.doesNotMatch(recap({ ...base, published: true, versions: [] }), /Versions/); +}); + +test("only what changed since the previous release is listed", () => { + const previous = { ".": "5.12.0", cli: "5.5.0", "plugins/aidd-dev": "2.7.0", "plugins/aidd-pm": "2.6.0" }; + const current = { ".": "5.13.0", cli: "5.6.0", "plugins/aidd-dev": "2.8.0", "plugins/aidd-pm": "2.6.0" }; + + assert.deepEqual(changedVersions(current, previous), ["framework 5.13.0", "CLI 5.6.0", "aidd-dev 2.8.0"]); +}); + +test("without a previous release every version is listed", () => { + assert.deepEqual(changedVersions({ ".": "5.13.0", "plugins/aidd-dev": "2.8.0" }, {}), ["framework 5.13.0", "aidd-dev 2.8.0"]); +}); diff --git a/scripts/__tests__/render-release-video.test.js b/scripts/__tests__/render-release-video.test.js new file mode 100644 index 000000000..b52966c1e --- /dev/null +++ b/scripts/__tests__/render-release-video.test.js @@ -0,0 +1,366 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { buildHtml, musicSegments, musicSections, sceneDuration, soundCues, soundTrack, totalDuration } = require("../../.claude/skills/release-video/assets/render-release-video.cjs"); + +const window = (rows) => ({ label: "Claude Code · ~/acme-app", rows }); + +const plan = { + scenes: [ + { + template: "terminal-demo", + pill: "/aidd-telemetry:01-usage", + title: "Know what your work costs,", + accent: "in tokens.", + window: window([ + { prompt: "/aidd-telemetry:01-usage how many tokens did ticket 412 use?" }, + { text: "$ aidd telemetry report --json", tone: "dim" }, + { chips: ["input", "output", "cache read", "cache write"] }, + ]), + note: "Example figures.", + }, + { + template: "terminal-demo", + pill: "/aidd-dev:10-batch", + title: "One prompt,", + accent: "one report.", + window: window([ + { prompt: "/aidd-dev:10-batch fix three things" }, + { table: { head: ["Agent", "Task", "Status"], rows: [["executor-1", "README typo", { status: "Done" }]] } }, + ]), + }, + { + template: "terminal-demo", + pill: "/aidd-orchestrator:01-sdlc", + title: "Deliver end to end.", + window: window([{ prompt: "/aidd-orchestrator:01-sdlc add a dark mode toggle" }]), + flow: [ + { n: "01", title: "Frame", sub: "pause · contract" }, + { title: "Draft PR", final: true }, + ], + }, + { + template: "two-panels", + pill: "/aidd-vcs:05-resolve-conflict", + title: "Git conflicts, resolved.", + panels: [ + { label: "config.yml", flex: 0.8, rows: [{ code: "<<<<<<< HEAD\ntimeout: 30" }] }, + { label: "resolve-conflict", flex: 1.6, rows: [{ check: "deterministic conflicts resolved" }, { check: "a real choice", mark: "!" }] }, + ], + }, + { + template: "bullet-grid", + pill: "docs", + title: "Docs that", + accent: "pick for you.", + cells: [{ title: "MCP or CLI, by usage", detail: "GitLab · Linear", wide: true }, { title: "Cook recipes refreshed" }], + }, + { template: "contributors", pill: "release notes", title: "Shipped by", accent: "2 contributors.", logins: ["alice", "bob"] }, + ], + skipped: [], +}; + +const sum = (scenes) => scenes.reduce((total, scene) => total + sceneDuration(scene), 0); + +test("the intro starts with no version on screen and types it in", () => { + const html = buildHtml(plan, { version: "5.13" }); + + assert.match(html, /<\/span><\/span><\/span>/); + assert.match(html, /const full = "5\.13"/); + assert.match(html, /AIDD v5\.13<\/span>/); +}); + +test("a scene's note appears after its last row, not with its window", () => { + const html = buildHtml(plan, { version: "5.13" }); + const noteAt = Number(/enter\("#s1 \.fin", ([\d.]+)/.exec(html)?.[1]); + const lastRowAt = 5 + 1.3 + 0.45 * (plan.scenes[0].window.rows.length - 1); + + assert.match(html, /

Example figures\.<\/p>/); + assert.ok(noteAt > lastRowAt, `note at ${noteAt}, last row at ${lastRowAt}`); +}); + +test("a window has the traffic-light bar and its label", () => { + const html = buildHtml(plan, { version: "5.13" }); + + assert.match(html, /<\/i><\/i><\/i>Claude Code · ~\/acme-app<\/span>/); +}); + +test("every row kind a scene can hold is drawn", () => { + const html = buildHtml(plan, { version: "5.13" }); + + assert.match(html, /><\/span> \/aidd-telemetry:01-usage how many/); + assert.match(html, /class="chip c1">inputAgent<\/th>/); + assert.match(html, /Done<\/span>/); + assert.match(html, /

<<<<<<< HEAD\ntimeout: 30<\/div>/); + assert.match(html, /!<\/span>a real choice<\/span>/); +}); + +test("a flow draws its steps, arrows and the final one", () => { + const html = buildHtml(plan, { version: "5.13" }); + + assert.match(html, /Frame<\/span>pause · contract<\/span>/); + assert.match(html, /→<\/span>/); + assert.match(html, /step pr it"> <\/span>Draft PR<\/span>/); +}); + +test("a title carries its gradient accent", () => { + const html = buildHtml(plan, { version: "5.13" }); + + assert.match(html, /Know what your work costs, in tokens\.<\/span>/); +}); + +test("panels keep their relative width", () => { + const html = buildHtml(plan, { version: "5.13" }); + + assert.match(html, /style="flex:0\.8"/); + assert.match(html, /style="flex:1\.6"/); +}); + +test("buildHtml renders each scene's title, in order", () => { + const html = buildHtml(plan, { version: "5.13" }); + const at = ["Know what your work costs", "One prompt", "Deliver end to end", "Git conflicts", "Docs that", "Shipped by"].map((t) => + html.indexOf(t) + ); + + assert.ok(at.every((i) => i > -1), `missing title: ${at}`); + assert.deepEqual([...at].sort((a, b) => a - b), at); +}); + +test("buildHtml escapes text so a scene cannot inject markup", () => { + const evil = { + scenes: [{ template: "terminal-demo", pill: "p", title: "", window: window([{ text: "x" }]) }], + skipped: [], + }; + const html = buildHtml(evil, { version: "5.13" }); + + assert.ok(!html.includes("") && !html.includes("x")); +}); + +test("the root duration is intro, scenes and outro added up", () => { + const html = buildHtml(plan, { version: "5.13" }); + + assert.match(html, new RegExp(`id="root"[^>]*data-duration="${5 + sum(plan.scenes) + 4}"`)); +}); + +test("a scene with more rows lasts longer", () => { + const few = { template: "terminal-demo", window: window([{ text: "a" }]) }; + const many = { template: "terminal-demo", window: window([{ text: "a" }, { text: "b" }, { text: "c" }]) }; + + assert.ok(sceneDuration(many) > sceneDuration(few)); +}); + +test("buildHtml fails on an unknown template", () => { + assert.throws( + () => buildHtml({ scenes: [{ template: "spinning-logo" }], skipped: [] }, { version: "5.13" }), + /Unknown template: spinning-logo/ + ); +}); + +test("a contributor avatar points at a file next to the page", () => { + assert.match(buildHtml(plan, { version: "5.13" }), /src="assets\/av-alice\.png"/); +}); + +test("a scene's lead says in plain words what it brings, under its title", () => { + const withLead = { + scenes: [{ ...plan.scenes[0], lead: "Ask what a task cost and get the answer." }], + skipped: [], + }; + const html = buildHtml(withLead, { version: "5.13" }); + + assert.match(html, /<\/h2>

Ask what a task cost and get the answer\.<\/p>/); +}); + +test("a lead gives the scene time to be read", () => { + const bare = plan.scenes[0]; + + assert.ok(sceneDuration({ ...bare, lead: "Ask what a task cost." }) > sceneDuration(bare)); +}); + +test("the intro says what the release holds, in a few words", () => { + const html = buildHtml( + { ...plan, intro: { tagline: "Costs, batches, conflicts.", highlights: ["Token costs", "Parallel agents", "Git conflicts"] } }, + { version: "5.13" }, + ); + + assert.match(html, /

Costs, batches, conflicts\.<\/p>/); + assert.match(html, /Token costs<\/span>Parallel agents<\/span>Git conflicts<\/span>/); +}); + +test("the intro keeps a default tagline when the plan gives none", () => { + assert.match(buildHtml(plan, { version: "5.13" }), /What's new, end to end\.<\/b>/); +}); + +test("a music track plays under the whole video and fades in and out", () => { + const total = 5 + sum(plan.scenes) + 4; + const html = buildHtml(plan, { version: "5.13", music: "assets/music.mp3" }); + const audio = /]*>/.exec(html)?.[0] ?? ""; + + assert.match(audio, /id="music"/); + assert.match(audio, /src="assets\/music\.mp3"/); + assert.match(audio, new RegExp(`data-start="0" data-duration="${total}"`)); + assert.match(audio, /data-automation="[^"]*"target":"volume"/); + assert.ok(audio.includes(`"t":${total}`), "the fade ends with the video"); +}); + +test("no music track, no audio element", () => { + assert.ok(!buildHtml(plan, { version: "5.13" }).includes(" { + const withIntro = { ...plan, intro: { highlights: ["a"] } }; + + assert.equal(totalDuration(plan), 5 + sum(plan.scenes) + 4); + assert.equal(totalDuration(withIntro), 6.5 + sum(plan.scenes) + 4); + assert.match(buildHtml(withIntro, { version: "5.13" }), new RegExp(`data-duration="${totalDuration(withIntro)}"`)); +}); + +test("the music stays under the picture: the fade peaks at the music level, not at full volume", () => { + const audio = /]*>/.exec(buildHtml(plan, { version: "5.13", music: "assets/music.mp3" }))[0]; + + assert.match(audio, /"t":0\.3,"v":0\.5/); + assert.ok(!audio.includes(""v":1}"), "a point at full volume"); +}); + +// Energy of a real track, one value per half second: a silent start, a loud hook, a quiet bed, +// a loud finale that fades out. +const rms = [ + -37, ...Array(11).fill(-10), ...Array(61).fill(-26), ...Array(5).fill(-8), -23, -61, +]; + +test("musicSections finds the hook and the ending of a track, the bed is what lies between", () => { + assert.deepEqual(musicSections(rms), { + headStart: 0.5, + headEnd: 6, + tailStart: 35.5, + tailEnd: 39.5, + }); +}); + +test("a track with no loud hook has no sections", () => { + assert.equal(musicSections(Array(40).fill(-26)), null); +}); + +test("the hook plays under the intro, the bed under the scenes and the outro, the ending closes the video", () => { + const sections = musicSections(rms); + const [head, body, tail] = musicSegments(sections, { intro: 5, total: 30, outro: 4 }); + + assert.deepEqual(head, { part: "head", from: 0.5, length: 5, at: 0 }); + assert.equal(body.part, "body"); + assert.equal(body.at, 5); + assert.equal(body.length, 30 - 4 - 5); + assert.deepEqual(tail, { part: "tail", from: 35.5, length: 4, at: 26 }); +}); + +test("an intro longer than the hook lets the bed start early, never a gap", () => { + const [head, body] = musicSegments(musicSections(rms), { intro: 6.5, total: 30, outro: 4 }); + + assert.equal(head.length, 5.5); + assert.equal(body.at, head.at + head.length); +}); + +const cuesOf = (kind, from, to) => soundCues(plan, { version: "5.13" }).filter((c) => c.kind === kind && c.t >= from && c.t < to); + +test("every sound is on a time of the video, in order", () => { + const cues = soundCues(plan, { version: "5.13" }); + + assert.ok(cues.length > 0); + assert.ok(cues.every((c) => c.t >= 0 && c.t < totalDuration(plan)), "a cue outside the video"); + assert.deepEqual(cues.map((c) => c.t), cues.map((c) => c.t).sort((a, b) => a - b)); +}); + +test("a key tick sounds for each character of the version as it is typed", () => { + const typed = cuesOf("key", 0.8, 1.9); + + assert.equal(typed.length, "5.13".length + 1); +}); + +test("each scene opens with a whoosh at its own start", () => { + const starts = plan.scenes.reduce( + (acc, scene) => [...acc, acc[acc.length - 1] + sceneDuration(scene)], + [5], + ).slice(0, plan.scenes.length); + const whooshes = soundCues(plan, { version: "5.13" }).filter((c) => c.kind === "whoosh").map((c) => c.t); + + assert.deepEqual(whooshes, starts); +}); + +test("a click sounds when each row of a window appears", () => { + const first = plan.scenes[0]; + const start = 5; + const clicks = cuesOf("click", start + 1.3, start + sceneDuration(first)).map((c) => c.t); + const rows = first.window.rows.length; + + assert.deepEqual(clicks, Array.from({ length: rows }, (_, i) => Math.round((start + 1.3 + 0.45 * i) * 100) / 100)); +}); + +test("a pop sounds as each window or item slides in", () => { + const start = 5; + const first = plan.scenes[0]; + const pops = cuesOf("pop", start + 0.6, start + 0.7 + 0.3 * 2).map((c) => c.t); + + assert.ok(first.window, "scene 1 has a window"); + assert.ok(pops.includes(start + 0.63 + 0.3 * 0), "the window pop"); +}); + +test("the outro has no sound of its own: the ending of the music opens it", () => { + assert.equal(soundCues(plan, { version: "5.13" }).filter((c) => c.kind === "chime").length, 0); + assert.equal(cuesOf("pop", totalDuration(plan) - 4, totalDuration(plan)).length, 2); +}); + +test("the sound track is a wav as long as the video, loud on a cue and silent between", () => { + const wav = soundTrack(plan, { version: "5.13" }); + const rate = wav.readUInt32LE(24); + const sample = (seconds) => Math.abs(wav.readInt16LE(44 + 2 * Math.floor(seconds * rate))); + const peakNear = (seconds) => Math.max(...Array.from({ length: 400 }, (_, i) => sample(seconds + i / rate))); + + assert.equal(wav.toString("latin1", 0, 4), "RIFF"); + assert.equal(wav.length, 44 + 2 * Math.round(totalDuration(plan) * rate)); + assert.ok(peakNear(5 + 1.3) > 1000, "silent on a click"); + assert.equal(peakNear(5 + 0.3), 0, "noise between cues"); +}); + +test("the sound track plays under the whole video, quieter than the picture would", () => { + const html = buildHtml(plan, { version: "5.13", sfx: "assets/sfx.wav" }); + const audio = /

Conflict<\/div>/); + assert.match(html, /\.row\.red \{/); +}); + +test("the kit ships its own music, so a render on the CI sounds like a local one", () => { + const kit = require("node:path").join(__dirname, "../../.claude/skills/release-video/assets/music.mp3"); + + assert.ok(require("node:fs").existsSync(kit), "the skill's music.mp3 is missing"); +}); + +test("an intro with tags and no phrase shows the tags alone, from the start of the text", () => { + const withTags = { ...plan, intro: { highlights: ["Telemetry for Codex", "No clashing rules"] } }; + const html = buildHtml(withTags, { version: "5.13" }); + + assert.ok(!html.includes('id="intro-tag"'), "a phrase is drawn"); + assert.match(html, /Telemetry for Codex<\/span>/); + assert.match(html, /enter\("#intro \.hl", 2,/); +}); + +test("no pop is cued for a phrase the intro does not draw", () => { + const withTags = { ...plan, intro: { highlights: ["a", "b"] } }; + const pops = soundCues(withTags, { version: "5.13" }).filter((c) => c.kind === "pop" && c.t < 4).map((c) => c.t); + + assert.deepEqual(pops, [0.1, 2, 2.3]); +}); diff --git a/scripts/__tests__/youtube-metadata.test.js b/scripts/__tests__/youtube-metadata.test.js new file mode 100644 index 000000000..ddcd3abc2 --- /dev/null +++ b/scripts/__tests__/youtube-metadata.test.js @@ -0,0 +1,78 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { youtubeMetadata, youtubeProblems } = require("../../.claude/skills/release-video/assets/youtube-metadata.js"); + +const plan = { + scenes: [ + { template: "terminal-demo", surfaces: ["aidd telemetry"] }, + { template: "terminal-demo", surfaces: ["aidd-context:05-rule-generate"] }, + { template: "contributors", logins: ["alexsoyes", "blafourcade"] }, + ], + skipped: [], + youtube: { + themes: ["Télémétrie", "Codex", "Règles"], + programme: [ + { surface: "aidd telemetry", text: "compter aussi le travail fait avec Codex." }, + { surface: "aidd-context:05-rule-generate", text: "vérifier qu'aucune règle n'en contredit une autre." }, + ], + }, +}; +const context = { version: "5.13", tag: "v5.13.0", repo: "ai-driven-dev/framework" }; + +test("the title names the release and its themes the way the published ones do", () => { + assert.equal(youtubeMetadata(plan, context).title, "Framework - Release 5.13 : Télémétrie, Codex et Règles"); + assert.equal( + youtubeMetadata({ ...plan, youtube: { ...plan.youtube, themes: ["Télémétrie", "Règles"] } }, context).title, + "Framework - Release 5.13 : Télémétrie et Règles", + ); + assert.equal( + youtubeMetadata({ ...plan, youtube: { ...plan.youtube, themes: ["Télémétrie"] } }, context).title, + "Framework - Release 5.13 : Télémétrie", + ); +}); + +test("the description follows the published layout, with commands, contributors and links", () => { + assert.equal( + youtubeMetadata(plan, context).description, + [ + "Les nouveautés du framework AI-Driven Dev 5.13, démontrées de bout en bout.", + "", + "Au programme :", + "- aidd telemetry : compter aussi le travail fait avec Codex.", + "- /aidd-context:05-rule-generate : vérifier qu'aucune règle n'en contredit une autre.", + "", + "Version livrée par 2 contributeurs (crédits dans les notes de version).", + "", + "Notes de version : https://github.com/ai-driven-dev/framework/releases/tag/v5.13.0", + "Code source : https://github.com/ai-driven-dev/framework", + ].join("\n"), + ); +}); + +test("a plugin skill is written as a slash command and a CLI command is left as it is", () => { + const { description } = youtubeMetadata(plan, context); + + assert.match(description, /^- aidd telemetry : /m); + assert.match(description, /^- \/aidd-context:05-rule-generate : /m); +}); + +test("a release with no contributors scene leaves the contributors line out", () => { + const { description } = youtubeMetadata({ ...plan, scenes: plan.scenes.slice(0, 2) }, context); + + assert.doesNotMatch(description, /contributeurs/); +}); + +test("youtubeProblems accepts a plan whose programme covers every scene surface", () => { + assert.deepEqual(youtubeProblems(plan), []); +}); + +test("youtubeProblems names a missing block, a title that is too long and an uncovered surface", () => { + assert.deepEqual(youtubeProblems({ scenes: [], skipped: [] }), ["youtube: missing"]); + + const long = { ...plan, youtube: { ...plan.youtube, themes: ["x".repeat(90)] } }; + assert.match(youtubeProblems(long).join("\n"), /title: \d+ characters, 100 at most/); + + const uncovered = { ...plan, youtube: { ...plan.youtube, programme: plan.youtube.programme.slice(0, 1) } }; + assert.deepEqual(youtubeProblems(uncovered), ["youtube programme: no line for aidd-context:05-rule-generate"]); +}); diff --git a/scripts/release-recap.js b/scripts/release-recap.js new file mode 100644 index 000000000..dc1e0b9b4 --- /dev/null +++ b/scripts/release-recap.js @@ -0,0 +1,73 @@ +const { execFileSync } = require("node:child_process"); + +const STEPS = [ + ["build-and-attach", "Marketplace bundle"], + ["build-per-tool", "Distributions"], + ["build-plugin", "Plugin archives"], + ["build-release-video", "Release video"], + ["publish-cli", "CLI on npm"], + ["publish-release", "Publication"], +]; + +const nameOf = (path) => (path === "." ? "framework" : path === "cli" ? "CLI" : path.replace(/^plugins\//, "")); + +/** The versions that moved since the previous release, or all of them when there is none. */ +function changedVersions(current, previous) { + return Object.entries(current) + .filter(([path, version]) => previous[path] !== version) + .map(([path, version]) => `${nameOf(path)} ${version}`); +} + +/** The message a release leaves in Slack: whether it went out, and what ran or broke on the way. */ +function recap({ tag, repo, runUrl, assets, needs, published, versions = [] }) { + const lines = [`Release ${tag} ${published ? "published" : "NOT published, it stays a draft"}`]; + for (const [job, label] of STEPS) { + const result = needs[job]?.result; + if (!result || result === "skipped") continue; + lines.push(result === "success" ? `✅ ${label}` : `❌ ${label}: ${result === "failure" ? "failed" : result}`); + } + if (!assets.includes("release-video.mp4")) lines.push("No release video attached."); + if (versions.length) lines.push(`Versions: ${versions.join(" · ")}`); + lines.push(`Release: https://github.com/${repo}/releases/tag/${tag}`, `Run: ${runUrl}`); + return lines.join("\n"); +} + +function readManifest(ref) { + return JSON.parse(execFileSync("git", ["show", `${ref}:.release-please-manifest.json`], { encoding: "utf8" })); +} + +/** The manifest of the release before this one, empty when there is none to compare with. */ +function previousManifest(tag) { + try { + const previous = execFileSync("git", ["describe", "--tags", "--abbrev=0", "--match", "v[0-9]*", `${tag}^`], { encoding: "utf8" }).trim(); + return readManifest(previous); + } catch { + return {}; + } +} + +async function main() { + const { NEEDS, TAG, REPO, RUN_URL } = process.env; + const needs = JSON.parse(NEEDS); + const assets = JSON.parse( + execFileSync("gh", ["release", "view", TAG, "--repo", REPO, "--json", "assets", "--jq", "[.assets[].name]"], { + encoding: "utf8", + }), + ); + const text = recap({ + tag: TAG, + repo: REPO, + runUrl: RUN_URL, + assets, + needs, + published: needs["publish-release"]?.result === "success", + versions: changedVersions(readManifest("HEAD"), previousManifest(TAG)), + }); + console.log(text); +} + +module.exports = { recap, changedVersions }; + +if (require.main === module) { + main().catch((error) => console.log(`Recap not printed: ${error.message}`)); +}