Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions .github/workflows/render-video.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -173,25 +180,22 @@ 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=""
[ -n "$LIMIT" ] && ARGS="$ARGS --limit=$LIMIT"
[ -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=""
Expand Down Expand Up @@ -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 <folder> 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
Expand Down
11 changes: 7 additions & 4 deletions .github/workflows/youtube.yml
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ collect → .build/<kind>/data.json (the facts, from a live public source)
gen → .build/<kind>/film/ (a HyperFrames project, 1920x1080)
check → lint + runtime + layout + motion + contrast, for every rendition
render → renders/<kind>-<id>.mp4 (+ -portrait.mp4, a cover each, + source tarball)
publish → a GitHub Release tagged <kind>-<id>, and one YouTube video per rendition
publish → a GitHub Release tagged <kind>-<id> (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 -- <kind>` is the whole
Expand Down
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<kind>/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 <folder>`, 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.
Expand Down
15 changes: 13 additions & 2 deletions lib/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 →
Expand All @@ -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';

Expand Down Expand Up @@ -349,15 +353,15 @@ 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) {
for (const id of 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 ?? ''}`);
}
Expand Down Expand Up @@ -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.
Expand Down
69 changes: 69 additions & 0 deletions lib/handoff.mjs
Original file line number Diff line number Diff line change
@@ -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/<tag>/` (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 <folder>`; 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/<tag>/ 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;
}
2 changes: 1 addition & 1 deletion lib/paths.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading