v1.23.0.0 feat: always prefix PR titles with v<VERSION> (#1284)

* feat: add bin/gstack-pr-title-rewrite.sh shared helper

Single source of truth for "rewrite a PR title to start with v<VERSION>".
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 <noreply@anthropic.com>

* feat(skills): /ship and /document-release always prefix PR titles with v<VERSION>

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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-01 07:06:37 -07:00
committed by GitHub
parent 454423aeb3
commit 7efa85cb4f
13 changed files with 309 additions and 37 deletions
+9 -30
View File
@@ -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<X.Y.Z.W> 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"
+44
View File
@@ -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 `<NEW_VERSION>` + `<CURRENT_TITLE>`, 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.**
+1 -1
View File
@@ -1 +1 @@
1.21.1.0
1.23.0.0
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Rewrite a PR/MR title to start with v<NEW_VERSION>.
#
# Usage: bin/gstack-pr-title-rewrite.sh <NEW_VERSION> <CURRENT_TITLE>
# Output: corrected title on stdout.
#
# Rule: PR titles MUST start with v<NEW_VERSION>. Three cases:
# 1. Already starts with "v<NEW_VERSION> " -> no change.
# 2. Starts with a different "v<digits and dots> " prefix -> replace prefix.
# 3. No version prefix -> prepend "v<NEW_VERSION> ".
#
# 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 <NEW_VERSION> <CURRENT_TITLE>" >&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"
+48
View File
@@ -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<VERSION>` — 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<X.Y.Z.W>` 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:
+48
View File
@@ -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<VERSION>` — 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<X.Y.Z.W>` 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:
+1 -1
View File
@@ -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",
+12 -1
View File
@@ -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<NEW_VERSION> <type>: <summary>` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION <type>: <summary>"` (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<X.Y.Z.W>` 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<NEW_VERSION> <type>: <summary>` — 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<X.Y.Z.W>` 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 <base> --title "v$NEW_VERSION <type>: <summary>" --body "$(cat <<'EOF'
<PR body from above>
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 <base> -t "v$NEW_VERSION <type>: <summary>" -d "$(cat <<'EOF'
<MR body from above>
EOF
+12 -1
View File
@@ -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<NEW_VERSION> <type>: <summary>` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION <type>: <summary>"` (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<X.Y.Z.W>` 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<NEW_VERSION> <type>: <summary>` — 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<X.Y.Z.W>` 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 <base> --title "v$NEW_VERSION <type>: <summary>" --body "$(cat <<'EOF'
<PR body from above>
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 <base> -t "v$NEW_VERSION <type>: <summary>" -d "$(cat <<'EOF'
<MR body from above>
EOF
+12 -1
View File
@@ -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<NEW_VERSION> <type>: <summary>` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION <type>: <summary>"` (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<X.Y.Z.W>` 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<NEW_VERSION> <type>: <summary>` — 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<X.Y.Z.W>` 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 <base> --title "v$NEW_VERSION <type>: <summary>" --body "$(cat <<'EOF'
<PR body from above>
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 <base> -t "v$NEW_VERSION <type>: <summary>" -d "$(cat <<'EOF'
<MR body from above>
EOF
+12 -1
View File
@@ -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<NEW_VERSION> <type>: <summary>` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION <type>: <summary>"` (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<X.Y.Z.W>` 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<NEW_VERSION> <type>: <summary>` — 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<X.Y.Z.W>` 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 <base> --title "v$NEW_VERSION <type>: <summary>" --body "$(cat <<'EOF'
<PR body from above>
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 <base> -t "v$NEW_VERSION <type>: <summary>" -d "$(cat <<'EOF'
<MR body from above>
EOF
+12 -1
View File
@@ -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<NEW_VERSION> <type>: <summary>` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION <type>: <summary>"` (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<X.Y.Z.W>` 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<NEW_VERSION> <type>: <summary>` — 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<X.Y.Z.W>` 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 <base> --title "v$NEW_VERSION <type>: <summary>" --body "$(cat <<'EOF'
<PR body from above>
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 <base> -t "v$NEW_VERSION <type>: <summary>" -d "$(cat <<'EOF'
<MR body from above>
EOF
+54
View File
@@ -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);
});
});