Skip to content

ci(content): publish content to production automatically - #2538

Merged
Hugo0 merged 6 commits into
mainfrom
ci/content-autopublish-to-main
Jul 28, 2026
Merged

ci(content): publish content to production automatically#2538
Hugo0 merged 6 commits into
mainfrom
ci/content-autopublish-to-main

Conversation

@Hugo0

@Hugo0 Hugo0 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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.yml opened its src/content bump PRs against dev. But content only ever flows maindev (via the back-merge), so dev was never ahead of main and a release could never carry a bump up:

What changes

1. Retarget the auto-PR to main and hand the gate to content-publish-automerge.yml.

2. Add a merge-on-green fallback. update-content.yml authors as CONTENT_BOT_TOKEN's user — the same user the automerge workflow approves with — and GitHub forbids self-approval, so the existing approve → --auto --merge path 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-success check run on the exact head commit and all of them green; diff exactly src/content; PR open, non-draft, base main, same-repo head, head SHA still equal to the tested commit. It keys off the ci-success check run rather than workflow_run.conclusion, because ci-success is 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 same main, 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 build in 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@6 already strips the same node set by default (removeJavaScriptExpressions via blockJS, removeImportsExportsPlugin via useDynamicImport: false). My original proof compiled through raw @mdx-js/mdx, which is not what renderContent calls. There was no live hole.

It is still worth keeping, for two smaller and truer reasons:

  1. That protection is a dependency default, on a path that now publishes to production unreviewed. A major upgrade, or someone setting blockJS: false for an unrelated reason, silently re-opens it. This asserts the invariant in our repo, visibly and under test.
  2. next-mdx-remote silently strips; we run first in 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:

Still works Now a build error
markdown, GFM tables {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 mono main? 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 the mirror: 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-success skips the 45-min grace period since it will never self-resolve.

Risks

  • Content now reaches production with no human in the loop. Approved on the condition that it is content-bump only and tests pass; both are enforced, fail-closed (any extra file, any gh error, any missing/red check → no merge).
  • Code cannot ride this path — verified live on this very PR: approve-and-merge ran, saw a non-content diff, and logged "Not content-only — normal review gate stays in place."
  • Worst case is wrong copy on peanut.me, revertible in one pointer bump. Content cannot execute (next-mdx-remote strips JS by default; item 4 pins that).

Design notes / accepted trade-offs

  • Depends on CONTENT_BOT_TOKEN belonging to an OrganizationAdmin. API-made commits are unsigned and required_signatures is on both rulesets; the admin bypass_mode: always is 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.)
  • Both merge jobs run from the base branch (pull_request_target / workflow_run) — a PR cannot tamper with the guard judging it. Neither checks out or executes PR code.
  • Corrected two materially wrong comments: the Git Data API commit was labelled "signed ... (verified signature)" (it is unsigned), and the Discord alert needs an explicit 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

  • Both workflows: yaml.safe_load parses, every run: block passes bash -n.
  • merge-on-green jq 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.
  • Watchdog logic dry-run against live data: mono content@0b0740a | mirrored from @0b0740a | peanut-content c268e5d | live c268e5d → CURRENT, stays quiet.
  • Discord webhook fired for real: HTTP 204 delivered.
  • MDX guard against the real compiler: 7 attack payloads rejected (fs read, env exfil, spread, import, IIFE, template literal, bare identifier); all 758 files under src/content compile unchanged. Full inventory of every {...} in content: 15 literals, 2 comments, 0 executable.
  • 13 unit tests for the guard. Full suite: 4 pre-existing worktree failures (@capacitor/clipboard missing, public/flags ungenerated) — environment, not this diff.
  • Prettier clean; typecheck clean for these files.

End-to-end can only be verified after merge: push content to mono → expect an auto-PR to main that 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.sh still prints dev for hop ③; flagged in the skill, worth a tidy-up pass later.

Summary by CodeRabbit

  • New Features

    • Added automated monitoring for content publishing, including alerts when production content becomes outdated or publishing is blocked.
    • Improved automatic publishing so validated content updates can merge reliably once checks pass.
  • Bug Fixes

    • Prevented executable or unsafe MDX content from being published, including scripts, imports, exports, and unsafe JSX expressions.
    • Added validation to protect content rendered in production while allowing supported static content formats.
  • Tests

    • Added coverage for safe and unsafe MDX content scenarios.

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.
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Jul 28, 2026 9:32am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9ac7cfeb-9f80-4653-bef1-78cf79206d68

📥 Commits

Reviewing files that changed from the base of the PR and between ba4666a and be18a2c.

📒 Files selected for processing (6)
  • .github/workflows/content-pipeline-watchdog.yml
  • .github/workflows/content-publish-automerge.yml
  • .github/workflows/update-content.yml
  • src/lib/__tests__/mdx-security.test.ts
  • src/lib/mdx-security.ts
  • src/lib/mdx.ts

📝 Walkthrough

Walkthrough

The pull request updates content publication workflows for main, adds freshness monitoring, and introduces compile-time MDX validation that rejects executable content while allowing inert forms.

Changes

Content publishing automation

Layer / File(s) Summary
Content PR generation
.github/workflows/update-content.yml
Content updates are serialized, based on main, opened as content-only PRs, and followed by best-effort cleanup of superseded PRs.
Content PR merging
.github/workflows/content-publish-automerge.yml
Same-repository content PRs use approval or a workflow_run path that verifies ci-success and an exact src/content diff before merging.
Content freshness monitoring
.github/workflows/content-pipeline-watchdog.yml
A scheduled watchdog compares production, mirror, and mono revisions, checks publish PR state, and optionally sends Discord alerts when publication is stale.

MDX execution prevention

Layer / File(s) Summary
MDX AST security guard
src/lib/mdx-security.ts
The new Remark plugin traverses MDX and JSX nodes, permits inert literals and comments, and rejects executable or forbidden constructs.
MDX compiler integration and validation
src/lib/mdx.ts, src/lib/__tests__/mdx-security.test.ts
MDX rendering registers the security plugin, with tests covering rejected executable forms and accepted inert content.

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
Loading
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
Loading

Suggested labels: enhancement

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/content-autopublish-to-main

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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: dependency version conflict. Check your lock file or package.json.


Comment @coderabbitai help to get the list of available commands.

@cursor

cursor Bot commented Jul 28, 2026

Copy link
Copy Markdown

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.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6278.78 → 6284.43 (+5.65)
Findings: +2 net (+3 new, -1 resolved)

🆕 New findings (3)

  • medium complexity — src/lib/mdx-security.ts — CC 23, MI 62.85, SLOC 49
  • low missing-return-type — src/lib/mdx-security.ts:116 — remarkNoExecutableContent: exported fn missing return type annotation
  • low missing-return-type — src/lib/mdx.ts:22 — renderContent: exported fn missing return type annotation

✅ Resolved (1)

  • src/lib/mdx.ts:18 — renderContent: exported fn missing return type annotation

📈 Painscore deltas (top movers)

File Before After Δ
src/lib/mdx-security.ts 0.0 5.5 +5.5

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2193 ran, 0 failed, 0 skipped, 35.9s

📊 Coverage (unit)

metric %
statements 61.2%
branches 44.8%
functions 50.6%
lines 61.5%
⏱ 10 slowest test cases
time test
3.4s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.2s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.4s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.4s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › keeps stickers off the username pill (final pass respects the keep-out)
0.2s src/utils/__tests__/demo-balance.test.ts › keeps a spent-down balance across a cold start within the TTL
0.2s src/utils/__tests__/url.utils.test.ts › uses the public BASE_URL in Capacitor, not the localhost WebView origin
0.2s src/utils/__tests__/demo-balance.test.ts › starts at the full balance on a fresh install and stamps a timestamp
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

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.
Hugo0 added 3 commits July 28, 2026 10:10
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.
@Hugo0
Hugo0 marked this pull request as ready for review July 28, 2026 09:23
@Hugo0

Hugo0 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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.
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