Monthly X-to-blog pipeline for the Updates category - #152
Open
vannyle wants to merge 65 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 manualworkflow_dispatch):@alex_barashkov's posts from the last 35 days, with mediaReviewing 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 underdocs/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.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.Setup state
Repo secrets
X_API_BEARER_TOKEN,ANTHROPIC_API_KEY, andSLACK_WEBHOOK_URLare set. The workflow becomes schedulable once this lands onmain.🤖 Generated with Claude Code