fix(sync): roll one PR per source repo, and stop interpolating dispatch payload into shell - #115
Merged
Conversation
…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>
This was referenced Jul 18, 2026
Closed
Closed
Closed
Closed
Closed
Closed
Closed
Closed
Closed
Closed
Closed
Closed
Closed
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.
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:
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-requestis 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_urlinsource-of-truth.yamlis claimed by two source repos:livetemplatelvtclientSo
sync/livetemplate,sync/lvtandsync/clientnever touch the same file and cannot conflict.2. Command injection via
client_payloadrepository_dispatchpayload went straight into a shell command:A payload of
x"; <command>; echo "executes in a job holdingcontents: write,pull-requests: write, andDOCS_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; norun:block interpolates${{ }}any more.Plus an unvalidated source repo
workflow_dispatchgatessource_repobehind achoicelist.repository_dispatchhad no such gate, so a dispatch could aim--source-repoat any repo and open a PR pulling that repo's markdown into this site. Now checked against the same three-repo allowlist, andrefmust match[A-Za-z0-9._/-]+since it becomes both a branch name and a shell argument.Verification
yaml.v3)${{ }}remains inside anyrun:blockenv:, 4run:)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