ci(content): publish content to production automatically - #2538
Conversation
Content merged to mono never reached peanut.me on its own. update-content.yml opened its submodule-bump PRs against `dev`, but content only ever flows main->dev via the back-merge, so `dev` was never ahead of `main` on `src/content` and a release could not carry a bump up. 29 auto-PRs were opened against `dev`; not one merged. Every publish was a manual signed PR to `main`. Retarget the auto-PR to `main` and let content-publish-automerge.yml own the gate. Because update-content.yml authors as CONTENT_BOT_TOKEN's user and GitHub forbids self-approval, the existing approve-then-auto-merge path can never complete for these PRs — so add a workflow_run fallback that merges via the org-admin bypass. That bypass skips the required status check as well as the review, so the fallback re-establishes green `ci-success` itself before merging: at least one ci-success check run on the exact head commit, all of them green, diff exactly `src/content`, PR open, non-draft, same-repo. Fail-closed throughout.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (6)
📝 WalkthroughWalkthroughThe pull request updates content publication workflows for ChangesContent publishing automation
MDX execution prevention
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant UpdateContent
participant Tests
participant ContentPublishAutomerge
participant GitHub
UpdateContent->>ContentPublishAutomerge: create content-only PR targeting main
Tests->>ContentPublishAutomerge: report ci-success for head SHA
ContentPublishAutomerge->>GitHub: verify PR ownership, target, SHA, and diff
ContentPublishAutomerge->>GitHub: merge eligible PR
sequenceDiagram
participant Watchdog
participant Production
participant PeanutContent
participant Mono
participant Discord
Watchdog->>Production: read live content SHA
Watchdog->>PeanutContent: read TIP SHA and mono reference
Watchdog->>Mono: compare referenced revision
Watchdog->>Discord: post alert when stale or doomed
Suggested labels: ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed: dependency version conflict. Check your lock file or package.json. Comment |
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
Code-analysis diffPainscore total: 6278.78 → 6284.43 (+5.65) 🆕 New findings (3)
✅ Resolved (1)
📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
Two failure modes the retarget would otherwise move from `dev` onto `main`. A burst of content pushes dispatches several runs of update-content.yml. Each branches off the same `main` and opens its own PR; once the first merges, the rest conflict on the `src/content` gitlink and can never merge. Serialise with a concurrency group so the newest dispatch wins — it checks out peanut-content's tip, so it already contains whatever the cancelled runs would have published. Any auto-PR left open is then dead weight, so close superseded ones on open and delete their branches. This is a chore that was already being done by hand: all 29 auto-PRs ever opened against `dev` were closed manually, one at a time, none merged. Best-effort — a failure to close must not fail the publish just opened.
MDX is code. An expression in a content file compiles straight through to
executable JavaScript — `{require("fs").readFileSync("/etc/passwd")}` survives
compilation verbatim — and then runs wherever the page renders: the CI runner
during `next build`, and the production build on Vercel. Both carry secrets.
That was tolerable while a human looked at every content publish. The preceding
commits remove that human: content-only bumps now merge to production on green
CI alone, on the premise that content cannot do anything dangerous. Enforce the
premise rather than assume it.
The check is on *executable*, not on braces. Content uses two inert forms that
must keep working — `{/* editor comments */}` and literal props like
`<Step number={1} />` — so an expression is allowed when its parsed program is
empty or a single literal, and rejected when it can call, read or reference.
Imports, exports and JSX spreads have no inert form and are always rejected.
Fail-closed: an expression whose program cannot be parsed is rejected.
Verified against the real compiler: 7 attack payloads rejected (fs read, env
exfil, spread, import, IIFE, template literal, bare identifier), and all 758
files under src/content compile unchanged. Content has 17 expressions in total —
15 literals and 2 comments — and no executable JavaScript anywhere, so this
removes nothing that is in use.
Auto-publishing removed the human who used to notice. It did not add anything to notice in their place: a broken internal link fails `validate-links`, the publish PR never merges, and nothing goes red anywhere — the content simply never appears until somebody spots a stale page. Assert the end-to-end invariant rather than instrumenting each failure: is production serving the content that is on mono `main`? That one question covers a dead mirror, a dispatch that never fired, a failed update-content run, red CI, a conflicted PR, and whatever else we have not thought of. It lives in peanut-ui because peanut-ui already holds tokens for all three repos (MONO_READ_TOKEN, SUBMODULE_TOKEN) and mono has no secrets at all. Checking hop 1 matters: comparing only peanut-content against peanut-ui would look perfectly consistent while the mirror was stuck and mono raced ahead. Loud means loud. A stuck pipeline always fails the run — red in Actions, and re-failing every 15 minutes so it cannot scroll away — and additionally posts to Discord when CONTENT_PIPELINE_DISCORD_WEBHOOK is set. A missing webhook degrades the alert; it never silences it. Red CI skips the grace period, since it will never resolve on its own.
Cloudflare 403s Discord webhook posts made with python-urllib's default User-Agent. Caught by actually firing the webhook rather than trusting it: the alert path would have failed silently in CI, which is precisely the failure this watchdog exists to prevent.
|
@coderabbitai review |
✅ Action performedReview finished.
|
I claimed this closed a live RCE. It does not. next-mdx-remote@6 already strips
the same node set by default — removeJavaScriptExpressions via blockJS, and
removeImportsExportsPlugin via useDynamicImport:false — so the production path
was never exposed. My proof compiled through raw @mdx-js/mdx, which is not what
renderContent calls.
The guard still earns its place, for smaller and truer reasons: it pins an
invariant that is currently only a dependency default on a path that publishes
to production unreviewed, and because it runs first in remarkPlugins it throws
where next-mdx-remote silently strips — so '{price}' written by an author
becomes a failed build instead of a page that quietly renders nothing.
Makes content publish itself to production, safely, and say so loudly when it doesn't.
The problem
Content merged to mono never reached peanut.me on its own — every publish needed a manual signed PR to
main.update-content.ymlopened itssrc/contentbump PRs againstdev. But content only ever flowsmain→dev(via the back-merge), sodevwas never ahead ofmainand a release could never carry a bump up:auto/update-contentPRs opened againstdev. Zero ever merged — each closed by hand as superseded.dev→mainreleases (Prod release SP-152 — dev → main #2506, Prod Release Sprint 151 — KYC verdict rendering · Solana/Tron/Base withdrawals · profile fixes (2026-07-16) #2407, Prod release SP-150 — dev → main #2391, release: dev → main (KYC poll backoff + cleanups) #2340, Prod Release Sprint 149 — cross-chain withdraw · card-launch CTA · share-asset · badges (2026-06-29) #2298) all leftsrc/contentuntouched.main=peanut-content@c268e5d,dev=@1078c28— dev's is a strict ancestor.What changes
1. Retarget the auto-PR to
mainand hand the gate tocontent-publish-automerge.yml.2. Add a
merge-on-greenfallback.update-content.ymlauthors asCONTENT_BOT_TOKEN's user — the same user the automerge workflow approves with — and GitHub forbids self-approval, so the existing approve →--auto --mergepath can never complete. The fallback merges via the org-admin bypass instead.That bypass skips the required status check as well as the review, so the fallback re-establishes green CI itself. It merges only when all of: ≥1
ci-successcheck run on the exact head commit and all of them green; diff exactlysrc/content; PR open, non-draft, basemain, same-repo head, head SHA still equal to the tested commit. It keys off theci-successcheck run rather thanworkflow_run.conclusion, becauseci-successis what "Protect Prod" actually gates on.3. Stop publishes racing and piling up.
concurrency: update-content— a burst of pushes otherwise branches several PRs off the samemain, and once the first merges the rest conflict on the gitlink and can never merge. Plus close superseded auto-PRs on open, automating a chore done by hand 29 times.4. Pin the "content can't execute" invariant. Raw MDX compiles expressions to executable JS, which would run during
next buildin CI and on Vercel.Correction, and it matters: we are not exposed to this today, and this guard is not what prevents it.
next-mdx-remote@6already strips the same node set by default (removeJavaScriptExpressionsviablockJS,removeImportsExportsPluginviauseDynamicImport: false). My original proof compiled through raw@mdx-js/mdx, which is not whatrenderContentcalls. There was no live hole.It is still worth keeping, for two smaller and truer reasons:
blockJS: falsefor an unrelated reason, silently re-opens it. This asserts the invariant in our repo, visibly and under test.remarkPlugins, so we throw. An author writing{price}expecting interpolation currently gets a page that quietly renders nothing and auto-publishes. They should get a failed build naming the file and line.It checks executable, not braced — content legitimately uses two inert forms that must keep working:
{require('fs')...}<ExchangeWidget currency="ARS" /><Foo bar={process.env.X} /><Step number={1} /><Foo {...process.env} />{/* editor comments */}import fs from 'node:fs'An expression passes only if its parsed program is empty (comment) or a single literal. Fail-closed: an expression that can't be parsed is rejected.
Reviewer: this is the one item I'd understand you dropping. It is defence-in-depth, not a fix. Items 1-3 and 5 are the PR.
5. Fail loud.
content-pipeline-watchdog.yml, every 15 min, asserts one end-to-end invariant — is production serving what's on monomain? One question covering every failure mode: dead mirror, missed dispatch, failed update-content, red CI, conflicted PR, and whatever we haven't thought of. It checks hop 1 too (parsing themirror: sync from mono@<sha>stamp), because comparing only peanut-content against peanut-ui looks perfectly consistent while the mirror is dead and mono races ahead. It lives here rather than in mono because peanut-ui already holds tokens for all three repos; mono has no secrets.A stall always fails the run (red in Actions, re-failing every 15 min so it can't scroll away) and posts to Discord. A missing webhook degrades the alert, never silences it. Red
ci-successskips the 45-min grace period since it will never self-resolve.Risks
gherror, any missing/red check → no merge).approve-and-mergeran, saw a non-content diff, and logged "Not content-only — normal review gate stays in place."Design notes / accepted trade-offs
CONTENT_BOT_TOKENbelonging to an OrganizationAdmin. API-made commits are unsigned andrequired_signaturesis on both rulesets; the adminbypass_mode: alwaysis the only reason this works. Documented inline — move that token to a non-admin account and the bump must become a locally signed commit. (Considered granting a bot App a ruleset bypass and rejected it: bypass is per-ruleset, not per-rule, so it would also hand out branch-deletion and force-push.)pull_request_target/workflow_run) — a PR cannot tamper with the guard judging it. Neither checks out or executes PR code.User-Agent(Cloudflare 403s python-urllib's default — caught by firing the webhook rather than trusting it, which would otherwise have been a silent failure in the thing built to prevent silent failures).QA
yaml.safe_loadparses, everyrun:block passesbash -n.merge-on-greenjq filters dry-run against the real merged publish content: publish latest to production (src/content → peanut-content@c268e5d) #2537 (029e5566):TOTAL ci-success: 2, GREEN: 2— the two-run (push+pull_request) case the all-green rule exists for. PR-selection correctly returns empty for an already-merged PR.mono content@0b0740a | mirrored from @0b0740a | peanut-content c268e5d | live c268e5d→ CURRENT, stays quiet.src/contentcompile unchanged. Full inventory of every{...}in content: 15 literals, 2 comments, 0 executable.@capacitor/clipboardmissing,public/flagsungenerated) — environment, not this diff.End-to-end can only be verified after merge: push content to mono → expect an auto-PR to
mainthat self-merges on green, live in ~15–20 min.Docs
Updated on mono
main(539b81f4, 15457469):content/_system/ARCHITECTURE.md,skills/publish-content/SKILL.md(+ frontmatter, now the fallback skill),skills/publish-content/references/slack-draft.md,skills/update-content/SKILL.md,skills/README.md. Each carries a note that the new behaviour activates when this PR merges — delete those notes on merge.Follow-up (not blocking)
skills/publish-content/scripts/pipeline-status.shstill printsdevfor hop ③; flagged in the skill, worth a tidy-up pass later.Summary by CodeRabbit
New Features
Bug Fixes
Tests