v1.57.3.0 fix(ship): always-loaded PR-title-version rule + fork-PR title-sync backstop (#1909)

* fix(ship): restore always-loaded PR-title-version invariant to skeleton

The v1.54.0.0 carve moved the 'PR title MUST start with v$NEW_VERSION' rule
out of the always-loaded ship skeleton and entirely into the lazily-loaded
pr-body.md section. The agent only set the version prefix if it happened to
read that section before creating the PR, so PRs landed with bare titles.

Restore a one-line invariant (+ helper reference) to ship/SKILL.md.tmpl right
before the {{SECTION:pr-body}} pointer, mirroring the AUQ always-loaded
precedent. Full procedure stays sectioned. Regenerated all hosts.

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

* test(ship): guard PR-title-version rule + pull_request_target safety

Two free gate tests so a future carve or workflow refactor can't silently
regress:

- ship-pr-title-version-always-loaded: asserts the invariant lives in the
  always-loaded ship/SKILL.md skeleton (not only sections/), and that the
  skeleton+sections union keeps BOTH the create and the existing-PR update
  title paths. Modeled on test/auq-format-always-loaded.test.ts.
- pr-title-sync-workflow-safety: static tripwire that fails CI if
  pr-title-sync.yml checks out PR-head code or inlines an attacker-controlled
  ${{ github.event.pull_request.* }} field inside a run: block (the two
  pull_request_target footguns actionlint cannot catch).

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

* fix(ci): pr-title-sync covers fork PRs via hardened pull_request_target

Under plain pull_request the GITHUB_TOKEN is read-only on fork PRs, so the
title-sync backstop could never edit a fork/agent PR title. Switch to
pull_request_target (write token in base context) and make it safe:

- Check out the base repo only (no ref:) — execute trusted infra, never
  fork-head code.
- All attacker-controlled PR fields (title, head repo, head sha) pass via
  env: and are referenced as shell-quoted "$VAR", never inlined into run:.
- Read the PR-head VERSION as data (raw media type) from the head repo at the
  head sha; guard the assignment under set -e.
- Same-repo read failure fails loudly; fork miss warns and skips (the backstop
  stays green without going silently optional).
- Never echo the raw fork title (Actions parses ::workflow-command:: from stdout).

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

* fix(ship): expand binDir path in pr-body Linked Spec block

ship/sections/pr-body.md.tmpl:98-99 used ${ctx.paths.binDir}, but the
gen-skill-docs generator only resolves {{TOKEN}} syntax in .tmpl files — the
${...} JS-template-literal form is substituted only inside .ts resolver files.
So the token passed through literally into the generated pr-body.md, leaving the
agent with an unexpandable ${ctx.paths.binDir}/gstack-paths command in the
Linked Spec auto-detect block. Use the hardcoded helper path, consistent with
every other path reference in this section.

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

* refactor(test): fold ship PR-title skeleton guard into carve-guard registry

main shipped a generalized carve-guard system (PR #1907) that is now the single
source of truth for carved-skill skeleton invariants. Register the PR-title rule
there instead of a standalone test: ship's mustStayInSkeleton asserts v$NEW_VERSION
+ the rewrite helper stay always-loaded, and mustMoveToSection asserts both the
create and update PR paths stay carved into pr-body.md (present in the union, out of
the skeleton). Delete the standalone ship-pr-title-version-always-loaded test it
replaces. The CI-workflow safety tripwire stays standalone (not a carve concern).

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

* chore: bump version and changelog (v1.57.3.0)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-06-07 22:04:18 -07:00 committed by GitHub
parent 4dfdb7cdc2
commit d8c91c6267
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 284 additions and 26 deletions

View File

@ -1,7 +1,25 @@
name: PR Title Sync name: PR Title Sync
# WHY pull_request_target (not pull_request): the default GITHUB_TOKEN is
# READ-ONLY on fork PRs under `pull_request`, so the title-sync backstop could
# never `gh pr edit` a fork/agent PR. `pull_request_target` runs in the base-repo
# context with a write token, which fixes fork coverage.
#
# WHY this is SAFE (pull_request_target is the most dangerous trigger):
# - We check out the BASE repo (no `ref:`), so the only code we execute is
# trusted base-repo infra (bin/gstack-pr-title-rewrite.sh). We NEVER check
# out or run PR-head/fork code.
# - Every attacker-controlled PR field (title, head repo, head sha) arrives via
# `env:` and is referenced as a shell-quoted "$VAR". We NEVER inline a
# `${{ github.event.pull_request.* }}` expression inside the run: script
# (that would execute a crafted title as shell).
# - The PR-head VERSION is read as DATA via the API (raw media type), from the
# head repo at the head sha — never by checking out the head.
# test/pr-title-sync-workflow-safety.test.ts is the static tripwire for all of
# the above and fails CI if any of it regresses.
on: on:
pull_request: pull_request_target:
types: [opened, synchronize, edited] types: [opened, synchronize, edited]
paths: paths:
- 'VERSION' - 'VERSION'
@ -19,25 +37,62 @@ jobs:
pull-requests: write pull-requests: write
if: github.actor != 'github-actions[bot]' if: github.actor != 'github-actions[bot]'
steps: steps:
- name: Checkout PR head # Base repo only — trusted infra (the rewrite helper). No PR-head checkout.
- name: Checkout base repo (trusted)
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
- name: Rewrite PR title to match VERSION - name: Rewrite PR title to match VERSION
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUM: ${{ github.event.pull_request.number }} PR_NUM: ${{ github.event.pull_request.number }}
# Attacker-controlled on fork PRs — env-only, never inlined into run:.
OLD_TITLE: ${{ github.event.pull_request.title }} OLD_TITLE: ${{ github.event.pull_request.title }}
BASE_REPO: ${{ github.repository }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: | run: |
set -euo pipefail set -euo pipefail
chmod +x ./bin/gstack-pr-title-rewrite.sh chmod +x ./bin/gstack-pr-title-rewrite.sh
VERSION=$(cat VERSION | tr -d '[:space:]')
NEW_TITLE=$(./bin/gstack-pr-title-rewrite.sh "$VERSION" "$OLD_TITLE") if [ "$HEAD_REPO" = "$BASE_REPO" ]; then IS_FORK=0; else IS_FORK=1; fi
if [ "$NEW_TITLE" = "$OLD_TITLE" ]; then
echo "Title already correct; no change." # Read the PR-head VERSION as data (raw bytes), from the head repo at
# the head sha. Guard the assignment itself: under `set -e` a bare
# `VERSION=$(...)` would abort the step before any later [ -z ] check.
if ! VERSION=$(gh api -H "Accept: application/vnd.github.raw" \
"repos/$HEAD_REPO/contents/VERSION?ref=$HEAD_SHA" 2>/dev/null | tr -d '[:space:]'); then
VERSION=""
fi
if [ -z "$VERSION" ]; then
# Same-repo read failure should never happen — fail loudly so we
# notice. A fork miss (public-contents quirk, private fork) is a
# convenience gap, not a gate — warn and skip so the check stays green.
if [ "$IS_FORK" = "0" ]; then
echo "::error::Could not read VERSION from same-repo PR head ($HEAD_SHA)."
exit 1
fi
echo "::warning::Could not read VERSION from fork $HEAD_REPO ($HEAD_SHA); skipping title sync."
exit 0
fi
# The helper rejects a malformed VERSION (exit 2). Same policy: loud for
# same-repo, soft for forks. Never echo the raw (attacker-controlled)
# title — Actions still parses ::workflow-command:: from stdout.
if ! NEW_TITLE=$(./bin/gstack-pr-title-rewrite.sh "$VERSION" "$OLD_TITLE"); then
if [ "$IS_FORK" = "0" ]; then
echo "::error::Could not compute title for VERSION '$VERSION' on PR #$PR_NUM."
exit 1
fi
echo "::warning::Could not compute title for fork PR #$PR_NUM; skipping."
exit 0
fi
if [ "$NEW_TITLE" = "$OLD_TITLE" ]; then
echo "PR #$PR_NUM title already correct; no change."
exit 0 exit 0
fi fi
echo "Rewriting: $OLD_TITLE -> $NEW_TITLE"
gh pr edit "$PR_NUM" --title "$NEW_TITLE" gh pr edit "$PR_NUM" --title "$NEW_TITLE"
echo "PR #$PR_NUM title synced to VERSION."

View File

@ -1,5 +1,63 @@
# Changelog # Changelog
## [1.57.3.0] - 2026-06-07
## **Every PR `/ship` opens gets the version stamped into its title, fork and agent PRs included.**
## **The rule rides in the always-loaded part of the skill now, and a guard keeps it there.**
`/ship` stamps `vX.Y.Z.W` onto the title of every PR or MR it creates or updates, so
the version is the first thing you read in the PR list. That rule now lives in the
always-loaded core of the ship skill instead of an on-demand section, so the agent
applies it whether or not it opened the section that spells out the full procedure.
A CI workflow backs this up: it rewrites a title to match VERSION on every PR that
bumps the version, and it now reaches fork and agent PRs too, which a read-only token
could never touch before. Two free tests lock the behavior in so it cannot drift on
the next refactor.
### The numbers that matter
Reproduce with `bun test test/carve-section-ordering.test.ts test/pr-title-sync-workflow-safety.test.ts`
and `bun run eval:select`.
| Property | Before | After |
|---|---|---|
| Where the title rule loads | on-demand section only (since v1.54.0.0) | always-loaded skeleton + on-demand detail |
| Fork / agent PR title sync | none (read-only token under `pull_request`) | covered via hardened `pull_request_target` |
| Test proving the rule stays put | none | carve-guard registry asserts it on every PR |
| CI injection guard for the title workflow | none | static tripwire fails CI on unsafe patterns |
The title workflow now runs with a write token in the base-repo context but never
checks out or executes PR-head code, and every attacker-controlled field reaches the
script through `env:`, never inlined. A static test fails CI if either rule regresses.
### What this means for you
Ship a branch and the PR shows up titled `v1.57.3.0 fix: ...` without you touching it,
even when the PR came from a fork. The agent no longer needs to read the right section
at the right moment for the version to land in the title, and the next person who slims
the ship skill cannot quietly strand the rule again, because a free test on every PR
checks that it is still there.
### Itemized changes
#### Added
- Carve-guard coverage for the ship PR-title invariant: the registry now asserts the
`v$NEW_VERSION` rule and the title helper stay in the always-loaded skeleton, while
the full create and update procedure stays in the on-demand section.
- Static CI-safety test for the title-sync workflow that fails the build if it checks
out PR-head code or inlines an attacker-controlled PR field into a shell step.
#### Changed
- The PR/MR title-version rule is always-loaded in `/ship` again, so the version
prefix lands on every PR the workflow creates or updates.
- The PR title-sync CI workflow now covers fork and agent PRs through a hardened
`pull_request_target` trigger (base-repo checkout only, PR fields passed via `env:`,
VERSION read as data from the PR head).
#### Fixed
- A path token in the ship PR-body section that rendered literally instead of resolving
now uses the correct helper path, so the Linked Spec auto-detect step runs as written.
## [1.57.2.0] - 2026-06-08 ## [1.57.2.0] - 2026-06-08
## **When the question picker breaks mid-skill, gstack asks in plain text instead of stalling.** ## **When the question picker breaks mid-skill, gstack asks in plain text instead of stalling.**

View File

@ -1 +1 @@
1.57.2.0 1.57.3.0

View File

@ -1,6 +1,6 @@
{ {
"name": "gstack", "name": "gstack",
"version": "1.57.2.0", "version": "1.57.3.0",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@ -20,16 +20,16 @@
"test": "bun test browse/test/ test/ make-pdf/test/ --ignore 'test/skill-e2e-*.test.ts' --ignore test/skill-llm-eval.test.ts --ignore test/skill-routing-e2e.test.ts --ignore test/codex-e2e.test.ts --ignore test/gemini-e2e.test.ts && (bun run slop:diff 2>/dev/null || true)", "test": "bun test browse/test/ test/ make-pdf/test/ --ignore 'test/skill-e2e-*.test.ts' --ignore test/skill-llm-eval.test.ts --ignore test/skill-routing-e2e.test.ts --ignore test/codex-e2e.test.ts --ignore test/gemini-e2e.test.ts && (bun run slop:diff 2>/dev/null || true)",
"test:free": "bun run scripts/test-free-shards.ts", "test:free": "bun run scripts/test-free-shards.ts",
"test:windows": "bun run scripts/test-free-shards.ts --windows-only", "test:windows": "bun run scripts/test-free-shards.ts --windows-only",
"test:evals": "EVALS=1 GSTACK_HEADLESS=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-llm-eval.test.ts test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts", "test:evals": "EVALS=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-llm-eval.test.ts test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts",
"test:evals:all": "EVALS=1 GSTACK_HEADLESS=1 EVALS_ALL=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-llm-eval.test.ts test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts", "test:evals:all": "EVALS=1 EVALS_ALL=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-llm-eval.test.ts test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts",
"test:e2e": "EVALS=1 GSTACK_HEADLESS=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts", "test:e2e": "EVALS=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts",
"test:e2e:all": "EVALS=1 GSTACK_HEADLESS=1 EVALS_ALL=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts", "test:e2e:all": "EVALS=1 EVALS_ALL=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts",
"test:gate": "EVALS=1 GSTACK_HEADLESS=1 EVALS_TIER=gate bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-llm-eval.test.ts test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts", "test:gate": "EVALS=1 EVALS_TIER=gate bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-llm-eval.test.ts test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts",
"test:periodic": "EVALS=1 GSTACK_HEADLESS=1 EVALS_TIER=periodic EVALS_ALL=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts", "test:periodic": "EVALS=1 EVALS_TIER=periodic EVALS_ALL=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts",
"test:codex": "EVALS=1 GSTACK_HEADLESS=1 bun test test/codex-e2e.test.ts", "test:codex": "EVALS=1 bun test test/codex-e2e.test.ts",
"test:codex:all": "EVALS=1 GSTACK_HEADLESS=1 EVALS_ALL=1 bun test test/codex-e2e.test.ts", "test:codex:all": "EVALS=1 EVALS_ALL=1 bun test test/codex-e2e.test.ts",
"test:gemini": "EVALS=1 GSTACK_HEADLESS=1 bun test test/gemini-e2e.test.ts", "test:gemini": "EVALS=1 bun test test/gemini-e2e.test.ts",
"test:gemini:all": "EVALS=1 GSTACK_HEADLESS=1 EVALS_ALL=1 bun test test/gemini-e2e.test.ts", "test:gemini:all": "EVALS=1 EVALS_ALL=1 bun test test/gemini-e2e.test.ts",
"skill:check": "bun run scripts/skill-check.ts", "skill:check": "bun run scripts/skill-check.ts",
"dev:skill": "bun run scripts/dev-skill.ts", "dev:skill": "bun run scripts/dev-skill.ts",
"start": "bun run browse/src/server.ts", "start": "bun run browse/src/server.ts",

View File

@ -1225,6 +1225,8 @@ git push -u origin <branch-name>
--- ---
**PR/MR title invariant (always applies — do not skip even if you don't open the section below):** Any PR or MR you create OR update in the next step MUST have a title that starts with `v$NEW_VERSION` (the version bumped in Step 12), in the format `v<NEW_VERSION> <type>: <summary>`. Never create or edit a PR/MR title without this prefix. Compute the correct title with the single source of truth helper: `~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" "<current title>"`. The full create/update procedure (idempotency, redaction scan, self-check) is in the section below.
> **STOP.** Before syncing docs and creating or updating the PR/MR (Steps 18-19), Read `~/.claude/skills/gstack/ship/sections/pr-body.md` and execute it > **STOP.** Before syncing docs and creating or updating the PR/MR (Steps 18-19), Read `~/.claude/skills/gstack/ship/sections/pr-body.md` and execute it
> in full. Do not work from memory — that section is the source of truth for this step. > in full. Do not work from memory — that section is the source of truth for this step.

View File

@ -395,6 +395,8 @@ git push -u origin <branch-name>
--- ---
**PR/MR title invariant (always applies — do not skip even if you don't open the section below):** Any PR or MR you create OR update in the next step MUST have a title that starts with `v$NEW_VERSION` (the version bumped in Step 12), in the format `v<NEW_VERSION> <type>: <summary>`. Never create or edit a PR/MR title without this prefix. Compute the correct title with the single source of truth helper: `~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" "<current title>"`. The full create/update procedure (idempotency, redaction scan, self-check) is in the section below.
{{SECTION:pr-body}} {{SECTION:pr-body}}
## Step 20: Persist ship metrics ## Step 20: Persist ship metrics

View File

@ -97,8 +97,8 @@ you missed it.>
## Linked Spec ## Linked Spec
<Auto-detect: look for /spec archives matching this branch via: <Auto-detect: look for /spec archives matching this branch via:
eval "$(${ctx.paths.binDir}/gstack-paths)" eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
eval "$(${ctx.paths.binDir}/gstack-slug)" eval "$(~/.claude/skills/gstack/bin/gstack-slug)"
CURRENT_BRANCH=$(git branch --show-current) CURRENT_BRANCH=$(git branch --show-current)
SPEC_ARCHIVES="$GSTACK_STATE_ROOT/projects/$SLUG/specs" SPEC_ARCHIVES="$GSTACK_STATE_ROOT/projects/$SLUG/specs"
# Find newest archive whose spec_branch frontmatter matches current branch (or one of its # Find newest archive whose spec_branch frontmatter matches current branch (or one of its

View File

@ -95,8 +95,8 @@ you missed it.>
## Linked Spec ## Linked Spec
<Auto-detect: look for /spec archives matching this branch via: <Auto-detect: look for /spec archives matching this branch via:
eval "$(${ctx.paths.binDir}/gstack-paths)" eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
eval "$(${ctx.paths.binDir}/gstack-slug)" eval "$(~/.claude/skills/gstack/bin/gstack-slug)"
CURRENT_BRANCH=$(git branch --show-current) CURRENT_BRANCH=$(git branch --show-current)
SPEC_ARCHIVES="$GSTACK_STATE_ROOT/projects/$SLUG/specs" SPEC_ARCHIVES="$GSTACK_STATE_ROOT/projects/$SLUG/specs"
# Find newest archive whose spec_branch frontmatter matches current branch (or one of its # Find newest archive whose spec_branch frontmatter matches current branch (or one of its

View File

@ -112,8 +112,14 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
scenario: scenario:
'This is a FRESH version-changing ship: the branch has a real code change, VERSION still equals the base version (needs a bump), and CHANGELOG.md needs a new entry. Follow the skill flow for a version-changing ship: run the pre-landing review and prepare the CHANGELOG entry. Produce the ship plan / review report. Do NOT actually commit, push, or open a PR.', 'This is a FRESH version-changing ship: the branch has a real code change, VERSION still equals the base version (needs a bump), and CHANGELOG.md needs a new entry. Follow the skill flow for a version-changing ship: run the pre-landing review and prepare the CHANGELOG entry. Produce the ship plan / review report. Do NOT actually commit, push, or open a PR.',
staticInvariants: { staticInvariants: {
mustStayInSkeleton: [], // The PR-title-version invariant MUST stay always-loaded: the v1.54.0.0
mustMoveToSection: [], // carve stranded it in pr-body.md and PRs started landing with bare titles
// (CI backstop: test/pr-title-sync-workflow-safety.test.ts).
mustStayInSkeleton: ['v$NEW_VERSION', 'gstack-pr-title-rewrite'],
// ...while the full create/update procedure stays carved into pr-body.md
// (out of the skeleton, present in the union). Asserts BOTH PR paths
// survive: the create path and the idempotent update path.
mustMoveToSection: ['gh pr create --base', 'gh pr edit --title'],
// ship is operational (multi-STOP, not a plan review); no single post-STOP gate. // ship is operational (multi-STOP, not a plan review); no single post-STOP gate.
gateAfterStop: undefined, gateAfterStop: undefined,
}, },

View File

@ -0,0 +1,135 @@
/**
* pr-title-sync.yml is a `pull_request_target` workflow static injection
* tripwire (gate, free).
*
* The anxiety this kills: `pull_request_target` runs with a WRITE token in the
* base-repo context, even for fork PRs. That is what lets this workflow rewrite
* fork-PR titles (the backstop). It is also the single most dangerous workflow
* trigger in GitHub Actions. Two classic footguns turn it into remote code
* execution / token theft, and `actionlint` catches NEITHER:
*
* 1. Checking out the PR head (`actions/checkout` with a `ref:` pointing at
* `pull_request.head` / `head_ref`) and then running anything from it
* that executes attacker-controlled fork code with the write token.
* 2. Interpolating an attacker-controlled `${{ github.event.pull_request.* }}`
* field directly INSIDE a `run:` block the title/body are attacker-
* controlled and the `${{ }}` is expanded into the shell before execution,
* so a crafted title runs as code. Those fields MUST arrive via `env:` and
* be referenced as `"$VAR"` (shell-quoted), never inlined.
*
* This tripwire reads the workflow file directly and fails CI if either pattern
* reappears. Mirrors the static-grep invariant tests in browse/test
* (terminal-agent-pid-identity, server-sanitize-surrogates).
*
* Note: `gh api ... -q '.head.sha'` inside a run block is SAFE (reading PR
* metadata as data via a jq filter string, not `${{ }}` interpolation), so we
* ban the interpolation form specifically, not the literal substring `head.sha`.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
const WORKFLOW = path.resolve(__dirname, '..', '.github', 'workflows', 'pr-title-sync.yml');
/** Indentation width (count of leading spaces) of a line. */
function indent(line: string): number {
const m = line.match(/^( *)/);
return m ? m[1].length : 0;
}
/**
* Return the lines that live inside a `run:` block, each tagged with its 1-based
* line number. Handles both `run: |` (multiline) and `run: <inline command>`.
*/
function runBlockLines(content: string): Array<{ n: number; text: string }> {
const lines = content.split('\n');
const out: Array<{ n: number; text: string }> = [];
let inRun = false;
let runIndent = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const n = i + 1;
const inlineRun = line.match(/^(\s*)run:\s*(\S.*)$/); // `run: echo foo`
const blockRun = /^(\s*)run:\s*(\|>?[+-]?)?\s*$/.test(line); // `run: |`
if (inlineRun && !/^\|/.test(inlineRun[2])) {
out.push({ n, text: inlineRun[2] });
inRun = false;
continue;
}
if (blockRun) {
inRun = true;
runIndent = indent(line);
continue;
}
if (inRun) {
if (line.trim() === '') {
out.push({ n, text: line });
continue;
}
// Block ends when a non-empty line is indented at or below the `run:` key.
if (indent(line) <= runIndent) {
inRun = false;
} else {
out.push({ n, text: line });
}
}
}
return out;
}
describe('pr-title-sync.yml pull_request_target safety', () => {
const content = fs.readFileSync(WORKFLOW, 'utf-8');
test('workflow file exists', () => {
expect(fs.existsSync(WORKFLOW)).toBe(true);
});
test('does NOT check out the PR head ref (no fork-code execution)', () => {
const offenders: string[] = [];
content.split('\n').forEach((line, i) => {
// A checkout `ref:` (or any `ref:`) pointing at the PR head is the footgun.
if (/ref:\s*\$\{\{[^}]*(pull_request\.head|head_ref)/.test(line)) {
offenders.push(` L${i + 1}: ${line.trim()}`);
}
});
if (offenders.length > 0) {
throw new Error(
`pr-title-sync.yml checks out the PR head under pull_request_target — that ` +
`runs attacker-controlled fork code with a write token. Check out the base ` +
`repo (no ref:) and read PR-head data via the API instead.\n` +
offenders.join('\n'),
);
}
});
test('does NOT interpolate ${{ github.event.pull_request.* }} inside a run: block', () => {
const offenders: string[] = [];
for (const { n, text } of runBlockLines(content)) {
if (/\$\{\{\s*github\.event\.pull_request/.test(text)) {
offenders.push(` L${n}: ${text.trim()}`);
}
}
if (offenders.length > 0) {
throw new Error(
`pr-title-sync.yml inlines an attacker-controlled PR field into a run: block ` +
`— a crafted PR title/body executes as shell. Pass it via env: and ` +
`reference "$VAR" (shell-quoted) instead.\n` +
offenders.join('\n'),
);
}
});
test('uses pull_request_target (the hardening is actually present)', () => {
// Positive assertion: if someone reverts to plain pull_request, the fork
// backstop silently stops working (read-only token). Keep it intentional.
expect(/^on:\s*$/m.test(content) || /\bpull_request_target\b/.test(content)).toBe(true);
expect(content).toMatch(/\bpull_request_target\b/);
});
test('passes the PR title through env:, not raw interpolation', () => {
// The safe pattern: OLD_TITLE: ${{ github.event.pull_request.title }} in an
// env: mapping, consumed as "$OLD_TITLE" in script.
expect(content).toMatch(/env:/);
expect(content).toMatch(/github\.event\.pull_request\.title/);
});
});