diff --git a/.github/workflows/render-video.yml b/.github/workflows/render-video.yml index 5d411cb..0ce8d5b 100644 --- a/.github/workflows/render-video.yml +++ b/.github/workflows/render-video.yml @@ -3,13 +3,20 @@ name: render-video # One job. It asks each kind "is there anything unreleased?" using nothing but # Node, and stops there on a quiet night — no npm ci, no Chrome, no ffmpeg. # When there is something, an agent may pick the story, then it renders every -# rendition the kind makes (landscape, portrait), publishes them to a GitHub -# Release and YouTube, and records the ledger and baselines in ONE commit. Two -# jobs pushing to main from one checkout is a race; keep it one job. +# rendition the kind makes (landscape, portrait), leaves them on a DRAFT GitHub +# Release, writes a posting.anyrouter.dev draft folder per film (the +# `posting-drafts` artifact), and records the ledger and baselines in ONE +# commit. Two jobs pushing to main from one checkout is a race; keep it one job. +# +# Nothing here goes public. The owner reviews the draft Release and presses +# Publish, pushes the posting draft with the AnyRouter repo's +# scripts/posting-draft.ts, and dispatches youtube.yml to upload (private). +# This job holds no YouTube credentials at all; lib/unattended.test.mjs fails +# CI if it ever does, or if an unattended run can publish. # # `regen` re-renders releases that are already out, from their frozen facts, and -# replaces the assets of their GitHub Releases in place. It never uploads to -# YouTube: dispatch youtube.yml for a rendition it added. +# replaces the assets of their GitHub Releases in place. Only an owner dispatch +# can run it. It never uploads to YouTube: dispatch youtube.yml for a rendition it added. on: workflow_dispatch: @@ -173,11 +180,6 @@ jobs: LIMIT: ${{ inputs.limit }} SECONDS_: ${{ inputs.seconds }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - YOUTUBE_CLIENT_ID: ${{ secrets.YOUTUBE_CLIENT_ID }} - YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }} - YOUTUBE_REFRESH_TOKEN: ${{ secrets.YOUTUBE_REFRESH_TOKEN }} - YT_PRIVACY: ${{ vars.YT_PRIVACY }} - YT_TITLE_TEMPLATE: ${{ vars.YT_TITLE_TEMPLATE }} run: | set -uo pipefail ARGS="" @@ -185,13 +187,15 @@ jobs: [ -n "$SECONDS_" ] && ARGS="$ARGS --seconds=$SECONDS_" if [ -n "$REGEN" ]; then [ "$REGEN" = "all" ] && ARGS="$ARGS --all" || ARGS="$REGEN $ARGS" - [ "$SHIP" = "true" ] && ARGS="$ARGS --publish" + # Replaces the assets of a published Release: an owner dispatch only. + [ "$SHIP" = "true" ] && [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && ARGS="$ARGS --publish" CMD=regen else # --reuse: film exactly the facts the decide step (and the agent) saw. ARGS="$ARGS --reuse" [ "$FORCE" = "true" ] && ARGS="$ARGS --force" - [ "$SHIP" = "true" ] && ARGS="$ARGS --publish --accept" + # A draft Release and a posting draft folder; never YouTube, never public. + [ "$SHIP" = "true" ] && ARGS="$ARGS --publish --draft --no-youtube --accept" CMD=run fi FAILED="" @@ -222,6 +226,17 @@ jobs: done echo '::error::could not push the ledger'; exit 1 + # The posting draft folders, for the owner to push into posting.anyrouter.dev + # (npx tsx scripts/posting-draft.ts in the AnyRouter repo). + - name: Keep the posting drafts + if: always() && steps.decide.outputs.kinds != '' + uses: actions/upload-artifact@v4 + with: + name: posting-drafts + path: handoff/ + if-no-files-found: ignore + retention-days: 14 + # The GitHub Release is the store. This copy is for branch runs, which do # not publish, and for debugging a run that failed. - name: Keep the renders diff --git a/.github/workflows/youtube.yml b/.github/workflows/youtube.yml index 2ba6f76..7a94c6b 100644 --- a/.github/workflows/youtube.yml +++ b/.github/workflows/youtube.yml @@ -1,8 +1,9 @@ name: youtube -# Uploads the films of a GitHub Release that are not on YouTube yet: after the -# render-video upload failed (an expired token), or after a regen added a -# rendition (regen never uploads). It renders nothing: it fetches each film the +# Uploads the films of a GitHub Release that are not on YouTube yet, as PRIVATE +# videos. It is the only path to YouTube, and only the owner dispatches it: +# render-video leaves a draft Release and never uploads, and a regen never +# uploads either. It renders nothing: it fetches each film the # ledger row has no YouTube link for, runs `publish`, which uploads those and # skips the rest, and fails unless every rendition then has its link. @@ -66,7 +67,9 @@ jobs: YOUTUBE_CLIENT_ID: ${{ secrets.YOUTUBE_CLIENT_ID }} YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }} YOUTUBE_REFRESH_TOKEN: ${{ secrets.YOUTUBE_REFRESH_TOKEN }} - YT_PRIVACY: ${{ vars.YT_PRIVACY }} + # Always private: the owner makes a video public in YouTube Studio. + # lib/publish.mjs refuses `public` anyway; the repo var is not read. + YT_PRIVACY: private YT_TITLE_TEMPLATE: ${{ vars.YT_TITLE_TEMPLATE }} run: | set -euo pipefail diff --git a/.gitignore b/.gitignore index de305b1..24f365d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ # ship on the GitHub Release, which is the durable public copy. /renders/ +# posting.anyrouter.dev draft folders a draft run writes (lib/handoff.mjs). +/handoff/ + node_modules/ # scratch from an older tool diff --git a/AGENTS.md b/AGENTS.md index 8f25a38..6fcbfcb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ collect → .build//data.json (the facts, from a live public source) gen → .build//film/ (a HyperFrames project, 1920x1080) check → lint + runtime + layout + motion + contrast, for every rendition render → renders/-.mp4 (+ -portrait.mp4, a cover each, + source tarball) -publish → a GitHub Release tagged -, and one YouTube video per rendition +publish → a GitHub Release tagged - (a DRAFT on every unattended run), and, by owner dispatch only, one private YouTube video per rendition ``` Same inputs, same mp4, every time. `npm run video -- ` is the whole diff --git a/README.md b/README.md index 357a517..b73eb67 100644 --- a/README.md +++ b/README.md @@ -205,19 +205,29 @@ repo, and by hand (pick a kind, optionally force). One job: OpenCode reads the facts and the brief and writes `.build//plan.json`: which items lead, the copy, or `limit: 0` to decline. Skipped without `OPENCODE_API_KEY`, and it can never fail the build. -4. **render → publish → accept**, one kind at a time. One kind failing does not - stop the others. +4. **render → draft → accept**, one kind at a time (`--publish --draft + --no-youtube`). Each film lands on a **draft** GitHub Release, and a + posting.anyrouter.dev draft folder per film goes in the `posting-drafts` + artifact. One kind failing does not stop the others. 5. **record** — one commit with the ledger and the baseline. -Releases and commits only happen on `main`. A run on a branch renders and keeps +Nothing this job does is public. The owner reviews the draft Release and +presses Publish, pushes the posting draft with the AnyRouter repo's +`npx tsx scripts/posting-draft.ts `, and dispatches `youtube.yml`. The job +holds no YouTube credentials; `lib/publish.mjs` refuses a YouTube upload or a +non-draft Release on a `schedule` or `repository_dispatch` run, and +`lib/unattended.test.mjs` fails CI if a workflow could do either. + +Draft Releases and commits only happen on `main`. A run on a branch renders and keeps the mp4 as a workflow artifact. By hand, the `regen` input (a release id, or `all`, with a kind) re-renders released films instead and replaces their Release assets. -**`youtube.yml`** — by hand, with a kind and a release id: uploads each film of -that release that is not on YouTube yet (an upload that failed, or a portrait -film a regen added) and commits the links. +**`youtube.yml`** — by the owner only, with a kind and a release id: uploads each +film of that release that is not on YouTube yet as a **private** video, and +commits the links. The owner makes a video public in YouTube Studio. +`YT_PRIVACY` accepts only `private` or `unlisted`. **`ci.yml`** — on pull requests and pushes that touch the code: the unit tests, then the HyperFrames checker on each kind built from its committed fixture. diff --git a/lib/cli.mjs b/lib/cli.mjs index 9c98b4a..8ac39fd 100644 --- a/lib/cli.mjs +++ b/lib/cli.mjs @@ -26,7 +26,10 @@ * --rendition=landscape|portrait: one rendition only. For run and regen that is a * dev run: it renders, and records and publishes nothing. * Flags for publish: --replace (swap the assets of an existing Release; never YouTube) + * --draft (create the Release as a draft; nothing goes public) * --no-youtube --dry-run + * A run nobody started by hand (schedule, repository_dispatch) must pass + * --draft --no-youtube: publish refuses anything else (lib/publish.mjs). * Anything else (--limit, --seconds, --from, …) is passed to the kind. * * A release is identified by WHAT it is about, not by when it ran: same facts → @@ -43,6 +46,7 @@ import { writeFilm, OPEN } from './film.mjs'; import * as ledger from './ledger.mjs'; import * as hyperframes from './hyperframes.mjs'; import { publish } from './publish.mjs'; +import { handoff } from './handoff.mjs'; import { checkPlan, SCHEMA } from './plan.mjs'; import { RENDITIONS, IDS, rendition } from './renditions.mjs'; @@ -349,7 +353,7 @@ async function publishKind(kind, id, flags) { const entry = id ? ledger.find(kind.id, id) : unpublished(kind); if (id && !entry) throw new Error(`no ${kind.id} release "${id}"`); if (!entry) { out(`${kind.id}: nothing to publish`); return; } - await publish(kind, entry, { dry: !!flags['dry-run'], youtube: !flags['no-youtube'], replace: !!flags.replace }); + await publish(kind, entry, { dry: !!flags['dry-run'], youtube: !flags['no-youtube'], replace: !!flags.replace, draft: !!flags.draft }); } function printLedger(ids) { @@ -357,7 +361,7 @@ function printLedger(ids) { const rows = ledger.list(id); out(`${id} [${rows.length} release${rows.length === 1 ? '' : 's'}]`); for (const r of rows) { - const state = r.published?.github ? 'published' : r.declined ? 'declined' : r.renderedAt ? 'rendered' : 'recorded'; + const state = r.published?.draft ? 'draft' : r.published?.github ? 'published' : r.declined ? 'declined' : r.renderedAt ? 'rendered' : 'recorded'; const films = Object.keys(r.renditions ?? {}).join('+'); out(` ${r.id.padEnd(46)} ${state.padEnd(10)} ${films.padEnd(19)} ${r.title ?? ''}`); } @@ -389,6 +393,13 @@ async function main() { if (flags.publish) { const ids = results.map(r => r.id).filter(Boolean); for (const id of ids.length ? ids : [null]) await publishKind(k, id, flags); + // A draft is for the owner to review: hand each film to posting as a draft too. + // A failed handoff loses no film (the draft Release has it), so it only warns. + if (flags.draft) { + for (const id of ids) { + try { handoff(k, ledger.find(k.id, id)); } catch (e) { warn('posting handoff failed', `${k.id} ${id}: ${e.message}`); } + } + } } // Only after everything above succeeded, so a failed render can never // silently swallow the next release's diff. diff --git a/lib/handoff.mjs b/lib/handoff.mjs new file mode 100644 index 0000000..15d5fa9 --- /dev/null +++ b/lib/handoff.mjs @@ -0,0 +1,69 @@ +/** + * handoff — turn a rendered release into a posting.anyrouter.dev draft folder. + * + * This repo never posts. The nightly run leaves a draft GitHub Release and, per + * release, `handoff//` (post.md plus the landscape film and its cover) in + * the run's `posting-drafts` artifact. The owner, or an agent, pushes that + * folder into posting as a DRAFT with the AnyRouter repo's + * `npx tsx scripts/posting-draft.ts `; the owner reviews and publishes + * it there. The folder format is that repo's .claude/skills/posting-draft/SKILL.md. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { STATE } from './paths.mjs'; +import { load, tagOf } from './ledger.mjs'; +import { releaseFiles } from './publish.mjs'; + +export const HANDOFF = path.join(STATE, 'handoff'); + +const yamlString = s => JSON.stringify(String(s)); + +/** The post.md text: facts and AI copy are a starting point the owner edits in review. */ +export function postMarkdown({ tag, headline, link, share, notes, film, cover }) { + const media = [film, cover].filter(Boolean); + return [ + '---', + `external_ref: ${yamlString(tag)}`, + `title: ${yamlString(headline)}`, + `link: ${yamlString(link)}`, + `media: [${media.map(yamlString).join(', ')}]`, + '---', + [headline, ...notes].join('\n\n'), + '', + '## x', + [share || headline, link].join('\n\n'), + '', + '## youtube', + headline.slice(0, 100), + [share, link].filter(Boolean).join('\n\n'), + '', + ].join('\n'); +} + +/** Writes handoff// for one rendered release and returns the folder. */ +export function handoff(kind, entry) { + const { data, plan } = load(kind.id, entry.id); + const tag = tagOf(kind.id, entry.id); + const lead = releaseFiles(kind, entry).find(f => f.onDisk); + if (!lead) throw new Error(`${tag}: no film on disk to hand off`); + const dir = path.join(HANDOFF, tag); + fs.mkdirSync(dir, { recursive: true }); + const film = path.basename(lead.name); + fs.copyFileSync(lead.file, path.join(dir, film)); + let cover = null; + if (lead.cover && fs.existsSync(lead.cover)) { + cover = path.basename(lead.coverName); + fs.copyFileSync(lead.cover, path.join(dir, cover)); + } + fs.writeFileSync(path.join(dir, 'post.md'), postMarkdown({ + tag, + headline: kind.headline(data, plan), + link: kind.link?.(data) ?? 'https://anyrouter.dev', + share: plan.share, + notes: kind.notes(data, plan), + film, + cover, + })); + console.log(`handoff ${path.relative(STATE, dir)}/ (push it to posting with scripts/posting-draft.ts)`); + return dir; +} diff --git a/lib/paths.mjs b/lib/paths.mjs index f1f83c1..590b9de 100644 --- a/lib/paths.mjs +++ b/lib/paths.mjs @@ -15,7 +15,7 @@ export const SHARED = path.join(ROOT, 'shared'); export const VIDEOS = path.join(ROOT, 'videos'); // What a run writes: scratch, films, the ledger. Tests point VIDEO_STATE at a // temp dir so they never touch the real ledger. -const STATE = process.env.VIDEO_STATE || ROOT; +export const STATE = process.env.VIDEO_STATE || ROOT; export const BUILD = path.join(STATE, '.build'); export const RENDERS = path.join(STATE, 'renders'); export const RELEASES = path.join(STATE, 'releases'); diff --git a/lib/publish.mjs b/lib/publish.mjs index 9b557ac..8a76688 100644 --- a/lib/publish.mjs +++ b/lib/publish.mjs @@ -19,6 +19,13 @@ * rendition is its own video: it is uploaded once, and its link is recorded on * its film in the ledger row. The portrait one becomes a Short. * + * ── who may publish ─────────────────────────────────────────────────────── + * Nothing goes public without the owner. A run nobody started by hand (the + * nightly schedule, a repository_dispatch) may only leave a DRAFT Release and + * never touches YouTube; the owner publishes the draft and dispatches + * youtube.yml. YouTube privacy is `private` (default) or `unlisted`, never + * `public`. See lib/unattended.test.mjs for the workflow-side guard. + * * ── replace ─────────────────────────────────────────────────────────────── * A re-rendered release (`regen`) swaps the assets of its existing Release in * place and refreshes the title and notes. It never uploads to YouTube: a video @@ -33,6 +40,18 @@ import { RENDITIONS } from './renditions.mjs'; const mb = f => (fs.statSync(f).size / 1048576).toFixed(1); +/** GitHub events no person started. A run on one of these may only render and leave a draft. */ +export const UNATTENDED_EVENTS = ['schedule', 'repository_dispatch']; +export const isUnattended = (env = process.env) => UNATTENDED_EVENTS.includes(env.GITHUB_EVENT_NAME); + +/** The only YouTube privacy values allowed. `public` is the owner's call, made in YouTube Studio. */ +export const YT_PRIVACIES = ['private', 'unlisted']; +export function youtubePrivacy(env = process.env) { + const v = env.YT_PRIVACY || 'private'; + if (!YT_PRIVACIES.includes(v)) throw new Error(`YT_PRIVACY must be ${YT_PRIVACIES.join(' or ')}, not "${v}"`); + return v; +} + /** * The films of a release by the names they have on its GitHub Release, one per * rendition in table order. @@ -50,9 +69,17 @@ export function releaseFiles(kind, entry) { }); } -export async function publish(kind, entry, { dry = false, youtube: wantYouTube = true, replace = false } = {}, +export async function publish(kind, entry, { dry = false, youtube: wantYouTube = true, replace = false, draft = false } = {}, clients = { github, youtube }) { if (!entry.renderedAt) throw new Error(`${entry.id} is recorded but not rendered`); + if (isUnattended()) { + const why = `an unattended run (${process.env.GITHUB_EVENT_NAME})`; + if (wantYouTube && !replace) throw new Error(`${why} never uploads to YouTube: pass --no-youtube`); + if (!draft) throw new Error(`${why} may only create a draft Release: pass --draft`); + if (replace) throw new Error(`${why} never replaces a Release; regen is an owner dispatch`); + } + // Checked before anything is sent, so a bad value never leaves half a release behind. + const privacy = wantYouTube && !replace ? youtubePrivacy() : null; const out = !!entry.published?.github; const films = releaseFiles(kind, entry); // A film that is not on disk must already be on its Release. A replace swaps every one. @@ -107,8 +134,10 @@ export async function publish(kind, entry, { dry = false, youtube: wantYouTube = return; } if (dry) { console.log('\n── github: would publish'); results.github = 'would publish'; return; } - results.github = await clients.github({ token, repo, tag, headline, notes, assets, replace }); - if (results.github !== row.published?.github) record(r => ({ published: { ...r.published, github: results.github } })); + results.github = await clients.github({ token, repo, tag, headline, notes, assets, replace, draft }); + if (results.github !== row.published?.github || (draft && !row.published?.draft)) { + record(r => ({ published: { ...r.published, github: results.github, ...(draft && { draft: true }) } })); + } }); for (const f of films.filter(f => f.youtube)) console.log(`\n── youtube: ${f.rendition.id} already uploaded (${f.youtube})`); @@ -127,7 +156,7 @@ export async function publish(kind, entry, { dry = false, youtube: wantYouTube = await attempt(`youtube ${id}`, async () => { if (!f.onDisk) { console.log(`\n── youtube: ${id} skipped (not on disk; youtube.yml fetches it from the Release)`); return; } if (dry) { console.log(`\n── youtube: would upload ${id}`); results[`youtube ${id}`] = 'would upload'; return; } - const url = await clients.youtube({ film: f.file, title: youtubeTitle(headline, tag, f.rendition), description }); + const url = await clients.youtube({ film: f.file, title: youtubeTitle(headline, tag, f.rendition), description, privacy }); console.log(`\n── youtube: uploaded ${id} ${url}`); results[`youtube ${id}`] = url; record(r => ({ renditions: { ...r.renditions, [id]: { ...r.renditions[id], youtube: url } } })); @@ -149,7 +178,7 @@ export async function publish(kind, entry, { dry = false, youtube: wantYouTube = return results; } -async function github({ token, repo, tag, headline, notes, assets, replace }) { +async function github({ token, repo, tag, headline, notes, assets, replace, draft }) { const api = `https://api.github.com/repos/${repo}`; const H = { authorization: `Bearer ${token}`, @@ -159,17 +188,28 @@ async function github({ token, repo, tag, headline, notes, assets, replace }) { }; const json = { ...H, 'content-type': 'application/json' }; + // The tag endpoint never returns a draft (its tag does not exist until it is + // published), so a draft is found by listing, or a re-run would make a second. let rel = await fetch(`${api}/releases/tags/${encodeURIComponent(tag)}`, { headers: H }) - .then(r => (r.ok ? r.json() : null)); + .then(r => (r.ok ? r.json() : null)) + ?? await fetch(`${api}/releases?per_page=100`, { headers: H }) + .then(r => (r.ok ? r.json() : [])) + .then(list => list.find(x => x.draft && x.tag_name === tag) ?? null); + if (rel && draft && !rel.draft) { + // Already published by the owner: a draft run changes nothing on it. + console.log(`\n── github: release ${tag} is already published (${rel.html_url}) — left as it is`); + return rel.html_url; + } if (!rel) { // The NAME is the human title, which leads with what shipped. const r = await fetch(`${api}/releases`, { method: 'POST', headers: json, - body: JSON.stringify({ tag_name: tag, name: headline, body: notes, draft: false, prerelease: false }), + body: JSON.stringify({ tag_name: tag, name: headline, body: notes, draft: !!draft, prerelease: false }), }); if (!r.ok) throw new Error(`create release ${r.status}: ${(await r.text()).slice(0, 300)}`); rel = await r.json(); - console.log(`\n── github: created release ${rel.html_url}`); + console.log(`\n── github: created ${draft ? 'DRAFT ' : ''}release ${rel.html_url}`); + if (draft) console.log(' The owner reviews it and presses Publish on GitHub; nothing is public yet.'); } else { // Refresh the title and notes in place, so fixing the copy does not mean // deleting and recreating a published release. @@ -236,7 +276,7 @@ function youtubeTitle(headline, tag, r) { return title.slice(0, 100 - r.titleSuffix.length) + r.titleSuffix; } -async function youtube({ film, title, description }) { +async function youtube({ film, title, description, privacy }) { const tok = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, @@ -262,7 +302,7 @@ async function youtube({ film, title, description }) { }, body: JSON.stringify({ snippet: { title, description: description.slice(0, 5000), categoryId: '28' }, - status: { privacyStatus: process.env.YT_PRIVACY || 'unlisted', selfDeclaredMadeForKids: false }, + status: { privacyStatus: privacy ?? youtubePrivacy(), selfDeclaredMadeForKids: false }, }), }); if (!init.ok) throw new Error(`resumable init ${init.status}: ${(await init.text()).slice(0, 300)}`); diff --git a/lib/publish.test.mjs b/lib/publish.test.mjs index f5184c9..14ef018 100644 --- a/lib/publish.test.mjs +++ b/lib/publish.test.mjs @@ -98,3 +98,79 @@ test('a film missing from disk is fine once its Release is out, and fatal before await assert.rejects(publish(kind, { ...entry, published: {} }, {}, c), /is gone/); await assert.rejects(publish(kind, entry, { replace: true }, c), /is gone/); }); + +/** A release rendered tonight that is not out anywhere yet. */ +function fresh() { + const entry = released(); + return ledger.update('dual', entry.hash, { + published: {}, + renditions: Object.fromEntries(Object.entries(entry.renditions).map(([r, f]) => [r, { artifact: f.artifact, cover: f.cover }])), + }); +} + +/** Runs fn with GITHUB_EVENT_NAME set (or removed), then puts it back. */ +async function as(event, fn) { + const prior = process.env.GITHUB_EVENT_NAME; + if (event) process.env.GITHUB_EVENT_NAME = event; else delete process.env.GITHUB_EVENT_NAME; + try { return await fn(); } finally { + if (prior === undefined) delete process.env.GITHUB_EVENT_NAME; else process.env.GITHUB_EVENT_NAME = prior; + } +} + +for (const event of ['schedule', 'repository_dispatch']) { + test(`a ${event} run refuses to upload to YouTube or publish a Release, and calls neither`, async () => { + await as(event, async () => { + const c = clients(); + await assert.rejects(publish(kind, fresh(), {}, c), /unattended/); + await assert.rejects(publish(kind, fresh(), { draft: true }, c), /unattended.*YouTube/); + await assert.rejects(publish(kind, fresh(), { youtube: false }, c), /unattended.*draft/); + await assert.rejects(publish(kind, released(), { replace: true, draft: true, youtube: false }, c), /unattended/); + assert.equal(c.calls.github.length, 0); + assert.equal(c.calls.youtube.length, 0); + }); + }); + + test(`a ${event} run with --draft --no-youtube makes a draft Release and nothing else`, async () => { + await as(event, async () => { + const c = clients(); + const entry = fresh(); + await publish(kind, entry, { draft: true, youtube: false }, c); + assert.equal(c.calls.github.length, 1); + assert.equal(c.calls.github[0].draft, true); + assert.equal(c.calls.youtube.length, 0); + assert.equal(ledger.find('dual', entry.id).published.draft, true); + }); + }); +} + +test('YT_PRIVACY=public is refused before anything is called', async () => { + await as('workflow_dispatch', async () => { + const c = clients(); + process.env.YT_PRIVACY = 'public'; + try { + await assert.rejects(publish(kind, fresh(), {}, c), /YT_PRIVACY/); + } finally { delete process.env.YT_PRIVACY; } + assert.equal(c.calls.github.length, 0); + assert.equal(c.calls.youtube.length, 0); + }); +}); + +test('YouTube privacy is private unless set to unlisted, and nothing else is accepted', async () => { + const { youtubePrivacy } = await import('./publish.mjs'); + assert.equal(youtubePrivacy({}), 'private'); + assert.equal(youtubePrivacy({ YT_PRIVACY: 'unlisted' }), 'unlisted'); + for (const v of ['public', 'PUBLIC', 'Public ', 'listed']) assert.throws(() => youtubePrivacy({ YT_PRIVACY: v }), /YT_PRIVACY/); +}); + +test('handoff writes a posting draft folder: post.md, the landscape film and its cover', async () => { + const { handoff } = await import('./handoff.mjs'); + const entry = fresh(); + const dual = { ...kind, link: () => 'https://anyrouter.dev/models' }; + const dir = handoff(dual, entry); + assert.deepEqual(fs.readdirSync(dir).sort(), ['anyrouter-dual-v1.mp4', 'anyrouter-dual-v1.png', 'post.md']); + const md = fs.readFileSync(path.join(dir, 'post.md'), 'utf8'); + assert.match(md, new RegExp(`^external_ref: "dual-${entry.id}"$`, 'm')); + assert.match(md, /^media: \["anyrouter-dual-v1.mp4", "anyrouter-dual-v1.png"\]$/m); + assert.match(md, /^## x\nOne key, every model\n\nhttps:\/\/anyrouter.dev\/models$/m); + assert.match(md, /^## youtube\nOne key, every model\n/m); +}); diff --git a/lib/unattended.test.mjs b/lib/unattended.test.mjs new file mode 100644 index 0000000..2ffb51b --- /dev/null +++ b/lib/unattended.test.mjs @@ -0,0 +1,46 @@ +// The owner approves everything that goes public. A run nobody started by hand +// (the nightly schedule, a repository_dispatch from the AnyRouter repo) may +// render and leave a draft, and nothing more. These read the workflow files as +// text, because the test job runs bare `node --test` with no YAML parser. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { ROOT } from './paths.mjs'; + +const dir = path.join(ROOT, '.github', 'workflows'); +const workflows = fs.readdirSync(dir).filter(f => /\.ya?ml$/.test(f)) + .map(f => ({ f, text: fs.readFileSync(path.join(dir, f), 'utf8') })); +/** The `on:` block: from `on:` to the next top-level key. */ +const triggers = text => /^on:\n([\s\S]*?)^\S/m.exec(text)?.[1] ?? ''; +const unattended = text => /^\s+(schedule|repository_dispatch|push|pull_request|workflow_run):/m.test(triggers(text)); + +test('a workflow that runs unattended holds no YouTube credentials', () => { + for (const { f, text } of workflows.filter(w => unattended(w.text))) { + assert.doesNotMatch(text, /YOUTUBE_|YT_PRIVACY/, `${f} runs unattended and can reach YouTube`); + } +}); + +test('an unattended workflow publishes only a draft Release and never to YouTube', () => { + for (const { f, text } of workflows.filter(w => unattended(w.text))) { + for (const line of text.split('\n').filter(l => /--publish\b/.test(l))) { + const ownerOnly = /GITHUB_EVENT_NAME"? = "?workflow_dispatch/.test(line); + assert.ok(ownerOnly || (/--draft\b/.test(line) && /--no-youtube\b/.test(line)), + `${f}: "${line.trim()}" can publish from an unattended run`); + } + } +}); + +test('the nightly render passes --draft and --no-youtube', () => { + const render = workflows.find(w => w.f === 'render-video.yml'); + assert.ok(render && unattended(render.text), 'render-video.yml is the nightly job'); + assert.match(render.text, /--publish --draft --no-youtube --accept/); +}); + +test('only an owner dispatch uploads to YouTube, and only as private', () => { + const uploaders = workflows.filter(w => /YOUTUBE_REFRESH_TOKEN/.test(w.text)); + assert.deepEqual(uploaders.map(w => w.f), ['youtube.yml']); + const yt = uploaders[0].text; + assert.deepEqual(triggers(yt).match(/^ {2}\w+:/gm), [' workflow_dispatch:']); + assert.match(yt, /YT_PRIVACY: private\n/); +});