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
39 changes: 9 additions & 30 deletions .github/workflows/pr-title-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.**
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.21.1.0
1.23.0.0
44 changes: 44 additions & 0 deletions bin/gstack-pr-title-rewrite.sh
Original file line number Diff line number Diff line change
@@ -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 changes: 48 additions & 0 deletions document-release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
48 changes: 48 additions & 0 deletions document-release/SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
13 changes: 12 additions & 1 deletion ship/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 12 additions & 1 deletion ship/SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading