Skip to content

fix(sync): roll one PR per source repo, and stop interpolating dispatch payload into shell - #115

Merged
adnaan merged 1 commit into
mainfrom
fix/rolling-sync-prs
Jul 18, 2026
Merged

fix(sync): roll one PR per source repo, and stop interpolating dispatch payload into shell#115
adnaan merged 1 commit into
mainfrom
fix/rolling-sync-prs

Conversation

@adnaan

@adnaan adnaan commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Two problems in sync.yml, both found while investigating why the docs site serves v0.16.0 reference docs while core is on v0.19.0.

1. The backlog — one rolling PR per source repo

The sync mechanism was never broken. It fires correctly on every source release and opens a PR. The bug is in the branch name:

branch: sync/${{ steps.slug.outputs.slug }}-${{ steps.inputs.outputs.ref }}

The ref is baked in, so every release looks like a brand-new branch and mints a brand-new PR. Nine were open at once, back to livetemplate v0.17.0 — which is the actual reason the site is three minor versions behind.

create-pull-request is idempotent per branch: given an existing branch with an open PR it updates that PR in place. Dropping the ref suffix gives one rolling PR per source repo, always at the latest ref, with no backlog and no open/close churn.

Safe because the page sets are disjoint — verified no site_url in source-of-truth.yaml is claimed by two source repos:

Source Pages
livetemplate 20
lvt 5
client 2

So sync/livetemplate, sync/lvt and sync/client never touch the same file and cannot conflict.

2. Command injection via client_payload

repository_dispatch payload went straight into a shell command:

run: |
  echo "source_repo=${{ github.event.client_payload.source_repo }}" >> "$GITHUB_OUTPUT"

A payload of x"; <command>; echo " executes in a job holding contents: write, pull-requests: write, and DOCS_BOT_TOKEN.

Dispatching requires write access, so this is hardening rather than an open door — but it widens the blast radius of any token scoped only to dispatch, and it's the documented anti-pattern. All untrusted values now arrive via env: and are quoted at every use; no run: block interpolates ${{ }} any more.

Plus an unvalidated source repo

workflow_dispatch gates source_repo behind a choice list. repository_dispatch had no such gate, so a dispatch could aim --source-repo at any repo and open a PR pulling that repo's markdown into this site. Now checked against the same three-repo allowlist, and ref must match [A-Za-z0-9._/-]+ since it becomes both a branch name and a shell argument.

Verification

  • YAML parses (yaml.v3)
  • No ${{ }} remains inside any run: block
  • Step/env/run structure unchanged otherwise (6 steps, 4 env:, 4 run:)

The workflow only runs on dispatch, so the real proof is the next source release opening/updating a sync/<slug> PR.

Still needs a human decision

This stops the backlog recurring; it does not drain the existing one. The nine open sync/<repo>-<ref> PRs are now orphaned — superseded by whatever the next rolling sync produces. They want a one-time close.

Note also that merging #112 alone would not fix the recursion docs: it syncs from the v0.19.0 tag, and the correcting change (livetemplate#498) landed after it, so the site would go from "would cause infinite loop" to "Rejected at parse time… a planned feature". Correct docs need a core release containing #498.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ui2cwpeGkrUfRt8rh2FgGG

…ch payload into shell

Two problems in the sync workflow, found while investigating why the docs site
was serving v0.16.0 reference docs against core v0.19.0.

Rolling PRs. The PR branch was suffixed with the ref
(sync/<slug>-<ref>), so every source release minted a fresh branch and
therefore a fresh PR. create-pull-request is idempotent per branch — given an
existing branch with an open PR it updates that PR in place — so dropping the
suffix yields one rolling PR per source repo, always at the latest ref, instead
of an accumulating backlog. Nine sync PRs were open at once, dating back to
livetemplate v0.17.0. Safe because the page sets are disjoint: no site_url in
source-of-truth.yaml is claimed by two source repos, so sync/livetemplate,
sync/lvt and sync/client never touch the same file.

Command injection. repository_dispatch client_payload was interpolated directly
into a run: block:

    echo "source_repo=${{ github.event.client_payload.source_repo }}" >> "$GITHUB_OUTPUT"

A payload of  x"; <command>; echo "  executes in a job holding contents: write,
pull-requests: write and DOCS_BOT_TOKEN. Dispatching needs write access, so this
is hardening rather than an open door, but it widens the blast radius of any
token scoped only to dispatch. All untrusted values now arrive as environment
variables and are quoted at every use; no run: block interpolates ${{ }}.

source_repo was also unvalidated on the dispatch path (workflow_dispatch gates
it behind a choice list), so a dispatch could aim the sync at an arbitrary repo
and open a PR pulling that repo's markdown into this site. It is now checked
against the same three-repo allowlist, and ref must match [A-Za-z0-9._/-]+
since it becomes both a branch name and a shell argument.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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