Skip to content

Commit d8c91c6

Browse files
garrytanclaude
andauthored
v1.57.3.0 fix(ship): always-loaded PR-title-version rule + fork-PR title-sync backstop (garrytan#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 garrytan#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>
1 parent 4dfdb7c commit d8c91c6

10 files changed

Lines changed: 283 additions & 25 deletions

File tree

.github/workflows/pr-title-sync.yml

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,25 @@
11
name: PR Title Sync
22

3+
# WHY pull_request_target (not pull_request): the default GITHUB_TOKEN is
4+
# READ-ONLY on fork PRs under `pull_request`, so the title-sync backstop could
5+
# never `gh pr edit` a fork/agent PR. `pull_request_target` runs in the base-repo
6+
# context with a write token, which fixes fork coverage.
7+
#
8+
# WHY this is SAFE (pull_request_target is the most dangerous trigger):
9+
# - We check out the BASE repo (no `ref:`), so the only code we execute is
10+
# trusted base-repo infra (bin/gstack-pr-title-rewrite.sh). We NEVER check
11+
# out or run PR-head/fork code.
12+
# - Every attacker-controlled PR field (title, head repo, head sha) arrives via
13+
# `env:` and is referenced as a shell-quoted "$VAR". We NEVER inline a
14+
# `${{ github.event.pull_request.* }}` expression inside the run: script
15+
# (that would execute a crafted title as shell).
16+
# - The PR-head VERSION is read as DATA via the API (raw media type), from the
17+
# head repo at the head sha — never by checking out the head.
18+
# test/pr-title-sync-workflow-safety.test.ts is the static tripwire for all of
19+
# the above and fails CI if any of it regresses.
20+
321
on:
4-
pull_request:
22+
pull_request_target:
523
types: [opened, synchronize, edited]
624
paths:
725
- 'VERSION'
@@ -19,25 +37,62 @@ jobs:
1937
pull-requests: write
2038
if: github.actor != 'github-actions[bot]'
2139
steps:
22-
- name: Checkout PR head
40+
# Base repo only — trusted infra (the rewrite helper). No PR-head checkout.
41+
- name: Checkout base repo (trusted)
2342
uses: actions/checkout@v4
2443
with:
2544
fetch-depth: 1
26-
ref: ${{ github.event.pull_request.head.sha }}
2745

2846
- name: Rewrite PR title to match VERSION
2947
env:
3048
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3149
PR_NUM: ${{ github.event.pull_request.number }}
50+
# Attacker-controlled on fork PRs — env-only, never inlined into run:.
3251
OLD_TITLE: ${{ github.event.pull_request.title }}
52+
BASE_REPO: ${{ github.repository }}
53+
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
54+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
3355
run: |
3456
set -euo pipefail
3557
chmod +x ./bin/gstack-pr-title-rewrite.sh
36-
VERSION=$(cat VERSION | tr -d '[:space:]')
37-
NEW_TITLE=$(./bin/gstack-pr-title-rewrite.sh "$VERSION" "$OLD_TITLE")
58+
59+
if [ "$HEAD_REPO" = "$BASE_REPO" ]; then IS_FORK=0; else IS_FORK=1; fi
60+
61+
# Read the PR-head VERSION as data (raw bytes), from the head repo at
62+
# the head sha. Guard the assignment itself: under `set -e` a bare
63+
# `VERSION=$(...)` would abort the step before any later [ -z ] check.
64+
if ! VERSION=$(gh api -H "Accept: application/vnd.github.raw" \
65+
"repos/$HEAD_REPO/contents/VERSION?ref=$HEAD_SHA" 2>/dev/null | tr -d '[:space:]'); then
66+
VERSION=""
67+
fi
68+
69+
if [ -z "$VERSION" ]; then
70+
# Same-repo read failure should never happen — fail loudly so we
71+
# notice. A fork miss (public-contents quirk, private fork) is a
72+
# convenience gap, not a gate — warn and skip so the check stays green.
73+
if [ "$IS_FORK" = "0" ]; then
74+
echo "::error::Could not read VERSION from same-repo PR head ($HEAD_SHA)."
75+
exit 1
76+
fi
77+
echo "::warning::Could not read VERSION from fork $HEAD_REPO ($HEAD_SHA); skipping title sync."
78+
exit 0
79+
fi
80+
81+
# The helper rejects a malformed VERSION (exit 2). Same policy: loud for
82+
# same-repo, soft for forks. Never echo the raw (attacker-controlled)
83+
# title — Actions still parses ::workflow-command:: from stdout.
84+
if ! NEW_TITLE=$(./bin/gstack-pr-title-rewrite.sh "$VERSION" "$OLD_TITLE"); then
85+
if [ "$IS_FORK" = "0" ]; then
86+
echo "::error::Could not compute title for VERSION '$VERSION' on PR #$PR_NUM."
87+
exit 1
88+
fi
89+
echo "::warning::Could not compute title for fork PR #$PR_NUM; skipping."
90+
exit 0
91+
fi
92+
3893
if [ "$NEW_TITLE" = "$OLD_TITLE" ]; then
39-
echo "Title already correct; no change."
94+
echo "PR #$PR_NUM title already correct; no change."
4095
exit 0
4196
fi
42-
echo "Rewriting: $OLD_TITLE -> $NEW_TITLE"
4397
gh pr edit "$PR_NUM" --title "$NEW_TITLE"
98+
echo "PR #$PR_NUM title synced to VERSION."

CHANGELOG.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,63 @@
11
# Changelog
22

3+
## [1.57.3.0] - 2026-06-07
4+
5+
## **Every PR `/ship` opens gets the version stamped into its title, fork and agent PRs included.**
6+
## **The rule rides in the always-loaded part of the skill now, and a guard keeps it there.**
7+
8+
`/ship` stamps `vX.Y.Z.W` onto the title of every PR or MR it creates or updates, so
9+
the version is the first thing you read in the PR list. That rule now lives in the
10+
always-loaded core of the ship skill instead of an on-demand section, so the agent
11+
applies it whether or not it opened the section that spells out the full procedure.
12+
A CI workflow backs this up: it rewrites a title to match VERSION on every PR that
13+
bumps the version, and it now reaches fork and agent PRs too, which a read-only token
14+
could never touch before. Two free tests lock the behavior in so it cannot drift on
15+
the next refactor.
16+
17+
### The numbers that matter
18+
19+
Reproduce with `bun test test/carve-section-ordering.test.ts test/pr-title-sync-workflow-safety.test.ts`
20+
and `bun run eval:select`.
21+
22+
| Property | Before | After |
23+
|---|---|---|
24+
| Where the title rule loads | on-demand section only (since v1.54.0.0) | always-loaded skeleton + on-demand detail |
25+
| Fork / agent PR title sync | none (read-only token under `pull_request`) | covered via hardened `pull_request_target` |
26+
| Test proving the rule stays put | none | carve-guard registry asserts it on every PR |
27+
| CI injection guard for the title workflow | none | static tripwire fails CI on unsafe patterns |
28+
29+
The title workflow now runs with a write token in the base-repo context but never
30+
checks out or executes PR-head code, and every attacker-controlled field reaches the
31+
script through `env:`, never inlined. A static test fails CI if either rule regresses.
32+
33+
### What this means for you
34+
35+
Ship a branch and the PR shows up titled `v1.57.3.0 fix: ...` without you touching it,
36+
even when the PR came from a fork. The agent no longer needs to read the right section
37+
at the right moment for the version to land in the title, and the next person who slims
38+
the ship skill cannot quietly strand the rule again, because a free test on every PR
39+
checks that it is still there.
40+
41+
### Itemized changes
42+
43+
#### Added
44+
- Carve-guard coverage for the ship PR-title invariant: the registry now asserts the
45+
`v$NEW_VERSION` rule and the title helper stay in the always-loaded skeleton, while
46+
the full create and update procedure stays in the on-demand section.
47+
- Static CI-safety test for the title-sync workflow that fails the build if it checks
48+
out PR-head code or inlines an attacker-controlled PR field into a shell step.
49+
50+
#### Changed
51+
- The PR/MR title-version rule is always-loaded in `/ship` again, so the version
52+
prefix lands on every PR the workflow creates or updates.
53+
- The PR title-sync CI workflow now covers fork and agent PRs through a hardened
54+
`pull_request_target` trigger (base-repo checkout only, PR fields passed via `env:`,
55+
VERSION read as data from the PR head).
56+
57+
#### Fixed
58+
- A path token in the ship PR-body section that rendered literally instead of resolving
59+
now uses the correct helper path, so the Linked Spec auto-detect step runs as written.
60+
361
## [1.57.2.0] - 2026-06-08
462

563
## **When the question picker breaks mid-skill, gstack asks in plain text instead of stalling.**

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.57.2.0
1+
1.57.3.0

package.json

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gstack",
3-
"version": "1.57.2.0",
3+
"version": "1.57.3.0",
44
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
55
"license": "MIT",
66
"type": "module",
@@ -20,16 +20,16 @@
2020
"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)",
2121
"test:free": "bun run scripts/test-free-shards.ts",
2222
"test:windows": "bun run scripts/test-free-shards.ts --windows-only",
23-
"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",
24-
"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",
25-
"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",
26-
"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",
27-
"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",
28-
"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",
29-
"test:codex": "EVALS=1 GSTACK_HEADLESS=1 bun test test/codex-e2e.test.ts",
30-
"test:codex:all": "EVALS=1 GSTACK_HEADLESS=1 EVALS_ALL=1 bun test test/codex-e2e.test.ts",
31-
"test:gemini": "EVALS=1 GSTACK_HEADLESS=1 bun test test/gemini-e2e.test.ts",
32-
"test:gemini:all": "EVALS=1 GSTACK_HEADLESS=1 EVALS_ALL=1 bun test test/gemini-e2e.test.ts",
23+
"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",
24+
"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",
25+
"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",
26+
"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",
27+
"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",
28+
"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",
29+
"test:codex": "EVALS=1 bun test test/codex-e2e.test.ts",
30+
"test:codex:all": "EVALS=1 EVALS_ALL=1 bun test test/codex-e2e.test.ts",
31+
"test:gemini": "EVALS=1 bun test test/gemini-e2e.test.ts",
32+
"test:gemini:all": "EVALS=1 EVALS_ALL=1 bun test test/gemini-e2e.test.ts",
3333
"skill:check": "bun run scripts/skill-check.ts",
3434
"dev:skill": "bun run scripts/dev-skill.ts",
3535
"start": "bun run browse/src/server.ts",

ship/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1225,6 +1225,8 @@ git push -u origin <branch-name>
12251225

12261226
---
12271227

1228+
**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.
1229+
12281230
> **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
12291231
> in full. Do not work from memory — that section is the source of truth for this step.
12301232

ship/SKILL.md.tmpl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,8 @@ git push -u origin <branch-name>
395395

396396
---
397397

398+
**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.
399+
398400
{{SECTION:pr-body}}
399401

400402
## Step 20: Persist ship metrics

ship/sections/pr-body.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ you missed it.>
9797
9898
## Linked Spec
9999
<Auto-detect: look for /spec archives matching this branch via:
100-
eval "$(${ctx.paths.binDir}/gstack-paths)"
101-
eval "$(${ctx.paths.binDir}/gstack-slug)"
100+
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
101+
eval "$(~/.claude/skills/gstack/bin/gstack-slug)"
102102
CURRENT_BRANCH=$(git branch --show-current)
103103
SPEC_ARCHIVES="$GSTACK_STATE_ROOT/projects/$SLUG/specs"
104104
# Find newest archive whose spec_branch frontmatter matches current branch (or one of its

ship/sections/pr-body.md.tmpl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ you missed it.>
9595
9696
## Linked Spec
9797
<Auto-detect: look for /spec archives matching this branch via:
98-
eval "$(${ctx.paths.binDir}/gstack-paths)"
99-
eval "$(${ctx.paths.binDir}/gstack-slug)"
98+
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
99+
eval "$(~/.claude/skills/gstack/bin/gstack-slug)"
100100
CURRENT_BRANCH=$(git branch --show-current)
101101
SPEC_ARCHIVES="$GSTACK_STATE_ROOT/projects/$SLUG/specs"
102102
# Find newest archive whose spec_branch frontmatter matches current branch (or one of its

test/helpers/carve-guards.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,14 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
112112
scenario:
113113
'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.',
114114
staticInvariants: {
115-
mustStayInSkeleton: [],
116-
mustMoveToSection: [],
115+
// The PR-title-version invariant MUST stay always-loaded: the v1.54.0.0
116+
// carve stranded it in pr-body.md and PRs started landing with bare titles
117+
// (CI backstop: test/pr-title-sync-workflow-safety.test.ts).
118+
mustStayInSkeleton: ['v$NEW_VERSION', 'gstack-pr-title-rewrite'],
119+
// ...while the full create/update procedure stays carved into pr-body.md
120+
// (out of the skeleton, present in the union). Asserts BOTH PR paths
121+
// survive: the create path and the idempotent update path.
122+
mustMoveToSection: ['gh pr create --base', 'gh pr edit --title'],
117123
// ship is operational (multi-STOP, not a plan review); no single post-STOP gate.
118124
gateAfterStop: undefined,
119125
},

0 commit comments

Comments
 (0)