Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 62 additions & 7 deletions .github/workflows/pr-title-sync.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
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:
pull_request:
pull_request_target:
types: [opened, synchronize, edited]
paths:
- 'VERSION'
Expand All @@ -19,25 +37,62 @@ jobs:
pull-requests: write
if: github.actor != 'github-actions[bot]'
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
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}

- name: Rewrite PR title to match VERSION
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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 }}
BASE_REPO: ${{ github.repository }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
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

# 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 "Title already correct; no change."
echo "PR #$PR_NUM title already correct; no change."
exit 0
fi
echo "Rewriting: $OLD_TITLE -> $NEW_TITLE"
gh pr edit "$PR_NUM" --title "$NEW_TITLE"
echo "PR #$PR_NUM title synced to VERSION."
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,63 @@
# 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

## **When the question picker breaks mid-skill, gstack asks in plain text instead of stalling.**
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.57.2.0
1.57.3.0
22 changes: 11 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"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.",
"license": "MIT",
"type": "module",
Expand All @@ -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:free": "bun run scripts/test-free-shards.ts",
"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: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: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: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: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: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:codex": "EVALS=1 GSTACK_HEADLESS=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:gemini": "EVALS=1 GSTACK_HEADLESS=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: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 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 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 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 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 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 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",
"dev:skill": "bun run scripts/dev-skill.ts",
"start": "bun run browse/src/server.ts",
Expand Down
2 changes: 2 additions & 0 deletions ship/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
> in full. Do not work from memory — that section is the source of truth for this step.

Expand Down
2 changes: 2 additions & 0 deletions ship/SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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}}

## Step 20: Persist ship metrics
Expand Down
4 changes: 2 additions & 2 deletions ship/sections/pr-body.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ you missed it.>
## Linked Spec
<Auto-detect: look for /spec archives matching this branch via:
eval "$(${ctx.paths.binDir}/gstack-paths)"
eval "$(${ctx.paths.binDir}/gstack-slug)"
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
eval "$(~/.claude/skills/gstack/bin/gstack-slug)"
CURRENT_BRANCH=$(git branch --show-current)
SPEC_ARCHIVES="$GSTACK_STATE_ROOT/projects/$SLUG/specs"
# Find newest archive whose spec_branch frontmatter matches current branch (or one of its
Expand Down
4 changes: 2 additions & 2 deletions ship/sections/pr-body.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ you missed it.>
## Linked Spec
<Auto-detect: look for /spec archives matching this branch via:
eval "$(${ctx.paths.binDir}/gstack-paths)"
eval "$(${ctx.paths.binDir}/gstack-slug)"
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
eval "$(~/.claude/skills/gstack/bin/gstack-slug)"
CURRENT_BRANCH=$(git branch --show-current)
SPEC_ARCHIVES="$GSTACK_STATE_ROOT/projects/$SLUG/specs"
# Find newest archive whose spec_branch frontmatter matches current branch (or one of its
Expand Down
10 changes: 8 additions & 2 deletions test/helpers/carve-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,14 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
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.',
staticInvariants: {
mustStayInSkeleton: [],
mustMoveToSection: [],
// The PR-title-version invariant MUST stay always-loaded: the v1.54.0.0
// 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.
gateAfterStop: undefined,
},
Expand Down
Loading
Loading