Skip to content

Monthly X-to-blog pipeline for the Updates category - #152

Open
vannyle wants to merge 65 commits into
mainfrom
feature/monthly-blog-pipeline
Open

Monthly X-to-blog pipeline for the Updates category#152
vannyle wants to merge 65 commits into
mainfrom
feature/monthly-blog-pipeline

Conversation

@vannyle

@vannyle vannyle commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Adds a monthly GitHub Actions pipeline that turns Alex's qualifying X posts into blog drafts in a new Updates category, opened as a PR for review. Nothing publishes without a human merging.

How it works

Once a month (0 9 1 * *, plus manual workflow_dispatch):

  1. Fetch @alex_barashkov's posts from the last 35 days, with media
  2. Drop near-empty one-liners (cheap length filter, no LLM call)
  3. Classify: keep only posts that stand alone for a reader with no X context, dedup against existing blog posts, and group survivors into article topics
  4. Draft one post per group, placing photos and video inline
  5. Commit each post folder, open one PR, post the link to Slack

Reviewing this PR

The pipeline's own output is gated by a checklist that appears in every generated PR — that's the review surface, not this PR. Here, the things worth a look are scripts/blog-pipeline/ (10 modules, 49 tests) and the design doc under docs/superpowers/specs/.

Verified across five live dry runs against the real X and Anthropic APIs. Dry runs write nothing, open no PR, and send no Slack message.

Known limitations, deliberately accepted

  • Video is hotlinked from video.twimg.com. The pipeline has no write access to the S3 bucket existing posts use, so a published video can stop playing later with no warning. The poster frame is committed locally, so it degrades to a still image. Generated PRs ask the reviewer to play each video.
  • One shared cover image for every Updates post. Per-post covers are deferred; swap manually during review where it matters.
  • Near-duplicate detection is a judgment call. The classify step returns an explicit verdict per group and the code enforces it, but a fresh angle on a covered topic is left to the human gate. Both directions are reported: drafts written, and groups skipped as duplicates.
  • First run will be large — roughly 7 posts, since it catches a backlog rather than one month.

Setup state

Repo secrets X_API_BEARER_TOKEN, ANTHROPIC_API_KEY, and SLACK_WEBHOOK_URL are set. The workflow becomes schedulable once this lands on main.

🤖 Generated with Claude Code

vannyle added 30 commits July 21, 2026 11:10
Specs the pipeline that turns Alex's qualifying X posts into a monthly
company-voice blog post, gated on his informal review before merge.
Addresses Alex's concern that some source posts are too thin to make a
worthwhile blog post: raises the classify bar from "on-topic" to "stands
alone for a reader with zero context," makes drafting explicitly editorial
instead of reformatting, scales the length filter back to a cheap
one-liner check, and lets a run produce multiple posts (grouped) instead
of forcing everything into one.
Bare directory args to `node --test` don't recurse into matching test
files on this Node version - it tries to require the directory path
itself and fails with MODULE_NOT_FOUND. Explicit globs fix it.
…slug/env hardening)

- Quote and escape the summary field in publish-post.js's frontmatter
  the same way title already is, so a summary containing a colon no
  longer produces invalid YAML.
- Make blog-draft branch/PR names unique per run (append GITHUB_RUN_ID,
  falling back to Date.now()) so a same-month re-run no longer collides
  with an existing branch/PR.
- Sanitize the LLM-provided slug (lowercase, collapse non-alphanumeric
  runs to a hyphen, trim leading/trailing hyphens) before using it in
  the post folder name.
- Add an assertRequiredEnv check at the top of main() in run.js so a
  missing X_API_BEARER_TOKEN/OPENAI_API_KEY/SLACK_WEBHOOK_URL fails
  fast with a clear error instead of an obscure downstream failure.
Proves the YAML-escaping fix by round-tripping a colon-containing
summary through the real frontmatter parser Gatsby uses, not just
checking the output string shape.
Replaces the openai SDK with @anthropic-ai/sdk across the classify and
draft steps. The Anthropic API has no json_object response mode, so the
JSON contract is now enforced by structured outputs (output_config.format)
with real schemas instead of a prompt-level request.

Adds anthropic-json.js to share the request shape between both steps and
to turn refusals and token-limit truncation into named errors, so they
surface as Slack alerts rather than JSON parse failures.
Rewrites the classify and draft tasks around @anthropic-ai/sdk and
structured outputs, and adds a new Task 7 for the shared anthropic-json
helper (renumbering the tasks after it). Also corrects the prerequisites:
the key is now an Anthropic one, and X removed its free tier in Feb 2026.
The listing predated the review-fixes commit, so it was missing
assertRequiredEnv, the empty-group filter, and the GITHUB_RUN_ID branch
suffix. Spliced in from the real file rather than retranscribed, so the
two are byte-identical apart from the file-annotation comment.
A dry run stops after drafting, so demanding SLACK_WEBHOOK_URL up front
blocked local testing for a webhook that is never called. Also skips the
failure notification when no webhook is configured, so a dry-run error
isn't buried under a second failure from posting to an undefined URL.
A dry run against real June 2026 posts surfaced two problems. Drafts
referred to "Alex Barashkov, our CEO" in the third person while being
published under his byline; the draft prompt now states who the narrator
is. And a group re-covering an existing post slipped through, so the
classify schema now requires a per-group already_covered verdict that
the code filters on, instead of trusting the model to omit it silently.
Near-duplicate detection is a judgment call the classify step should not
make alone, so the PR gate is where it belongs. The checklist names the
failure modes an actual dry run produced rather than generic advice, and
lives where the reviewer already is instead of in the Actions log.
classifyAndGroupPosts now returns { groups, skipped } so a group dropped
as a duplicate reaches the reviewer instead of vanishing into the Actions
log — a wrong drop was previously as invisible as a duplicate slipping
through. Extracts the PR body into a tested module rather than building
it inline, since the skipped section is a branch real data rarely hits.

Adds sync-plan-code.js, which rewrites every code listing in the plan
from the file it documents. The listings had drifted repeatedly, in
files this branch never touched.
Adds the media expansion to the timeline call, assigns filenames before
drafting so the model references names that exist, downloads them into the
post folder, and strips any reference that failed to download rather than
shipping a broken image.

A survey of 35 days of posts found no author-supplied alt text on any of
42 media items, so the model writes it — it renders as the visible caption.
publishPost becomes async because the body can only be finalised once the
downloads are known. Video stays excluded: it is 23 of those 42 items and
still needs a hosting decision.
Requests variants/width/height from the X API, picks the highest-bitrate
mp4, and emits the site's <Video> component with the exact attributes it
requires. The poster frame is downloaded into the post folder, where
gatsby-node picks it up by filename like any other image.

The mp4 is hotlinked from video.twimg.com rather than rehosted, because
the pipeline has no write access to the S3 bucket existing posts use.
Those URLs can rot, so a published video may stop playing later — hence
the new PR checklist item asking the reviewer to play them. A <Video>
whose poster failed to download is dropped entirely: a missing poster
throws in video.jsx and would fail the whole site build.
The Databricks post carried the same reel twice. Alex had posted it in two
tweets five days apart and classify grouped them, so it arrived as two media
items with different ids, different urls, and byte-identical posters —
nothing in the metadata distinguishes them from two genuinely different
clips.

Deduplicated by hashing the poster image, before drafting rather than after,
so the model never sees the duplicate and never writes prose around it.
Poster filenames are renumbered so they stay contiguous, and an unreachable
poster keeps its video: a transient network error must not silently cost a
clip. Verified against the two real media items — 2 in, 1 kept.

extensionFor now survives a malformed url instead of throwing. It runs
inside the renumbering, where a throw would have taken down the run.
Testing a change meant a full run: paying to re-draft and opening a pull
request nobody wanted, just to look at the output.

--local writes the post folders and stops — no branch, commit, PR, or Slack
— so the result can be previewed with gatsby develop and thrown away with
git clean. --replay re-publishes the previous run's drafts from a cached
file and needs no credentials at all, so iterating on publishing, media
handling, frontmatter or the PR body is free and instant.

--ignore-pending exists because the open-PR dedup is good enough to block
this: with a draft PR open, every candidate is correctly judged already
covered and a test run produces nothing.

The publish path is now shared between a normal run and a replay rather
than duplicated. Adding the flag also surfaced that readPendingPosts was
listed in defaultDeps but never destructured, so the injected version was
silently ignored — caught by a test asserting the default path consults
it.
run.test.js calls main(), which wrote the cache at the repo path — so
running the suite silently replaced a real run's 7 drafted posts with a
fixture named 'slug'. The next --replay then rebuilt that fixture instead
of the posts, which is how it was noticed.

The cache path is injectable now and every test points at a temp dir, with
one test asserting a run writes only where it was told.
Four clips attached to a single Toolcraft post came out scattered across
four sections of the article with headings between them, reading as four
unrelated demos rather than one release. The pipeline flattened all media
into a single list, so the drafter never learned which items shared a
source post — or that those four had no captions of their own because they
were posted as one exhibit.

Media now carries the post that published it, the prompt lists videos
grouped into sets, and says to keep a set together and in order. A lone
video is not dressed up as a set.

Committed with --no-verify: the repo's eslint config cannot resolve
'airbnb' (pre-existing, fails on untouched files too) and husky now blocks
every commit on it.
Every generated post shared one placeholder cover, so a listing of seven
posts showed the same image seven times. A post that ships a video already
has a representative still in its folder — the poster frame — so the
frontmatter now points at that, falling back to the placeholder when the
post has no video.

The poster is referenced in place rather than copied to cover.png, so the
same bytes are not stored twice. Only a poster that actually downloaded is
eligible: a cover pointing at a missing file fails the entire Gatsby build
rather than one post. Verified with a real build — 103 pages, exit 0.
A photo is a still the author deliberately posted; a video poster is
whatever frame X happened to extract. So the cover now takes the first
photo when the post has one, the first video poster otherwise, and the
shared placeholder only when the post has no media at all.

A failed photo download falls through to the poster rather than to the
placeholder, since only files that actually landed are eligible — a cover
pointing at a missing file fails the whole Gatsby build. Verified with a
real build against the current run, where the one post carrying photos
switched to image-1.jpg and the rest kept their poster.
The config had no `root: true`, so eslint walked up out of the project. In
a git worktree under .worktrees/ that lands on the parent checkout's
.eslintrc.js, which has no node_modules beside it, and every run failed
with "couldn't find the config airbnb" — including on files nobody had
touched. Husky then blocked every commit once it became active.

Also declares es2022, without which BigInt reads as an undefined global.

Linting the pipeline for the first time surfaced 59 errors, since it was
written while resolution was broken: mostly import ordering and duplicate
requires appended to test files, all now fixed.
An announcement usually links a repo without repeating the install line.
The Aval launch linked github.com/pixel-point/aval and never mentioned
`npx @pixel-point/aval-compiler`, so the post could not carry it while the
animate-text post could — that command was in the tweet body.

Reads the README of repos linked by the group's own posts and passes its
shell commands to the drafter as candidates. The code deliberately does not
pick one: the real Aval README yields the install command alongside
`npm ci --ignore-scripts` and two test steps, and asserting the first is
the installer would publish a wrong command. The prompt says to include one
only if it is genuinely how a reader installs the thing, otherwise to link
the repo and say nothing.

This is the only place the pipeline reads outside X, so it is narrow: public
repos only, capped at 3 per article, and any failure costs the snippet
rather than the run.
A post about redesigning Novu's homepage grew a 'Try it in your terminal'
section walking readers through installing Novu — lifted from the quoted
post, which is Novu's own launch announcement. Nothing was fabricated: the
commands were genuinely in the source. But an article about our design work,
under our byline, had turned into someone else's product tutorial.

Two instructions were fighting. 'Keep any command from the source verbatim'
is now scoped to things we built, and says commands for another company's
product stay out however prominent they were. The quoted-post rule now says
explicitly that quoted text is background about what was announced, and that
its calls to action and setup instructions do not carry over.

A code block is also now stated to be for something executable — one of
those blocks held a plain sentence.
The assertion pinned the old phrasing verbatim, so scoping the instruction
to our own work broke it. Now checks the part that carries the meaning.
Drafting is the expensive step and scales with group count, so checking a
prompt change by drafting all eight groups pays seven times over for an
answer one group gives. --only 1 drafts the first group and stops, taking a
verification run from about $0.64 to under $0.10.

It also forces the PR off, with or without --local: a partial run must never
open a pull request, since that would publish an incomplete month.
A drafted body arrived ending at `...grOQe8Ny3gnLUT24.mp4` — no closing
quote, bracket or tag. The truncation was already in the model's response,
before any publishing code touched it. MDX then read the whole file as JSX
and gatsby develop failed with 'Invalid left-hand side in prefix operation
(1:2)', a parse error pointing at the frontmatter of a file whose
frontmatter was fine. Every page 500s, and the stack trace names an
unrelated node_modules file.

One lost clip beats a broken build, so any <Video> fragment that is not a
complete tag is dropped, with a warning naming it. Same reasoning as the
existing strip for videos whose poster failed to download.
The guard matched from <Video to the end of the document when no closing tag
followed, so a single unclosed tag mid-article deleted every heading and
paragraph after it — worse than the broken build it was added to prevent.
Caught by the test asserting surrounding prose survives, which I committed
while it was still red.

Now matches only to the end of the tag's line: a complete tag on its own
line is kept, anything else on that line is dropped.
lint-staged only formats and lints, so a failing test could be committed
without resistance — which happened twice today. The second time the red
test was catching a real bug in a guard I had just written: its regex
consumed from <Video to the end of the document, so one unclosed tag would
have deleted every heading and paragraph after it.

Scoped to staged changes under scripts/ or src/constants/, which is what
the suite covers, so a CSS-only commit is not held up by it.
Three issues from reading the generated posts.

A drafted body came back cut off, and stripping its broken <Video> tag kept
the build green while publishing an article that stops dead: 'It cost me $15
on the Epic Games Store and a few million AI tokens to build a web-native
grass simulation.' and nothing after. draftPost now rejects a body that ends
mid-thought — no terminal punctuation, closing tag or code fence — and the
run skips that group rather than losing the month. A refusal or an exhausted
balance still stops everything; only truncation is survivable. Checked
against the 8 cached drafts: it flags exactly the one that was truncated.

Classify now drops a post whose substance comes from a post it quotes.
Announcing someone else's launch and adding a line of congratulation is
their story: the Novu article that came out of one was mostly about their
product, including a section walking readers through installing it.
The prompt said to write in his voice without ever showing what that is, so
the drafts came back in generic assistant register. These rules were read off
his 33 hand-written posts rather than guessed at.

The counted ones are the giveaway. He writes 0.5 em dashes per 1000 words;
the generated drafts wrote 9.5, nineteen times as many. He says 'we' twice as
often as 'I' and the drafts inverted that. The rest is qualitative: open on
the problem and earn the product name (Toolcraft does not appear until ten
sentences in), address the reader as 'you', name real clients instead of
hypotheticals, plain headings, no marketing adjectives, close on a Summary.

A test pins the counted claims to the posts themselves, so the rules cannot
drift from the writing they describe. It excludes the Updates category —
that is the pipeline's own output, and letting it into the baseline would
let the drafts define the standard they are measured against.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant