From 7efa85cb4f1e726b13c7ae1974331f7963aa2728 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 1 May 2026 07:06:37 -0700 Subject: [PATCH] v1.23.0.0 feat: always prefix PR titles with v (#1284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add bin/gstack-pr-title-rewrite.sh shared helper Single source of truth for "rewrite a PR title to start with v". Three cases: already correct (no-op), different prefix (replace), no prefix (prepend). Rejects malformed VERSION (anything outside ^[0-9]+(\.[0-9]+)*$) with exit code 2. Uses literal case prefix match instead of bash's pattern- matching # operator so a VERSION with glob metacharacters cannot mismatch. Free bun test covers the four branches plus malformed-input rejection, plain-words-not-stripped, single-segment-not-stripped, idempotence, and missing-args. 9 tests, ~400ms. Co-Authored-By: Claude Opus 4.7 * feat(skills): /ship and /document-release always prefix PR titles with v ship/SKILL.md.tmpl Step 19: idempotency block now always rewrites titles to start with v$NEW_VERSION via the new helper. Removes the "custom title kept intentionally" loophole that let unprefixed titles persist forever. Adds a post-edit self-check that re-fetches the title and retries once if the edit didn't stick. Inline comments on the create-PR snippets at lines 867 and 876 make the rule unmissable. document-release/SKILL.md.tmpl Step 9: new "PR/MR title sync" sub-step calls the same helper after the body update. Catches the case where Step 8 bumped VERSION after /ship had already created the PR — title now follows VERSION instead of going stale. Golden fixtures regenerated for claude/codex/factory ship variants. Co-Authored-By: Claude Opus 4.7 * feat(ci): pr-title-sync rewrites titles unconditionally Drops the "eligible only if already prefixed" gate. Sources the new shared helper, rewrites unconditionally on every VERSION change. Defense-in-depth backstop for PRs opened outside the skills (manual gh pr create, web UI). Uses env: for OLD_TITLE so YAML expression injection cannot reach run:. Co-Authored-By: Claude Opus 4.7 * chore: bump version and changelog (v1.23.0.0) Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- .github/workflows/pr-title-sync.yml | 39 ++++------------ CHANGELOG.md | 44 ++++++++++++++++++ VERSION | 2 +- bin/gstack-pr-title-rewrite.sh | 44 ++++++++++++++++++ document-release/SKILL.md | 48 +++++++++++++++++++ document-release/SKILL.md.tmpl | 48 +++++++++++++++++++ package.json | 2 +- ship/SKILL.md | 13 +++++- ship/SKILL.md.tmpl | 13 +++++- test/fixtures/golden/claude-ship-SKILL.md | 13 +++++- test/fixtures/golden/codex-ship-SKILL.md | 13 +++++- test/fixtures/golden/factory-ship-SKILL.md | 13 +++++- test/pr-title-rewrite.test.ts | 54 ++++++++++++++++++++++ 13 files changed, 309 insertions(+), 37 deletions(-) create mode 100755 bin/gstack-pr-title-rewrite.sh create mode 100644 test/pr-title-rewrite.test.ts diff --git a/.github/workflows/pr-title-sync.yml b/.github/workflows/pr-title-sync.yml index 023f5f66..7cd274cd 100644 --- a/.github/workflows/pr-title-sync.yml +++ b/.github/workflows/pr-title-sync.yml @@ -25,40 +25,19 @@ jobs: fetch-depth: 1 ref: ${{ github.event.pull_request.head.sha }} - - name: Read VERSION + current title - id: inspect - run: | - set -euo pipefail - VERSION=$(cat VERSION | tr -d '[:space:]') - TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH") - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - # Only rewrite titles that ALREADY follow the v prefix pattern. - # Custom titles (no prefix) are left alone — user kept them intentionally. - if printf '%s' "$TITLE" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ '; then - PREFIX=$(printf '%s' "$TITLE" | awk '{print $1}') - REST=$(printf '%s' "$TITLE" | sed 's/^v[0-9][0-9.]* //') - { - echo "prefix=$PREFIX" - echo "rest=$REST" - echo "eligible=true" - } >> "$GITHUB_OUTPUT" - else - echo "eligible=false" >> "$GITHUB_OUTPUT" - fi - - - name: Rewrite title if version changed - if: steps.inspect.outputs.eligible == 'true' + - name: Rewrite PR title to match VERSION env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUM: ${{ github.event.pull_request.number }} - NEW_V: ${{ steps.inspect.outputs.version }} - OLD_PREFIX: ${{ steps.inspect.outputs.prefix }} - REST: ${{ steps.inspect.outputs.rest }} + OLD_TITLE: ${{ github.event.pull_request.title }} run: | - if [ "v$NEW_V" = "$OLD_PREFIX" ]; then - echo "Title already matches v$NEW_V; no change." + 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 [ "$NEW_TITLE" = "$OLD_TITLE" ]; then + echo "Title already correct; no change." exit 0 fi - NEW_TITLE="v$NEW_V $REST" - echo "Rewriting: $OLD_PREFIX ... → v$NEW_V ..." + echo "Rewriting: $OLD_TITLE -> $NEW_TITLE" gh pr edit "$PR_NUM" --title "$NEW_TITLE" diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee05133..a627ac61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## [1.23.0.0] - 2026-04-30 + +## **Every PR title now starts with `vX.Y.Z.W`. `/ship`, `/document-release`, and the GitHub Action all enforce it.** + +The format was already documented in `/ship` Step 19, but a "leave custom titles alone" loophole meant a PR opened without a version prefix would never get one — and `/document-release` never touched the title at all, so a doc-release VERSION bump silently left the PR pointing at the old version. This release closes both gaps. The rule lives in one place now (`bin/gstack-pr-title-rewrite.sh`), all three callers shell out to it, and a free `bun test` locks in the four branches. + +### The numbers that matter + +Numbers come from `git diff --shortstat origin/main..HEAD` and `bun test test/pr-title-rewrite.test.ts` on a clean tree. + +| Metric | Δ | +|---|---| +| Net branch size vs main | +210 / −36 lines (5 files + 2 new) | +| New helper script | **bin/gstack-pr-title-rewrite.sh** (40 lines, single source of truth) | +| New unit tests added | **+9** (test/pr-title-rewrite.test.ts) | +| Unit suite runtime | **402ms** (free-tier, runs on every push) | +| Loopholes closed | **3** (ship Step 19, document-release Step 9, pr-title-sync.yml) | +| Reviewers run on this PR | plan-eng-review (CLEARED) + adversarial (Claude subagent) | + +### What this means for builders + +PR titles are now a deterministic function of the VERSION file, no matter how the PR got created. Open one via the web UI with `feat: my thing` and the next push of a VERSION bump turns it into `v1.23.0.0 feat: my thing`. Run `/ship` from a stale branch where Step 12's queue-drift detection rebumps to a higher version and the title moves with it. Run `/document-release`, bump VERSION at Step 8, and the PR title now follows along instead of staying at the previous version. + +The helper itself rejects malformed VERSION values (anything outside `^[0-9]+(\.[0-9]+)*$`) with exit code 2, uses a literal `case` prefix match instead of bash's pattern-matching `#` operator (so a hypothetical VERSION containing glob metacharacters can't silently mismatch), and is idempotent — applying it twice yields the same result. + +### Itemized changes + +#### Added + +- `bin/gstack-pr-title-rewrite.sh`: shared helper. Takes `` + ``, prints the corrected title on stdout. Three cases: already correct (no-op), different version prefix (replace), no prefix (prepend). Validates NEW_VERSION shape at entry. Used by `/ship`, `/document-release`, and the GitHub Action. +- `test/pr-title-rewrite.test.ts`: 9 deterministic tests covering already-correct, different-prefix, different-prefix-length, no-prefix, plain-words-not-stripped, single-segment-not-stripped, missing-args, malformed-VERSION rejection, and idempotence. Free-tier, runs on every `bun test`. + +#### Changed + +- `ship/SKILL.md.tmpl` Step 19: idempotency block now always rewrites titles to start with `v$NEW_VERSION` — no more "custom title kept intentionally" escape hatch. Shells out to `bin/gstack-pr-title-rewrite.sh` for the rule. Adds a post-edit self-check that re-fetches the title and retries once if the edit didn't stick. +- `ship/SKILL.md.tmpl` create-PR snippets (lines 867 and 876): inline comment makes the `v$NEW_VERSION` requirement unmissable when reading the step. +- `document-release/SKILL.md.tmpl` Step 9: new "PR/MR title sync" sub-step calls the same helper after the body update. Catches the case where Step 8 bumped VERSION after `/ship` had already created the PR — title follows VERSION instead of going stale. +- `.github/workflows/pr-title-sync.yml`: drops the "eligible only if already prefixed" gate. Sources the helper, rewrites unconditionally on every VERSION change. Defense-in-depth backstop for PRs opened outside the skills (manual `gh pr create`, web UI). Uses `env:` for `OLD_TITLE` so YAML expression injection can't reach `run:`. + +#### For contributors + +- The helper is a regular `bin/` script with `set -euo pipefail`, no external deps beyond bash + sed. Slots into the existing pattern alongside `bin/gstack-config`, `bin/gstack-slug`, `bin/gstack-next-version`. +- Test coverage gates this — any future change to the rule has to update the test fixtures or the suite goes red. + ## [1.21.1.0] - 2026-04-28 ## **plan-ceo-review smoke tightens. The "agent skips Step 0 and ships a plan" regression now fails the gate.** diff --git a/VERSION b/VERSION index 6b86f767..14430dc1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.21.1.0 +1.23.0.0 diff --git a/bin/gstack-pr-title-rewrite.sh b/bin/gstack-pr-title-rewrite.sh new file mode 100755 index 00000000..4725ed72 --- /dev/null +++ b/bin/gstack-pr-title-rewrite.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Rewrite a PR/MR title to start with v. +# +# Usage: bin/gstack-pr-title-rewrite.sh +# Output: corrected title on stdout. +# +# Rule: PR titles MUST start with v. Three cases: +# 1. Already starts with "v " -> no change. +# 2. Starts with a different "v " prefix -> replace prefix. +# 3. No version prefix -> prepend "v ". +# +# The version-prefix regex matches two or more dot-separated digit segments +# (covers v1.2, v1.2.3, v1.2.3.4) so the rule is portable across repos that +# use 3-part or 4-part versions, but does NOT strip plain words like +# "version 5". + +set -euo pipefail + +if [ $# -lt 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +NEW_VERSION="$1" +TITLE="$2" + +# Reject malformed NEW_VERSION early. Real values are dot-separated digits; +# anything with shell pattern metacharacters or whitespace is a caller bug. +if ! printf '%s' "$NEW_VERSION" | grep -qE '^[0-9]+(\.[0-9]+)*$'; then + echo "error: NEW_VERSION must be dot-separated digits, got: $NEW_VERSION" >&2 + exit 2 +fi + +# Literal prefix match (case statement is glob-quoted by bash, but our +# regex-validated NEW_VERSION has no glob metacharacters so this is safe). +case "$TITLE" in + "v$NEW_VERSION "*) + printf '%s\n' "$TITLE" + exit 0 + ;; +esac + +REST=$(printf '%s' "$TITLE" | sed -E 's/^v[0-9]+(\.[0-9]+)+ //') +printf 'v%s %s\n' "$NEW_VERSION" "$REST" diff --git a/document-release/SKILL.md b/document-release/SKILL.md index 7d049b19..575c3501 100644 --- a/document-release/SKILL.md +++ b/document-release/SKILL.md @@ -1018,6 +1018,54 @@ rm -f /tmp/gstack-pr-body-$$.md 7. If `gh pr edit` / `glab mr update` fails: warn "Could not update PR/MR body — documentation changes are in the commit." and continue. +**PR/MR title sync (idempotent, always-on):** + +PR titles must always start with `v` — same rule as `/ship`. If Step 8 bumped VERSION after `/ship` had already created the PR, the title is now stale. This sub-step fixes it. + +1. Read the current VERSION: + +```bash +V=$(cat VERSION 2>/dev/null | tr -d '[:space:]') +``` + +If `VERSION` does not exist or is empty, skip this sub-step entirely. + +2. Read the current PR/MR title: + +**If GitHub:** +```bash +CURRENT_TITLE=$(gh pr view --json title -q .title 2>/dev/null || true) +``` + +**If GitLab:** +```bash +CURRENT_TITLE=$(glab mr view -F json 2>/dev/null | jq -r .title 2>/dev/null || true) +``` + +If `CURRENT_TITLE` is empty (no open PR/MR), skip with message "No PR/MR found — skipping title sync." + +3. Compute the corrected title using the shared helper (single source of truth — same one `/ship` uses): + +```bash +NEW_TITLE=$(~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$V" "$CURRENT_TITLE") +``` + +The helper handles three cases: title already correct (no-op), title has a different `v` prefix (replace it), or title has no version prefix (prepend one). + +4. If `NEW_TITLE` differs from `CURRENT_TITLE`, update it: + +**If GitHub:** +```bash +gh pr edit --title "$NEW_TITLE" +``` + +**If GitLab:** +```bash +glab mr update -t "$NEW_TITLE" +``` + +5. If the edit command fails: warn "Could not update PR/MR title — documentation changes are still in the commit." and continue. Do not block on title sync failure. + **Structured doc health summary (final output):** Output a scannable summary showing every documentation file's status: diff --git a/document-release/SKILL.md.tmpl b/document-release/SKILL.md.tmpl index 0fd08eac..8e2b7059 100644 --- a/document-release/SKILL.md.tmpl +++ b/document-release/SKILL.md.tmpl @@ -342,6 +342,54 @@ rm -f /tmp/gstack-pr-body-$$.md 7. If `gh pr edit` / `glab mr update` fails: warn "Could not update PR/MR body — documentation changes are in the commit." and continue. +**PR/MR title sync (idempotent, always-on):** + +PR titles must always start with `v` — same rule as `/ship`. If Step 8 bumped VERSION after `/ship` had already created the PR, the title is now stale. This sub-step fixes it. + +1. Read the current VERSION: + +```bash +V=$(cat VERSION 2>/dev/null | tr -d '[:space:]') +``` + +If `VERSION` does not exist or is empty, skip this sub-step entirely. + +2. Read the current PR/MR title: + +**If GitHub:** +```bash +CURRENT_TITLE=$(gh pr view --json title -q .title 2>/dev/null || true) +``` + +**If GitLab:** +```bash +CURRENT_TITLE=$(glab mr view -F json 2>/dev/null | jq -r .title 2>/dev/null || true) +``` + +If `CURRENT_TITLE` is empty (no open PR/MR), skip with message "No PR/MR found — skipping title sync." + +3. Compute the corrected title using the shared helper (single source of truth — same one `/ship` uses): + +```bash +NEW_TITLE=$(~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$V" "$CURRENT_TITLE") +``` + +The helper handles three cases: title already correct (no-op), title has a different `v` prefix (replace it), or title has no version prefix (prepend one). + +4. If `NEW_TITLE` differs from `CURRENT_TITLE`, update it: + +**If GitHub:** +```bash +gh pr edit --title "$NEW_TITLE" +``` + +**If GitLab:** +```bash +glab mr update -t "$NEW_TITLE" +``` + +5. If the edit command fails: warn "Could not update PR/MR title — documentation changes are still in the commit." and continue. Do not block on title sync failure. + **Structured doc health summary (final output):** Output a scannable summary showing every documentation file's status: diff --git a/package.json b/package.json index ba1b4f8f..06998152 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.21.1.0", + "version": "1.23.0.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/ship/SKILL.md b/ship/SKILL.md index 1030ef99..9a884b14 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -2760,7 +2760,14 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS" If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. -**Also update the PR title** if the version changed on rerun. PR titles use the workspace-aware format `v : ` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION : "` (or the `glab mr update -t ...` equivalent). This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version. If the title has no `v` prefix (a custom title kept intentionally), leave the title alone — only rewrite titles that already follow the format. +**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v : ` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule. + +1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`). +2. Compute the corrected title: `NEW_TITLE=$(~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" "$CURRENT")`. The helper handles three cases: title already correct (no-op), title has a different `v` prefix (replace it), or title has no version prefix (prepend one). +3. If `NEW_TITLE` differs from `CURRENT`, run `gh pr edit --title "$NEW_TITLE"` (or `glab mr update -t "$NEW_TITLE"`). +4. **Self-check:** re-fetch the title and assert it starts with `v$NEW_VERSION `. If it does not, retry the edit once. If still wrong, surface the failure to the user. + +This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version, and forces the format on PRs that were created without it. Print the existing URL and continue to Step 20. @@ -2830,6 +2837,8 @@ you missed it.> **If GitHub:** ```bash +# PR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. +# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) gh pr create --base --title "v$NEW_VERSION : " --body "$(cat <<'EOF' EOF @@ -2839,6 +2848,8 @@ EOF **If GitLab:** ```bash +# MR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. +# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) glab mr create -b -t "v$NEW_VERSION : " -d "$(cat <<'EOF' EOF diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index b6a19bcb..470068fd 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -794,7 +794,14 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS" If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. -**Also update the PR title** if the version changed on rerun. PR titles use the workspace-aware format `v : ` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION : "` (or the `glab mr update -t ...` equivalent). This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version. If the title has no `v` prefix (a custom title kept intentionally), leave the title alone — only rewrite titles that already follow the format. +**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v : ` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule. + +1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`). +2. Compute the corrected title: `NEW_TITLE=$(~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" "$CURRENT")`. The helper handles three cases: title already correct (no-op), title has a different `v` prefix (replace it), or title has no version prefix (prepend one). +3. If `NEW_TITLE` differs from `CURRENT`, run `gh pr edit --title "$NEW_TITLE"` (or `glab mr update -t "$NEW_TITLE"`). +4. **Self-check:** re-fetch the title and assert it starts with `v$NEW_VERSION `. If it does not, retry the edit once. If still wrong, surface the failure to the user. + +This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version, and forces the format on PRs that were created without it. Print the existing URL and continue to Step 20. @@ -864,6 +871,8 @@ you missed it.> **If GitHub:** ```bash +# PR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. +# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) gh pr create --base --title "v$NEW_VERSION : " --body "$(cat <<'EOF' EOF @@ -873,6 +882,8 @@ EOF **If GitLab:** ```bash +# MR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. +# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) glab mr create -b -t "v$NEW_VERSION : " -d "$(cat <<'EOF' EOF diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 1030ef99..9a884b14 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -2760,7 +2760,14 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS" If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. -**Also update the PR title** if the version changed on rerun. PR titles use the workspace-aware format `v : ` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION : "` (or the `glab mr update -t ...` equivalent). This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version. If the title has no `v` prefix (a custom title kept intentionally), leave the title alone — only rewrite titles that already follow the format. +**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v : ` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule. + +1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`). +2. Compute the corrected title: `NEW_TITLE=$(~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" "$CURRENT")`. The helper handles three cases: title already correct (no-op), title has a different `v` prefix (replace it), or title has no version prefix (prepend one). +3. If `NEW_TITLE` differs from `CURRENT`, run `gh pr edit --title "$NEW_TITLE"` (or `glab mr update -t "$NEW_TITLE"`). +4. **Self-check:** re-fetch the title and assert it starts with `v$NEW_VERSION `. If it does not, retry the edit once. If still wrong, surface the failure to the user. + +This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version, and forces the format on PRs that were created without it. Print the existing URL and continue to Step 20. @@ -2830,6 +2837,8 @@ you missed it.> **If GitHub:** ```bash +# PR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. +# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) gh pr create --base --title "v$NEW_VERSION : " --body "$(cat <<'EOF' EOF @@ -2839,6 +2848,8 @@ EOF **If GitLab:** ```bash +# MR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. +# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) glab mr create -b -t "v$NEW_VERSION : " -d "$(cat <<'EOF' EOF diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 40a03b38..32d68710 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -2375,7 +2375,14 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS" If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. -**Also update the PR title** if the version changed on rerun. PR titles use the workspace-aware format `v : ` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION : "` (or the `glab mr update -t ...` equivalent). This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version. If the title has no `v` prefix (a custom title kept intentionally), leave the title alone — only rewrite titles that already follow the format. +**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v : ` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule. + +1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`). +2. Compute the corrected title: `NEW_TITLE=$($GSTACK_ROOT/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" "$CURRENT")`. The helper handles three cases: title already correct (no-op), title has a different `v` prefix (replace it), or title has no version prefix (prepend one). +3. If `NEW_TITLE` differs from `CURRENT`, run `gh pr edit --title "$NEW_TITLE"` (or `glab mr update -t "$NEW_TITLE"`). +4. **Self-check:** re-fetch the title and assert it starts with `v$NEW_VERSION `. If it does not, retry the edit once. If still wrong, surface the failure to the user. + +This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version, and forces the format on PRs that were created without it. Print the existing URL and continue to Step 20. @@ -2445,6 +2452,8 @@ you missed it.> **If GitHub:** ```bash +# PR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. +# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) gh pr create --base --title "v$NEW_VERSION : " --body "$(cat <<'EOF' EOF @@ -2454,6 +2463,8 @@ EOF **If GitLab:** ```bash +# MR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. +# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) glab mr create -b -t "v$NEW_VERSION : " -d "$(cat <<'EOF' EOF diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index c361b59c..8b8e479e 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -2751,7 +2751,14 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS" If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. -**Also update the PR title** if the version changed on rerun. PR titles use the workspace-aware format `v : ` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION : "` (or the `glab mr update -t ...` equivalent). This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version. If the title has no `v` prefix (a custom title kept intentionally), leave the title alone — only rewrite titles that already follow the format. +**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v : ` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule. + +1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`). +2. Compute the corrected title: `NEW_TITLE=$($GSTACK_ROOT/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" "$CURRENT")`. The helper handles three cases: title already correct (no-op), title has a different `v` prefix (replace it), or title has no version prefix (prepend one). +3. If `NEW_TITLE` differs from `CURRENT`, run `gh pr edit --title "$NEW_TITLE"` (or `glab mr update -t "$NEW_TITLE"`). +4. **Self-check:** re-fetch the title and assert it starts with `v$NEW_VERSION `. If it does not, retry the edit once. If still wrong, surface the failure to the user. + +This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version, and forces the format on PRs that were created without it. Print the existing URL and continue to Step 20. @@ -2821,6 +2828,8 @@ you missed it.> **If GitHub:** ```bash +# PR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. +# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) gh pr create --base --title "v$NEW_VERSION : " --body "$(cat <<'EOF' EOF @@ -2830,6 +2839,8 @@ EOF **If GitLab:** ```bash +# MR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. +# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) glab mr create -b -t "v$NEW_VERSION : " -d "$(cat <<'EOF' EOF diff --git a/test/pr-title-rewrite.test.ts b/test/pr-title-rewrite.test.ts new file mode 100644 index 00000000..28a7b61a --- /dev/null +++ b/test/pr-title-rewrite.test.ts @@ -0,0 +1,54 @@ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as path from 'path'; + +const HELPER = path.join(import.meta.dir, '..', 'bin', 'gstack-pr-title-rewrite.sh'); + +function rewrite(version: string, title: string): { stdout: string; status: number; stderr: string } { + const r = spawnSync(HELPER, [version, title], { encoding: 'utf-8' }); + return { stdout: (r.stdout ?? '').trimEnd(), status: r.status ?? -1, stderr: r.stderr ?? '' }; +} + +describe('gstack-pr-title-rewrite', () => { + test('already correct: no change', () => { + const r = rewrite('1.2.3.4', 'v1.2.3.4 feat: foo'); + expect(r.status).toBe(0); + expect(r.stdout).toBe('v1.2.3.4 feat: foo'); + }); + + test('different version prefix: replaces it', () => { + expect(rewrite('1.2.3.5', 'v1.2.3.4 feat: foo').stdout).toBe('v1.2.3.5 feat: foo'); + }); + + test('different prefix length (3-part vs 4-part): replaces it', () => { + expect(rewrite('1.2.3.4', 'v1.2.3 feat: foo').stdout).toBe('v1.2.3.4 feat: foo'); + }); + + test('no version prefix: prepends', () => { + expect(rewrite('1.2.3.4', 'feat: foo').stdout).toBe('v1.2.3.4 feat: foo'); + }); + + test('does not mistake plain words for a prefix', () => { + expect(rewrite('1.2.3.4', 'version 5 feature').stdout).toBe('v1.2.3.4 version 5 feature'); + }); + + test('does not strip a single-segment prefix like v1', () => { + expect(rewrite('1.2.3.4', 'v1 feat: foo').stdout).toBe('v1.2.3.4 v1 feat: foo'); + }); + + test('errors on missing args', () => { + const r = spawnSync(HELPER, ['1.2.3.4'], { encoding: 'utf-8' }); + expect(r.status).not.toBe(0); + }); + + test('rejects malformed VERSION with shell metacharacters', () => { + expect(rewrite('1.*.*.*', 'feat: foo').status).toBe(2); + expect(rewrite('1.2.3.4; rm -rf /', 'feat: foo').status).toBe(2); + }); + + test('idempotent: applying twice yields the same result', () => { + const once = rewrite('1.2.3.4', 'feat: foo').stdout; + const twice = rewrite('1.2.3.4', once).stdout; + expect(twice).toBe(once); + }); +});