feat(ship,spec,review): PR and issue text goes out only through gstack-post

/ship: Step 18 writes the title into an agent-written file. An existing PR's
title is piped from gh/glab through gstack-pr-title-rewrite.sh --stdin (new),
so neither the agent nor a shell string carries it; a new title is written
with the file tool and its version prefix checked in the file. Step 19
composes the body as before and publishes with gstack-post pr-create, or
pr-body then pr-title, which scan the exact bytes they send. The separate
title and body scans, the hand-rolled REST fallback and the title self-check
are gone (the helper does all three). Title preparation moved into the
pr-body section, so the skeleton shrinks.

/spec files its issue with gstack-post issue-create and drops the pre-issue
re-scan; Greptile replies use gstack-post reply and pr-comment.
scripts/resolvers/redact-doc.ts loses its PR and issue sinks, and a test
keeps any template from rendering one again.

Tests that pinned the old scan-then-gh wiring now pin the gstack-post wiring;
the publication tests run the real compose and publish blocks against a stub
gh with the real helper (HIGH in title or body refused, MEDIUM token flow).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-07 20:07:13 +00:00
1 parent eac410f7e2
commit b104c3df4b
21 files changed
+419 -268

No files matched your search

+10 -2
View File
@@ -2,7 +2,10 @@
# 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.
# gh pr view N --json title -q .title | bin/gstack-pr-title-rewrite.sh <NEW_VERSION> --stdin
# Output: corrected title on stdout. --stdin reads the title from standard
# input, so /ship pipes the platform's title through without the agent or a
# shell string ever carrying it.
#
# Rule: PR titles MUST start with v<NEW_VERSION>. Three cases:
# 1. Already starts with "v<NEW_VERSION>" -> no change.
@@ -25,12 +28,17 @@
set -euo pipefail
if [ $# -lt 2 ]; then
echo "usage: $0 <NEW_VERSION> <CURRENT_TITLE>" >&2
echo "usage: $0 <NEW_VERSION> <CURRENT_TITLE|--stdin>" >&2
exit 2
fi
NEW_VERSION="$1"
TITLE="$2"
if [ "$TITLE" = "--stdin" ] && [ $# -eq 2 ]; then
TITLE=$(cat)
case "$TITLE" in *$'\n'*) echo "error: --stdin title must be one line" >&2; exit 2 ;; esac
[ -n "$TITLE" ] || { echo "error: --stdin read an empty title" >&2; exit 2; }
fi
# Reject malformed NEW_VERSION early. Real values are dot-separated digits;
# anything with shell pattern metacharacters or whitespace is a caller bug.
+11 -6
View File
@@ -111,20 +111,25 @@ REPLY_FILE=$(mktemp "${_GT:?}/reply.XXXXXX") || { echo "Not sent: mktemp failed
Line-level comments (from `pulls/$PR/comments`):
```bash
REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner') && PR_NUMBER=$(gh pr view --json number --jq '.number') || { echo "Not sent: gh could not resolve the repository and PR." >&2; exit 1; }
PR_NUMBER=$(gh pr view --json number --jq '.number') || { echo "Not sent: gh could not resolve the PR." >&2; exit 1; }
REPLY_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<reply-file-name>"
[ -s "$REPLY_FILE" ] || { echo "Not sent: $REPLY_FILE is missing or empty, so the text was never written. Write it, then send by hand: gh api repos/$REPO/pulls/$PR_NUMBER/comments/<comment-id>/replies -F body=@$REPLY_FILE" >&2; exit 1; }
gh api "repos/$REPO/pulls/$PR_NUMBER/comments/<comment-id>/replies" -F "body=@$REPLY_FILE" >/dev/null && rm -f "$REPLY_FILE"
[ -s "$REPLY_FILE" ] || { echo "Not sent: $REPLY_FILE is missing or empty, so the text was never written. Write it, then send by hand: ~/.claude/skills/gstack/bin/gstack-post reply $PR_NUMBER --to <comment-id> --body-file $REPLY_FILE" >&2; exit 1; }
~/.claude/skills/gstack/bin/gstack-post reply "$PR_NUMBER" --to <comment-id> --body-file "$REPLY_FILE" && rm -f "$REPLY_FILE"
```
Top-level comments (from `issues/$PR/comments`):
```bash
REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner') && PR_NUMBER=$(gh pr view --json number --jq '.number') || { echo "Not sent: gh could not resolve the repository and PR." >&2; exit 1; }
PR_NUMBER=$(gh pr view --json number --jq '.number') || { echo "Not sent: gh could not resolve the PR." >&2; exit 1; }
REPLY_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<reply-file-name>"
[ -s "$REPLY_FILE" ] || { echo "Not sent: $REPLY_FILE is missing or empty, so the text was never written. Write it, then send by hand: gh pr comment $PR_NUMBER --body-file $REPLY_FILE" >&2; exit 1; }
gh api "repos/$REPO/issues/$PR_NUMBER/comments" -F "body=@$REPLY_FILE" >/dev/null && rm -f "$REPLY_FILE"
[ -s "$REPLY_FILE" ] || { echo "Not sent: $REPLY_FILE is missing or empty, so the text was never written. Write it, then send by hand: ~/.claude/skills/gstack/bin/gstack-post pr-comment $PR_NUMBER --body-file $REPLY_FILE" >&2; exit 1; }
~/.claude/skills/gstack/bin/gstack-post pr-comment "$PR_NUMBER" --body-file "$REPLY_FILE" && rm -f "$REPLY_FILE"
```
`gstack-post` scans the reply before sending it. Exit 1 (HIGH) means the reply quotes a
credential: rewrite it without the value. Exit 2 (MEDIUM) prints `RULE:` lines and a
`TOKEN:`: rewrite the reply, or ask the user and rerun the block with
`--confirm <confirm-token>` added to the `gstack-post` command.
**If a reply POST fails** (e.g., PR was closed, no write permission): warn and continue. Do not stop the workflow for a failed reply.
---
+8 -7
View File
@@ -6,10 +6,13 @@
* and /cso never drift from the engine.
* {{REDACT_INVOCATION_BLOCK:<sink>}} → the canonical scan-at-sink bash + prose
* for one enforcement point. <sink> is a
* hyphenated label: pre-codex, pre-issue,
* pre-archive, pre-pr-body, pre-pr-title,
* hyphenated label: pre-codex, pre-archive,
* pre-commit.
*
* PR, MR and issue text has no sink here: bin/gstack-post scans the exact
* bytes it sends and refuses HIGH itself (CEO-19), so those sites carry no
* separate scan.
*
* DRY: every skill writes one placeholder per enforcement point; UX/threshold
* changes land here once. test/redact-doc-resolver.test.ts golden-pins the output.
*/
@@ -25,17 +28,15 @@ interface SinkSpec {
const SINKS: Record<string, SinkSpec> = {
'pre-codex': { noun: 'the spec body', blockVerb: 'dispatch to the outside reviewer' },
'pre-issue': { noun: "the issue body you're about to file", blockVerb: 'file the issue' },
'pre-archive': { noun: 'the body about to be archived', blockVerb: 'write the archive' },
'pre-pr-body': { noun: 'the composed PR body', blockVerb: 'create/edit the PR' },
'pre-pr-title': { noun: 'the PR title', blockVerb: 'set the PR title' },
'pre-commit': { noun: 'the generated docs about to be committed', blockVerb: 'commit' },
};
const UNKNOWN_SINK: SinkSpec = { noun: 'the text about to be sent', blockVerb: 'send it' };
export function generateRedactInvocationBlock(ctx: TemplateContext, args?: string[]): string {
const sinkLabel = args?.[0] ?? 'pre-issue';
const sinkLabel = args?.[0] ?? 'unknown';
const brief = args?.[1] === 'brief';
const sink = SINKS[sinkLabel] ?? SINKS['pre-issue'];
const sink = SINKS[sinkLabel] ?? UNKNOWN_SINK;
const bin = `${ctx.paths.binDir}/gstack-redact`;
const outsideGate = sinkLabel === 'pre-codex';
const scan = `REDACT_JSON=$(${outsideGate ? `"${toShellPath(bin)}"` : bin} --from-file "$REDACT_FILE" --repo-visibility "$REDACT_VIS" --self-email "$(git config user.email 2>/dev/null)" --json)`;
+3 -10
View File
@@ -496,7 +496,7 @@ sections. Read a section in full before doing its step; do not work from memory.
| the adversarial review and learnings capture (Step 11) | `sections/adversarial.md` |
| writing the CHANGELOG entry (Step 13) | `sections/changelog.md` |
| auditing docs before final commit/verification (Step 14.5), on every ship | `sections/documentation.md` |
| creating or updating the PR/MR with the verified documentation outcome (Step 19) | `sections/pr-body.md` |
| preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19) | `sections/pr-body.md` |
---
@@ -1124,16 +1124,9 @@ A successful empty array means new; one match supplies the existing title/identi
Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
Save the result for Step 19's recheck.
Prepare the title from that result; Step 19 scans and publishes it:
1. For an existing open PR/MR, use the matched title and run
`~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" "<current title>"`.
2. For a new PR/MR, compose `v<NEW_VERSION> <type>: <summary>`.
3. Save the result as `NEW_TITLE` for Step 19. Every created or updated title MUST
start with `v$NEW_VERSION `; never publish an unprefixed title.
4. **NO_VERSION:** replaces items 1-3: keep an existing title, or compose
`<type>: <summary>`; no version prefix.
The pr-body section prepares the title from that result, then publishes.
> **STOP.** Before creating or updating the PR/MR with the verified documentation outcome (Step 19), Read `~/.claude/skills/gstack/ship/sections/pr-body.md` and execute it
> **STOP.** Before preparing the title and creating or updating the PR/MR with the verified documentation outcome (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.
## Step 20: Persist ship metrics
+1 -8
View File
@@ -594,14 +594,7 @@ A successful empty array means new; one match supplies the existing title/identi
Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
Save the result for Step 19's recheck.
Prepare the title from that result; Step 19 scans and publishes it:
1. For an existing open PR/MR, use the matched title and run
`~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" "<current title>"`.
2. For a new PR/MR, compose `v<NEW_VERSION> <type>: <summary>`.
3. Save the result as `NEW_TITLE` for Step 19. Every created or updated title MUST
start with `v$NEW_VERSION `; never publish an unprefixed title.
4. **NO_VERSION:** replaces items 1-3: keep an existing title, or compose
`<type>: <summary>`; no version prefix.
The pr-body section prepares the title from that result, then publishes.
{{SECTION:pr-body}}
+1 -1
View File
@@ -74,7 +74,7 @@
"id": "pr-body",
"file": "pr-body.md",
"title": "PR/MR creation and documentation outcome",
"trigger": "creating or updating the PR/MR with the verified documentation outcome (Step 19)"
"trigger": "preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19)"
}
]
}
+76 -55
View File
@@ -1,10 +1,40 @@
<!-- AUTO-GENERATED from pr-body.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
### Prepare the title (Step 18)
Prepare the title in a private file; Step 19 posts that file, never a shell string:
```bash
_GT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp"
mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; }
_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; }
TITLE_FILE=$(mktemp "${_GT:?}/pr-title.XXXXXX") || { echo "Not sent: mktemp failed in $_GT." >&2; exit 1; }; echo "TITLE_FILE: $TITLE_FILE (name: ${TITLE_FILE##*/})"
```
Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand.
1. For an existing open PR/MR, fill the file from the platform, substituting the
printed name, the matched number and `NEW_VERSION`; no title text passes through you:
```bash
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
gh pr view <pr-number> --json title -q .title | ~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh <new-version> --stdin > "$TITLE_FILE" || exit 1
```
GitLab reads `glab mr view <pr-number> --output json | jq -r .title` into the same pipe.
2. For a new PR/MR, write `v<NEW_VERSION> <type>: <summary>` into the file with your file-write tool.
3. Every created or updated title MUST start with `v$NEW_VERSION `; check the file with
`grep -Eq '^v<new-version>( |$)' "$TITLE_FILE"` and never publish an unprefixed title.
4. **NO_VERSION:** replaces items 1-3: keep an existing title (the platform title
piped straight into the file), or write `<type>: <summary>`; no version prefix.
Save the title file name for Step 19.
---
## Step 19: Create PR/MR
Recheck Step 18's PR/MR lookup and record it. Errors or ambiguous matches STOP publication.
If the open PR/MR or title changed, repeat Step 18's identity/title preparation,
then return here for a new lookup, fresh body and both redaction scans before publishing.
then return here for a new lookup and a fresh body before publishing.
### Resolve Linked Spec before composing the body
@@ -24,7 +54,7 @@ then return here for a new lookup, fresh body and both redaction scans before pu
completed Step 8 plan scope permits `Closes #N`, with every spec criterion
verified. Partial, deferred, failed, dropped or unverified scope uses `Linked to #N`
and names the remaining work; never auto-close it. Include the archive filename
and `spec_filed_at`, not a private absolute path. Send these fields through the same redaction scan.
and `spec_filed_at`, not a private absolute path; `gstack-post` scans them with the body.
The PR/MR body should contain these sections (never reuse a prior run's body):
@@ -92,7 +122,7 @@ Unavailable/inconclusive is never PASS.>
## Documentation
<Embed Step 14.5's vetted nonempty `documentation_section` for this invocation:
its saved section file, inserted unchanged by the scan block's `DOCS_SECTION_FILE` lines.
its saved section file, inserted unchanged by the compose block's `DOCS_SECTION_FILE` lines.
A blocked audit shipped under a user exception has no section file: state its
blocked status, scope and exception here and drop the block's `DOCS_SECTION_FILE` guard
and its `cat -- "$DOCS_SECTION_FILE" && echo &&` step.>
@@ -104,14 +134,14 @@ and its `cat -- "$DOCS_SECTION_FILE" && echo &&` step.>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
```
#### Redaction scan (PR body + title) — runs before create AND edit
#### Compose the body, then publish through gstack-post
The PR body is world-readable on a public repo. Scan-at-sink before sending:
compose the body into a temp file, scan THAT file with the shared engine,
and pass the same file to `gh`/`glab`. Wrap any Codex / Greptile / eval output
sections in tool-attributed fences (` ```codex-review ` / ` ```greptile `) so the
engine WARN-degrades the example credentials those tools quote instead of blocking
the PR (a live-format credential inside the fence still blocks).
The PR body is world-readable on a public repo. `gstack-post` scans the exact title
and body bytes it sends and passes them to `gh`/`glab` as arguments, so there is no
separate scan here. Wrap any Codex / Greptile / eval output sections in
tool-attributed fences (` ```codex-review ` / ` ```greptile `) so the scanner
WARN-degrades the example credentials those tools quote instead of blocking the PR
(a live-format credential inside the fence still blocks).
Write the body from above into two private files: the first through the
`## Documentation` heading line, the second from `## Test plan` on.
@@ -126,69 +156,60 @@ BODY_REST_FILE=$(mktemp "${_GT:?}/pr-body-rest.XXXXXX") || { echo "Not sent: mkt
Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand.
Use Step 18's `NEW_TITLE` unchanged; its version prefix is already present.
In a new shell, restore the saved literal title before this block, and Step 14.5's
saved section file path as `DOCS_SECTION_FILE`. Substitute the printed names. The
block deletes both drafts; to change the body, write fresh ones.
In a new shell, restore Step 14.5's saved section file path as `DOCS_SECTION_FILE`
and substitute the printed names. The block deletes both drafts; to change the body,
write fresh ones.
```bash
: "${NEW_TITLE:?Restore the saved Step 18 title before scanning}"
: "${DOCS_SECTION_FILE:?Restore the saved Step 14.5 section file path before composing}"
REDACT_VIS=$(~/.claude/skills/gstack/bin/gstack-config get redact_repo_visibility 2>/dev/null)
[ -z "$REDACT_VIS" ] && REDACT_VIS=$(gh repo view --json visibility -q .visibility 2>/dev/null | tr 'A-Z' 'a-z')
REDACT_VIS="${REDACT_VIS:-unknown}"
PR_BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-pr-body.XXXXXX") || { echo "ERROR: mktemp failed — cannot scan the PR body; refusing to create the PR unscanned." >&2; exit 1; }
PR_BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-pr-body.XXXXXX") || { echo "ERROR: mktemp failed — cannot compose the PR body; refusing to publish." >&2; exit 1; }
BODY_TOP_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-top-file-name>"
BODY_REST_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-rest-file-name>"
[ -s "$BODY_TOP_FILE" ] && [ -s "$BODY_REST_FILE" ] || { echo "Not scanned: $BODY_TOP_FILE or $BODY_REST_FILE is empty, so the body was never written. Write both, then rerun." >&2; exit 1; }
[ -s "$BODY_TOP_FILE" ] && [ -s "$BODY_REST_FILE" ] || { echo "Not composed: $BODY_TOP_FILE or $BODY_REST_FILE is empty, so the body was never written. Write both, then rerun." >&2; exit 1; }
{ awk 1 "$BODY_TOP_FILE" && cat -- "$DOCS_SECTION_FILE" && echo && awk 1 "$BODY_REST_FILE"; } > "$PR_BODY_FILE" || exit 1
rm -f "$BODY_TOP_FILE" "$BODY_REST_FILE"
~/.claude/skills/gstack/bin/gstack-redact --from-file "$PR_BODY_FILE" --repo-visibility "$REDACT_VIS" --self-email "$(git config user.email 2>/dev/null)" --json
case $? in
0) ;;
3) echo "BLOCKED — credential in PR body. Rotate + redact, do not create the PR."; exit 1 ;;
2) echo "MEDIUM findings — confirm per finding (sterner on public) before proceeding." ;;
*) echo "BLOCKED — PR body scan failed. Repair the scanner and repeat before publication."; exit 1 ;;
esac
printf '%s' "$NEW_TITLE" | ~/.claude/skills/gstack/bin/gstack-redact --repo-visibility "$REDACT_VIS" --json
echo "PR_BODY_FILE: $PR_BODY_FILE"
```
Check both scan results: exit 0 permits publication; exit 2 requires
AskUserQuestion per MEDIUM finding (PII offers `--auto-redact`); exit 3 blocks for
HIGH findings. Exit 1 or any other error blocks until the scanner works and both
scans pass. When visibility lookup is unavailable, including on GitLab, `unknown`
uses the scanner's public-strict policy.
In each block below, restore the literal `PR_BODY_FILE` path and substitute Step 18's
title file name; `gstack-post` detects GitHub or GitLab from the remote and the repo's
visibility (an unavailable lookup, including on GitLab, uses the scanner's
public-strict policy). Never re-render the body: every retry sends the same files.
For every create/edit command below, send the same scanned bytes. Never re-render
the body. In a new shell, restore the literal `PR_BODY_FILE` path and `NEW_TITLE`.
**Existing open PR/MR:** update using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub)
or `glab mr update -d "$(cat "$PR_BODY_FILE")"` (GitLab).
Update the title with the same scanned `NEW_TITLE`: `gh pr edit --title "$NEW_TITLE"` (or `glab mr update -t "$NEW_TITLE"`).
**REST fallback:** if `gh pr edit` fails with the `repository.pullRequest.projectCards` GraphQL deprecation, do not re-ask for auth. Use the SAME scanned file: `PR_NUMBER=$(gh pr view --json number -q .number)`, then `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -F body=@"$PR_BODY_FILE"`; for the title use `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -f title="$NEW_TITLE"`.
**Self-check:** re-fetch the title and assert it equals `NEW_TITLE`. Retry once if wrong, then surface any failure. Print the existing URL and continue to Step 20; do not run the create commands below.
**No open PR/MR, GitHub:**
**Existing open PR/MR** (`<pr-number>` from the recheck):
```bash
[ -s "$PR_BODY_FILE" ] || { echo "ERROR: scanned body file missing/empty — re-run the scan block." >&2; exit 1; }
gh pr create --base <base> --title "$NEW_TITLE" --body-file "$PR_BODY_FILE"
rm -f "$PR_BODY_FILE"
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
~/.claude/skills/gstack/bin/gstack-post pr-body <pr-number> --body-file "${PR_BODY_FILE:?restore the composed body path}" && \
~/.claude/skills/gstack/bin/gstack-post pr-title <pr-number> --title-file "$TITLE_FILE"
```
**No open PR/MR, GitLab:**
It updates the body, then the title, retries a retired-GraphQL edit through REST and
reads the title back. If Step 6.5 opened this PR as an early draft and the user did not
ask for a draft, mark it ready now: `gh pr ready <pr-number>`. Print the existing URL
and continue to Step 20; do not run the create command below.
**No open PR/MR:**
```bash
[ -s "$PR_BODY_FILE" ] || { echo "ERROR: scanned body file missing/empty — re-run the scan block." >&2; exit 1; }
glab mr create -b <base> -t "$NEW_TITLE" -d "$(cat "$PR_BODY_FILE")"
rm -f "$PR_BODY_FILE"
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
~/.claude/skills/gstack/bin/gstack-post pr-create --base <base> --title-file "$TITLE_FILE" --body-file "${PR_BODY_FILE:?restore the composed body path}"
```
**If neither CLI is available:**
Print the branch name, remote URL, and instruct the user to create the PR/MR manually via the web UI. Do not stop — the code is pushed and ready.
Branch on the exit code of either block:
- **0** posted. Remove `$PR_BODY_FILE` and the title file.
- **1** HIGH finding: BLOCKED — do not publish. Rotate and redact at the source, then
compose fresh files.
- **2** MEDIUM findings, printed as `RULE:` lines with a `TOKEN:`. AskUserQuestion per
finding (sterner on public repos, no batch acknowledge). PII offers auto-redact:
write `~/.claude/skills/gstack/bin/gstack-redact --from-file "$PR_BODY_FILE" --auto-redact <ids>`
output back into the body file and rerun the block, which scans again. Only when the
user accepts the findings as they are, rerun the same block with
`--confirm <confirm-token>` added to the command that printed it; any edit needs a new token.
- **3** `gh`/`glab` failed, or **64** no usable remote or CLI: print the error, the
branch name, the remote URL and the block for posting by hand. Do not stop — the code
is pushed — but never report a PR that was not created.
- Any other exit is an error that blocks publication until its cause is fixed.
**Output the PR/MR URL** — then proceed to Step 20.
+69 -55
View File
@@ -1,8 +1,31 @@
### Prepare the title (Step 18)
Prepare the title in a private file; Step 19 posts that file, never a shell string:
{{FREE_TEXT_FILE:TITLE_FILE=pr-title}}
1. For an existing open PR/MR, fill the file from the platform, substituting the
printed name, the matched number and `NEW_VERSION`; no title text passes through you:
```bash
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
gh pr view <pr-number> --json title -q .title | ~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh <new-version> --stdin > "$TITLE_FILE" || exit 1
```
GitLab reads `glab mr view <pr-number> --output json | jq -r .title` into the same pipe.
2. For a new PR/MR, write `v<NEW_VERSION> <type>: <summary>` into the file with your file-write tool.
3. Every created or updated title MUST start with `v$NEW_VERSION `; check the file with
`grep -Eq '^v<new-version>( |$)' "$TITLE_FILE"` and never publish an unprefixed title.
4. **NO_VERSION:** replaces items 1-3: keep an existing title (the platform title
piped straight into the file), or write `<type>: <summary>`; no version prefix.
Save the title file name for Step 19.
---
## Step 19: Create PR/MR
Recheck Step 18's PR/MR lookup and record it. Errors or ambiguous matches STOP publication.
If the open PR/MR or title changed, repeat Step 18's identity/title preparation,
then return here for a new lookup, fresh body and both redaction scans before publishing.
then return here for a new lookup and a fresh body before publishing.
### Resolve Linked Spec before composing the body
@@ -22,7 +45,7 @@ then return here for a new lookup, fresh body and both redaction scans before pu
completed Step 8 plan scope permits `Closes #N`, with every spec criterion
verified. Partial, deferred, failed, dropped or unverified scope uses `Linked to #N`
and names the remaining work; never auto-close it. Include the archive filename
and `spec_filed_at`, not a private absolute path. Send these fields through the same redaction scan.
and `spec_filed_at`, not a private absolute path; `gstack-post` scans them with the body.
The PR/MR body should contain these sections (never reuse a prior run's body):
@@ -90,7 +113,7 @@ Unavailable/inconclusive is never PASS.>
## Documentation
<Embed Step 14.5's vetted nonempty `documentation_section` for this invocation:
its saved section file, inserted unchanged by the scan block's `DOCS_SECTION_FILE` lines.
its saved section file, inserted unchanged by the compose block's `DOCS_SECTION_FILE` lines.
A blocked audit shipped under a user exception has no section file: state its
blocked status, scope and exception here and drop the block's `DOCS_SECTION_FILE` guard
and its `cat -- "$DOCS_SECTION_FILE" && echo &&` step.>
@@ -102,83 +125,74 @@ and its `cat -- "$DOCS_SECTION_FILE" && echo &&` step.>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
```
#### Redaction scan (PR body + title) — runs before create AND edit
#### Compose the body, then publish through gstack-post
The PR body is world-readable on a public repo. Scan-at-sink before sending:
compose the body into a temp file, scan THAT file with the shared engine,
and pass the same file to `gh`/`glab`. Wrap any Codex / Greptile / eval output
sections in tool-attributed fences (` ```codex-review ` / ` ```greptile `) so the
engine WARN-degrades the example credentials those tools quote instead of blocking
the PR (a live-format credential inside the fence still blocks).
The PR body is world-readable on a public repo. `gstack-post` scans the exact title
and body bytes it sends and passes them to `gh`/`glab` as arguments, so there is no
separate scan here. Wrap any Codex / Greptile / eval output sections in
tool-attributed fences (` ```codex-review ` / ` ```greptile `) so the scanner
WARN-degrades the example credentials those tools quote instead of blocking the PR
(a live-format credential inside the fence still blocks).
Write the body from above into two private files: the first through the
`## Documentation` heading line, the second from `## Test plan` on.
{{FREE_TEXT_FILE:BODY_TOP_FILE=pr-body-top:BODY_REST_FILE=pr-body-rest}}
Use Step 18's `NEW_TITLE` unchanged; its version prefix is already present.
In a new shell, restore the saved literal title before this block, and Step 14.5's
saved section file path as `DOCS_SECTION_FILE`. Substitute the printed names. The
block deletes both drafts; to change the body, write fresh ones.
In a new shell, restore Step 14.5's saved section file path as `DOCS_SECTION_FILE`
and substitute the printed names. The block deletes both drafts; to change the body,
write fresh ones.
```bash
: "${NEW_TITLE:?Restore the saved Step 18 title before scanning}"
: "${DOCS_SECTION_FILE:?Restore the saved Step 14.5 section file path before composing}"
REDACT_VIS=$(~/.claude/skills/gstack/bin/gstack-config get redact_repo_visibility 2>/dev/null)
[ -z "$REDACT_VIS" ] && REDACT_VIS=$(gh repo view --json visibility -q .visibility 2>/dev/null | tr 'A-Z' 'a-z')
REDACT_VIS="${REDACT_VIS:-unknown}"
PR_BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-pr-body.XXXXXX") || { echo "ERROR: mktemp failed — cannot scan the PR body; refusing to create the PR unscanned." >&2; exit 1; }
PR_BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-pr-body.XXXXXX") || { echo "ERROR: mktemp failed — cannot compose the PR body; refusing to publish." >&2; exit 1; }
BODY_TOP_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-top-file-name>"
BODY_REST_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-rest-file-name>"
[ -s "$BODY_TOP_FILE" ] && [ -s "$BODY_REST_FILE" ] || { echo "Not scanned: $BODY_TOP_FILE or $BODY_REST_FILE is empty, so the body was never written. Write both, then rerun." >&2; exit 1; }
[ -s "$BODY_TOP_FILE" ] && [ -s "$BODY_REST_FILE" ] || { echo "Not composed: $BODY_TOP_FILE or $BODY_REST_FILE is empty, so the body was never written. Write both, then rerun." >&2; exit 1; }
{ awk 1 "$BODY_TOP_FILE" && cat -- "$DOCS_SECTION_FILE" && echo && awk 1 "$BODY_REST_FILE"; } > "$PR_BODY_FILE" || exit 1
rm -f "$BODY_TOP_FILE" "$BODY_REST_FILE"
~/.claude/skills/gstack/bin/gstack-redact --from-file "$PR_BODY_FILE" --repo-visibility "$REDACT_VIS" --self-email "$(git config user.email 2>/dev/null)" --json
case $? in
0) ;;
3) echo "BLOCKED — credential in PR body. Rotate + redact, do not create the PR."; exit 1 ;;
2) echo "MEDIUM findings — confirm per finding (sterner on public) before proceeding." ;;
*) echo "BLOCKED — PR body scan failed. Repair the scanner and repeat before publication."; exit 1 ;;
esac
printf '%s' "$NEW_TITLE" | ~/.claude/skills/gstack/bin/gstack-redact --repo-visibility "$REDACT_VIS" --json
echo "PR_BODY_FILE: $PR_BODY_FILE"
```
Check both scan results: exit 0 permits publication; exit 2 requires
AskUserQuestion per MEDIUM finding (PII offers `--auto-redact`); exit 3 blocks for
HIGH findings. Exit 1 or any other error blocks until the scanner works and both
scans pass. When visibility lookup is unavailable, including on GitLab, `unknown`
uses the scanner's public-strict policy.
In each block below, restore the literal `PR_BODY_FILE` path and substitute Step 18's
title file name; `gstack-post` detects GitHub or GitLab from the remote and the repo's
visibility (an unavailable lookup, including on GitLab, uses the scanner's
public-strict policy). Never re-render the body: every retry sends the same files.
For every create/edit command below, send the same scanned bytes. Never re-render
the body. In a new shell, restore the literal `PR_BODY_FILE` path and `NEW_TITLE`.
**Existing open PR/MR:** update using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub)
or `glab mr update -d "$(cat "$PR_BODY_FILE")"` (GitLab).
Update the title with the same scanned `NEW_TITLE`: `gh pr edit --title "$NEW_TITLE"` (or `glab mr update -t "$NEW_TITLE"`).
**REST fallback:** if `gh pr edit` fails with the `repository.pullRequest.projectCards` GraphQL deprecation, do not re-ask for auth. Use the SAME scanned file: `PR_NUMBER=$(gh pr view --json number -q .number)`, then `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -F body=@"$PR_BODY_FILE"`; for the title use `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -f title="$NEW_TITLE"`.
**Self-check:** re-fetch the title and assert it equals `NEW_TITLE`. Retry once if wrong, then surface any failure. Print the existing URL and continue to Step 20; do not run the create commands below.
**No open PR/MR, GitHub:**
**Existing open PR/MR** (`<pr-number>` from the recheck):
```bash
[ -s "$PR_BODY_FILE" ] || { echo "ERROR: scanned body file missing/empty — re-run the scan block." >&2; exit 1; }
gh pr create --base <base> --title "$NEW_TITLE" --body-file "$PR_BODY_FILE"
rm -f "$PR_BODY_FILE"
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
~/.claude/skills/gstack/bin/gstack-post pr-body <pr-number> --body-file "${PR_BODY_FILE:?restore the composed body path}" && \
~/.claude/skills/gstack/bin/gstack-post pr-title <pr-number> --title-file "$TITLE_FILE"
```
**No open PR/MR, GitLab:**
It updates the body, then the title, retries a retired-GraphQL edit through REST and
reads the title back. If Step 6.5 opened this PR as an early draft and the user did not
ask for a draft, mark it ready now: `gh pr ready <pr-number>`. Print the existing URL
and continue to Step 20; do not run the create command below.
**No open PR/MR:**
```bash
[ -s "$PR_BODY_FILE" ] || { echo "ERROR: scanned body file missing/empty — re-run the scan block." >&2; exit 1; }
glab mr create -b <base> -t "$NEW_TITLE" -d "$(cat "$PR_BODY_FILE")"
rm -f "$PR_BODY_FILE"
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
~/.claude/skills/gstack/bin/gstack-post pr-create --base <base> --title-file "$TITLE_FILE" --body-file "${PR_BODY_FILE:?restore the composed body path}"
```
**If neither CLI is available:**
Print the branch name, remote URL, and instruct the user to create the PR/MR manually via the web UI. Do not stop — the code is pushed and ready.
Branch on the exit code of either block:
- **0** posted. Remove `$PR_BODY_FILE` and the title file.
- **1** HIGH finding: BLOCKED — do not publish. Rotate and redact at the source, then
compose fresh files.
- **2** MEDIUM findings, printed as `RULE:` lines with a `TOKEN:`. AskUserQuestion per
finding (sterner on public repos, no batch acknowledge). PII offers auto-redact:
write `~/.claude/skills/gstack/bin/gstack-redact --from-file "$PR_BODY_FILE" --auto-redact <ids>`
output back into the body file and rerun the block, which scans again. Only when the
user accepts the findings as they are, rerun the same block with
`--confirm <confirm-token>` added to the command that printed it; any edit needs a new token.
- **3** `gh`/`glab` failed, or **64** no usable remote or CLI: print the error, the
branch name, the remote URL and the block for posting by hand. Do not stop — the code
is pushed — but never report a PR that was not created.
- Any other exit is an error that blocks publication until its cause is fixed.
**Output the PR/MR URL** — then proceed to Step 20.
+16 -17
View File
@@ -247,20 +247,8 @@ interrupt before the work happens.
#### File the issue (always)
**Re-scan before filing** (Phase 4 edits can introduce content the 4.5b scan
never saw, and the issue is world-readable):
#### Redaction scan — pre-issue (the issue body you're about to file)
Run the SAME scan-at-sink procedure shown above (resolve `$REDACT_VIS` once and
reuse it; when the issue body you're about to file changed since the last scan, rewrite the same `$REDACT_FILE`
with your file-write tool; `~/.claude/skills/gstack/bin/gstack-redact --from-file "$REDACT_FILE"
--repo-visibility "$REDACT_VIS" --json`), now on the issue body you're about to file. Apply the same
exit-3/2/0 handling. On exit 3, do NOT file the issue; HIGH has no skip. Pass the
same `$REDACT_FILE` downstream so the bytes scanned are the bytes sent.
If `gh` is available and authenticated, file from the scanned file. The title and
the one-line approach for the decision log are free text too, so they go into their
If `gh` is available and authenticated, file from the scanned draft file. The title
and the one-line approach for the decision log are free text too, so they go into their
own private files:
```bash
@@ -273,14 +261,19 @@ APPROACH_FILE=$(mktemp "${_GT:?}/approach.XXXXXX") || { echo "Not sent: mktemp f
Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand.
Then file, substituting the three printed names:
Then file, substituting the three printed names. `gstack-post` scans the exact title
and body it sends (Phase 4 edits can introduce content the 4.5b scan never saw, and the
issue is world-readable) and passes both to `gh` as arguments:
```bash
REDACT_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<redact-file-name>"
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
APPROACH_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<approach-file-name>"
[ -s "$REDACT_FILE" ] && [ -s "$TITLE_FILE" ] || { echo "Not filed: $REDACT_FILE or $TITLE_FILE is missing or empty. Write them, then file by hand: gh issue create --title \"\$(cat $TITLE_FILE)\" --body-file $REDACT_FILE" >&2; exit 1; }
ISSUE_URL=$(gh issue create --title "$(cat "$TITLE_FILE")" --body-file "$REDACT_FILE") || exit 1
[ -s "$REDACT_FILE" ] && [ -s "$TITLE_FILE" ] || { echo "Not filed: $REDACT_FILE or $TITLE_FILE is missing or empty. Write them, then file by hand: ~/.claude/skills/gstack/bin/gstack-post issue-create --title-file $TITLE_FILE --body-file $REDACT_FILE" >&2; exit 1; }
POST_OUT=$(~/.claude/skills/gstack/bin/gstack-post issue-create --title-file "$TITLE_FILE" --body-file "$REDACT_FILE"); POST_CODE=$?
printf '%s\n' "$POST_OUT"
[ "$POST_CODE" = 0 ] || { echo "Not filed (gstack-post exit $POST_CODE)." >&2; exit "$POST_CODE"; }
ISSUE_URL=$(printf '%s\n' "$POST_OUT" | grep -m1 -E '^https?://')
ISSUE_NUMBER=$(echo "$ISSUE_URL" | sed -E 's|.*/issues/([0-9]+)$|\1|')
echo "Filed: $ISSUE_URL (ISSUE_NUMBER: $ISSUE_NUMBER)"
[ -s "$APPROACH_FILE" ] && ~/.claude/skills/gstack/bin/gstack-decision-log "$(jq -cn --arg n "$ISSUE_NUMBER" --rawfile t "$TITLE_FILE" --rawfile a "$APPROACH_FILE" \
@@ -288,6 +281,12 @@ echo "Filed: $ISSUE_URL (ISSUE_NUMBER: $ISSUE_NUMBER)"
rm -f "$APPROACH_FILE"
```
Exit 1 (HIGH): do NOT file; rotate and redact at source, no skip. Exit 2 (MEDIUM): ask
per printed `RULE:` line exactly as in the 4.5b disposition (auto-redact rewrites
`$REDACT_FILE`, which the archive then uses); when the user accepts a finding as it
is, rerun the block with `--confirm <confirm-token>` after `--body-file "$REDACT_FILE"`.
Any edit needs a new scan, so the token no longer applies. Exit 3: `gh` failed; report it.
The last line records the spec as a durable, issue-scoped cross-session decision so a future session (or `/ship` closing the issue) inherits the core approach and why, not just the issue link. Non-interactive, best-effort (`|| true`). The approach file holds the one core approach/decision the spec settled. Only fires when the issue was actually filed.
If `gh` is not available, print: "`gh` not authenticated — title and body below
+16 -10
View File
@@ -120,25 +120,25 @@ interrupt before the work happens.
#### File the issue (always)
**Re-scan before filing** (Phase 4 edits can introduce content the 4.5b scan
never saw, and the issue is world-readable):
{{REDACT_INVOCATION_BLOCK:pre-issue:brief}}
If `gh` is available and authenticated, file from the scanned file. The title and
the one-line approach for the decision log are free text too, so they go into their
If `gh` is available and authenticated, file from the scanned draft file. The title
and the one-line approach for the decision log are free text too, so they go into their
own private files:
{{FREE_TEXT_FILE:TITLE_FILE=title:APPROACH_FILE=approach}}
Then file, substituting the three printed names:
Then file, substituting the three printed names. `gstack-post` scans the exact title
and body it sends (Phase 4 edits can introduce content the 4.5b scan never saw, and the
issue is world-readable) and passes both to `gh` as arguments:
```bash
REDACT_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<redact-file-name>"
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
APPROACH_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<approach-file-name>"
[ -s "$REDACT_FILE" ] && [ -s "$TITLE_FILE" ] || { echo "Not filed: $REDACT_FILE or $TITLE_FILE is missing or empty. Write them, then file by hand: gh issue create --title \"\$(cat $TITLE_FILE)\" --body-file $REDACT_FILE" >&2; exit 1; }
ISSUE_URL=$(gh issue create --title "$(cat "$TITLE_FILE")" --body-file "$REDACT_FILE") || exit 1
[ -s "$REDACT_FILE" ] && [ -s "$TITLE_FILE" ] || { echo "Not filed: $REDACT_FILE or $TITLE_FILE is missing or empty. Write them, then file by hand: ~/.claude/skills/gstack/bin/gstack-post issue-create --title-file $TITLE_FILE --body-file $REDACT_FILE" >&2; exit 1; }
POST_OUT=$(~/.claude/skills/gstack/bin/gstack-post issue-create --title-file "$TITLE_FILE" --body-file "$REDACT_FILE"); POST_CODE=$?
printf '%s\n' "$POST_OUT"
[ "$POST_CODE" = 0 ] || { echo "Not filed (gstack-post exit $POST_CODE)." >&2; exit "$POST_CODE"; }
ISSUE_URL=$(printf '%s\n' "$POST_OUT" | grep -m1 -E '^https?://')
ISSUE_NUMBER=$(echo "$ISSUE_URL" | sed -E 's|.*/issues/([0-9]+)$|\1|')
echo "Filed: $ISSUE_URL (ISSUE_NUMBER: $ISSUE_NUMBER)"
[ -s "$APPROACH_FILE" ] && ~/.claude/skills/gstack/bin/gstack-decision-log "$(jq -cn --arg n "$ISSUE_NUMBER" --rawfile t "$TITLE_FILE" --rawfile a "$APPROACH_FILE" \
@@ -146,6 +146,12 @@ echo "Filed: $ISSUE_URL (ISSUE_NUMBER: $ISSUE_NUMBER)"
rm -f "$APPROACH_FILE"
```
Exit 1 (HIGH): do NOT file; rotate and redact at source, no skip. Exit 2 (MEDIUM): ask
per printed `RULE:` line exactly as in the 4.5b disposition (auto-redact rewrites
`$REDACT_FILE`, which the archive then uses); when the user accepts a finding as it
is, rerun the block with `--confirm <confirm-token>` after `--body-file "$REDACT_FILE"`.
Any edit needs a new scan, so the token no longer applies. Exit 3: `gh` failed; report it.
The last line records the spec as a durable, issue-scoped cross-session decision so a future session (or `/ship` closing the issue) inherits the core approach and why, not just the issue link. Non-interactive, best-effort (`|| true`). The approach file holds the one core approach/decision the spec settled. Only fires when the issue was actually filed.
If `gh` is not available, print: "`gh` not authenticated — title and body below
+17 -11
View File
@@ -52,20 +52,25 @@ function sandbox(name: string) {
const log = path.join(dir, 'log');
const runtime = path.join(dir, 'runtime');
for (const d of [repo, bin, log, path.join(runtime, 'bin'), path.join(dir, 'state')]) fs.mkdirSync(d, { recursive: true });
expect(spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 }).status).toBe(0);
// The fixture ignores the machine's git config (a url.insteadOf rewrite
// would change the origin gstack-post resolves).
fs.writeFileSync(path.join(dir, 'gitconfig'), '');
const gitEnv = { GIT_CONFIG_GLOBAL: path.join(dir, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' };
expect(spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000, env: { ...process.env, ...gitEnv } }).status).toBe(0);
expect(spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/o/r.git'], { cwd: repo, timeout: 10_000, env: { ...process.env, ...gitEnv } }).status).toBe(0);
const stub = (file: string, body: string) => fs.writeFileSync(file, `#!/usr/bin/env bash\n${body}\n`, { mode: 0o755 });
stub(path.join(bin, 'gh'), `
LOG="${log}"
n=$(ls "$LOG" | wc -l | tr -d ' ')
case "$1 $2" in
"repo view") echo o/r; exit 0 ;;
"repo view") case "$*" in *visibility*) echo PRIVATE ;; *) echo o/r ;; esac; exit 0 ;;
"pr view") echo 7; exit 0 ;;
"issue view") echo "https://github.com/o/r/issues/$3"; exit 0 ;;
esac
printf '%s\\n' "$@" > "$LOG/$n.args"
prev=""
for a in "$@"; do
case "$a" in body=@*) cp "\${a#body=@}" "$LOG/$n.body" ;; esac
case "$a" in body=@-|--field=body=@-|--body-file=-) cat > "$LOG/$n.body" ;; body=@*) cp "\${a#body=@}" "$LOG/$n.body" ;; --title=*) printf '%s' "\${a#--title=}" > "$LOG/$n.title" ;; esac
[ "$prev" = "--body-file" ] && cp "$a" "$LOG/$n.body"
[ "$prev" = "--title" ] && printf '%s' "$a" > "$LOG/$n.title"
prev="$a"
@@ -81,11 +86,12 @@ for a in "$@"; do
done
printf '%s\\n' "$@" > "$LOG/$n.args"`);
stub(path.join(runtime, 'bin', 'gstack-decision-log'), `printf '%s' "$1" > "${log}/decision.json"`);
stub(path.join(runtime, 'bin', 'gstack-post'), `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(path.join(ROOT, 'bin', 'gstack-post'))} "$@"`);
stub(path.join(runtime, 'bin', 'gstack-paths'), `echo "${path.join(dir, 'state')}"`);
stub(path.join(runtime, 'bin', 'gstack-slug'), 'echo proj');
const run = (script: string) => spawnSync('bash', ['-c', script.replaceAll('~/.claude/skills/gstack', runtime)], {
cwd: repo, encoding: 'utf8', timeout: 15_000,
env: { PATH: `${bin}:${process.env.PATH}`, HOME: path.join(dir, 'home'), TMPDIR: dir },
env: { PATH: `${bin}:${process.env.PATH}`, HOME: path.join(dir, 'home'), TMPDIR: dir, ...gitEnv },
});
/** Run a create block, then do what the agent's file-write tool does: write each printed file. */
const create = (script: string, texts: Record<string, string>) => {
@@ -158,8 +164,8 @@ describe('identifier grammars (CEO-12, ENG-8)', () => {
describe('Greptile replies through the real review/greptile-triage.md blocks', () => {
const doc = fs.readFileSync(path.join(ROOT, 'review/greptile-triage.md'), 'utf8');
const createBlock = fence(doc, 'REPLY_FILE=$(mktemp');
const lineBlock = fence(doc, 'comments/<comment-id>/replies');
const topBlock = fence(doc, 'issues/$PR_NUMBER/comments" -F');
const lineBlock = fence(doc, 'gstack-post reply "$PR_NUMBER"');
const topBlock = fence(doc, 'gstack-post pr-comment "$PR_NUMBER"');
test('the create block is the shared free-text block', () => {
expect(createBlock).toBe(freeTextFileBash([{ variable: 'REPLY_FILE', stem: 'reply' }]));
@@ -170,7 +176,7 @@ describe('Greptile replies through the real review/greptile-triage.md blocks', (
expect(fence(doc, 'GREPTILE_DIR=$(mktemp -d')).toContain('"$GREPTILE_DIR/line.json"');
});
for (const [kind, block, api] of [['line-level', lineBlock, 'repos/o/r/pulls/7/comments/123/replies'], ['top-level', topBlock, 'repos/o/r/issues/7/comments']] as const) {
for (const [kind, block, args] of [['line-level', lineBlock, ['api', '--method=POST', 'repos/{owner}/{repo}/pulls/7/comments/123/replies', '--field=body=@-']], ['top-level', topBlock, ['pr', 'comment', '7', '--body-file=-']]] as const) {
test(`${kind}: a hostile reply posts byte for byte and nothing in it runs`, () => {
const s = sandbox(`greptile-${kind}`);
const canary = path.join(s.dir, 'PWNED');
@@ -179,7 +185,7 @@ describe('Greptile replies through the real review/greptile-triage.md blocks', (
const r = s.run(block.replaceAll('<reply-file-name>', names.REPLY_FILE).replaceAll('<comment-id>', '123'));
expect(r.status, r.stderr).toBe(0);
const [call] = s.calls();
expect(call.args.slice(0, 2)).toEqual(['api', api]);
expect(call.args).toEqual([...args]);
expect(call.body).toBe(body);
expect(ranAnything(s.dir, canary)).toEqual([]);
expect(fs.readdirSync(path.join(s.repo, '.gstack/tmp'))).toEqual([]);
@@ -195,7 +201,7 @@ describe('Greptile replies through the real review/greptile-triage.md blocks', (
expect(r.status).toBe(1);
const file = path.join(fs.realpathSync(s.repo), '.gstack/tmp', names.REPLY_FILE);
expect(r.stderr).toContain(`Not sent: ${file} is missing or empty`);
expect(r.stderr).toContain(`gh api repos/o/r/pulls/7/comments/123/replies -F body=@${file}`);
expect(r.stderr).toContain(`gstack-post reply 7 --to 123 --body-file ${file}`);
expect(s.calls()).toEqual([]);
expect(fs.existsSync(file)).toBe(true);
});
@@ -248,7 +254,7 @@ describe('/spec filing and archive through the real generated gate-and-file text
const doc = fs.readFileSync(path.join(ROOT, 'spec/sections/gate-and-file.md'), 'utf8');
const draftBlock = fence(doc, 'REDACT_FILE=$(mktemp');
const titleBlock = fence(doc, 'TITLE_FILE=$(mktemp');
const fileBlock = fence(doc, 'ISSUE_URL=$(gh issue create');
const fileBlock = fence(doc, 'gstack-post issue-create --title-file "$TITLE_FILE"');
const archiveBlock = fence(doc, 'ARCHIVE_PATH.tmp');
test('hostile title, body and approach are filed and archived as literal text', () => {
@@ -284,7 +290,7 @@ describe('/spec filing and archive through the real generated gate-and-file text
const r = s.run(fileBlock.replaceAll('<redact-file-name>', draft.REDACT_FILE).replaceAll('<title-file-name>', files.TITLE_FILE).replaceAll('<approach-file-name>', files.APPROACH_FILE));
expect(r.status).toBe(1);
expect(r.stderr).toContain('Not filed:');
expect(r.stderr).toContain('gh issue create --title "$(cat ');
expect(r.stderr).toContain('gstack-post issue-create --title-file ');
expect(s.calls()).toEqual([]);
});
});
+2
View File
@@ -47,6 +47,8 @@ export const IDENTIFIER_PLACEHOLDERS: Record<string, IdentifierGrammar> = {
'<key>': { grammar: ID, what: 'option key' },
'<summary-slug>': { grammar: ID, what: 'question summary as a kebab-case slug' },
'<comment-id>': { grammar: NUM, what: 'GitHub comment id from the fetched JSON' },
'<pr-number>': { grammar: NUM, what: 'PR/MR number from the open-PR lookup or the early-PR create output' },
'<new-version>': { grammar: /^[0-9]+(?:\.[0-9]+)*$/, what: 'NEW_VERSION from the version bump (dot-separated digits)' },
'<issue-number>': { grammar: /^[0-9]*$/, what: 'filed issue number, or empty when none was filed' },
'<check-number>': { grammar: NUM, what: 'canary check number' },
'<run-id>': { grammar: NUM, what: 'GitHub Actions run id' },
+17
View File
@@ -68,4 +68,21 @@ describe('gstack-pr-title-rewrite', () => {
const twice = rewrite('1.2.3.4', once).stdout;
expect(twice).toBe(once);
});
test('--stdin reads the platform title from standard input, so no shell string carries it', () => {
const title = "feat: `id` $(touch x) it's \"quoted\"";
const r = spawnSync(HELPER, ['1.2.3.4', '--stdin'], { input: title + '\n', encoding: 'utf-8', timeout: 30_000 });
expect(r.status).toBe(0);
expect(r.stdout).toBe(`v1.2.3.4 ${title}\n`);
const again = spawnSync(HELPER, ['1.2.3.4', '--stdin'], { input: 'v1.2.3 fix: y', encoding: 'utf-8', timeout: 30_000 });
expect(again.stdout).toBe('v1.2.3.4 fix: y\n');
});
test('--stdin refuses an empty or multi-line title', () => {
for (const input of ['', 'a\nb\n']) {
const r = spawnSync(HELPER, ['1.2.3.4', '--stdin'], { input, encoding: 'utf-8', timeout: 30_000 });
expect(r.status).toBe(2);
expect(r.stdout).toBe('');
}
});
});
+20 -6
View File
@@ -6,6 +6,8 @@
* branches, the which-bun probe, and the guardrail framing.
*/
import { describe, test, expect } from "bun:test";
import { spawnSync } from "child_process";
import * as path from "path";
import {
generateRedactInvocationBlock,
} from "../scripts/resolvers/redact-doc";
@@ -22,7 +24,7 @@ const ctx = {
describe("REDACT_INVOCATION_BLOCK", () => {
test("scan-at-sink: agent-written private file → scan that file → exact bytes", () => {
const block = generateRedactInvocationBlock(ctx, ["pre-issue"]);
const block = generateRedactInvocationBlock(ctx, ["pre-archive"]);
// CEO-12: the bytes come from the file the agent wrote (created by the
// shared mktemp free-text block), never from a heredoc.
expect(block).toContain('/.gstack/tmp/<redact-file-name>"');
@@ -39,34 +41,46 @@ describe("REDACT_INVOCATION_BLOCK", () => {
});
test("resolves visibility config → gh → glab → unknown", () => {
const block = generateRedactInvocationBlock(ctx, ["pre-issue"]);
const block = generateRedactInvocationBlock(ctx, ["pre-archive"]);
expect(block).toContain("redact_repo_visibility");
expect(block).toContain("gh repo view --json visibility");
expect(block).toContain("glab repo view");
});
test("includes a which-bun probe", () => {
expect(generateRedactInvocationBlock(ctx, ["pre-issue"])).toContain("command -v bun");
expect(generateRedactInvocationBlock(ctx, ["pre-archive"])).toContain("command -v bun");
});
test("HIGH has no skip flag; framed as guardrail not enforcement", () => {
const block = generateRedactInvocationBlock(ctx, ["pre-issue"]);
const block = generateRedactInvocationBlock(ctx, ["pre-archive"]);
expect(block).toMatch(/no skip flag for HIGH/i);
expect(block).toMatch(/guardrail, not airtight enforcement/i);
});
test("PII subset offers auto-redact; non-PII MEDIUM does not", () => {
const block = generateRedactInvocationBlock(ctx, ["pre-pr-body"]);
const block = generateRedactInvocationBlock(ctx, ["pre-commit"]);
expect(block).toContain("--auto-redact");
expect(block).toContain("Proceed (acknowledged)");
});
test("sink label drives the prose noun/verb", () => {
expect(generateRedactInvocationBlock(ctx, ["pre-commit"])).toContain("commit");
expect(generateRedactInvocationBlock(ctx, ["pre-pr-title"])).toContain("PR title");
expect(generateRedactInvocationBlock(ctx, ["pre-archive"])).toContain("write the archive");
});
test("unknown sink label falls back without throwing", () => {
expect(() => generateRedactInvocationBlock(ctx, ["bogus-sink"])).not.toThrow();
});
});
describe("PR and issue sites carry no separate scan (CEO-19)", () => {
// gstack-post scans the exact bytes it posts; a second scan at a PR or issue
// site would ask the user twice and could drift from what is sent.
test("no template renders a PR, MR or issue sink", () => {
const r = spawnSync("git", ["grep", "-n", "-E", "REDACT_INVOCATION_BLOCK:pre-(issue|pr-)", "--", "*.tmpl"], {
cwd: path.resolve(import.meta.dir, ".."), encoding: "utf8", timeout: 30_000,
});
expect(r.stdout).toBe("");
expect(r.status).toBe(1);
});
});
@@ -79,10 +79,11 @@ describe("#2679: skill-content mktemp guards", () => {
expect(body).toMatch(/PR_BODY_FILE=\$\(mktemp "\$\{TMPDIR:-\/tmp\}\/[^"]+"\)\s*\|\|\s*\{[^}]*exit 1/);
});
test("ship pr-body GitLab path sends the SCANNED file, never a re-rendered heredoc", () => {
test("ship pr-body GitLab path sends the SCANNED file through gstack-post, never a re-rendered heredoc", () => {
const body = readScript("ship/sections/pr-body.md.tmpl");
expect(body).toContain('-d "$(cat "$PR_BODY_FILE")"');
expect(body).not.toMatch(/glab mr create[^\n]*-d "\$\(cat <<'EOF'/);
expect(body).toContain('gstack-post pr-create --base <base> --title-file "$TITLE_FILE" --body-file "${PR_BODY_FILE:?restore the composed body path}"');
expect(body).not.toMatch(/glab mr (?:create|update)/);
expect(body).not.toMatch(/cat <<'EOF'/);
});
test("gstack-upgrade vendored block guards mktemp -d and clone with loud aborts", () => {
+1 -1
View File
@@ -333,6 +333,6 @@ describe('ship source controller', () => {
expect(push).toMatch(/never bypass failed guards/i);
const publication = compact(read('ship/sections/pr-body.md.tmpl'));
expect(publication).toMatch(/repeat Step 18's identity\/title preparation/i);
expectMentions(publication, [['before', 'publishing', 'redaction']], 'publication');
expectMentions(publication, [['fresh body', 'before', 'publishing']], 'publication');
});
});
+103 -38
View File
@@ -1,5 +1,5 @@
import { expect, test } from 'bun:test';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { expectMentions } from './helpers/prompt-structure';
@@ -13,45 +13,86 @@ function writeDrafts(root: string, top = '## Summary\n\n## Documentation', rest
writeFileSync(join(root, '.gstack/tmp/pr-body-rest.abc123'), rest);
return block => block.replace('<body-top-file-name>', 'pr-body-top.abc123').replace('<body-rest-file-name>', 'pr-body-rest.abc123');
}
const scanner = resolve(import.meta.dir, '../bin/gstack-redact');
const POST = resolve(import.meta.dir, '../bin/gstack-post');
const fenceAfter = (marker: string) => {
const at = template.indexOf(marker);
expect(at).toBeGreaterThan(-1);
const open = template.indexOf('```bash\n', at) + 8;
return template.slice(open, template.indexOf('\n```', open));
};
const composeBlock = template.match(/```bash\n(: "\$\{DOCS_SECTION_FILE:[\s\S]*?)\n```/)?.[1]!;
const editBlock = fenceAfter('**Existing open PR/MR**');
const createBlock = fenceAfter('**No open PR/MR:**');
/**
* A repo whose origin is GitHub, a stub gh that records argv and stdin, and
* the real gstack-post at the installed path the blocks call.
*/
function publicationRepo(root: string) {
const log = join(root, 'log');
const stubs = join(root, 'stubs');
const bin = join(root, '.claude/skills/gstack/bin');
for (const dir of [log, stubs, bin]) mkdirSync(dir, { recursive: true });
writeFileSync(join(root, 'gitconfig'), '');
const gitEnv = { GIT_CONFIG_GLOBAL: join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' };
for (const args of [['init', '-q'], ['remote', 'add', 'origin', 'https://github.com/o/r.git']]) {
Bun.spawnSync(['git', ...args], { cwd: root, env: { ...process.env, ...gitEnv }, timeout: 10_000 });
}
writeFileSync(join(bin, 'gstack-post'), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(POST)} "$@"\n`, { mode: 0o755 });
writeFileSync(join(stubs, 'gh'), `#!/usr/bin/env bash
case "$1 $2" in
"repo view") echo PRIVATE; exit 0 ;;
"pr view") cat "${log}/title" 2>/dev/null; exit 0 ;;
esac
n=$(ls "${log}" | grep -c args)
for a in "$@"; do printf '%s\\0' "$a"; done > "${log}/$n.args"
cat > "${log}/$n.stdin"
for a in "$@"; do case "$a" in --title=*) printf '%s\\n' "\${a#--title=}" > "${log}/title" ;; esac; done
[ "$1 $2" = "pr create" ] && echo "https://github.com/o/r/pull/9"
exit 0
`, { mode: 0o755 });
const run = (script: string) => Bun.spawnSync(['bash', '-c', script], {
cwd: root, env: { ...process.env, ...gitEnv, HOME: root, PATH: `${stubs}:${process.env.PATH}`, DOCS_SECTION_FILE: join(root, 'section.md'), TMPDIR: root },
stdout: 'pipe', stderr: 'pipe', timeout: 30_000,
});
const calls = () => readdirSync(log).filter(f => f.endsWith('.args')).sort().map(f => ({
args: readFileSync(join(log, f), 'utf8').split('\0').slice(0, -1),
stdin: readFileSync(join(log, f.replace('.args', '.stdin')), 'utf8'),
}));
return { run, calls };
}
/** Compose the body with the real compose block and write the Step 18 title file. */
function composed(root: string, opts: { title?: string; top?: string } = {}) {
const repo = publicationRepo(root);
writeFileSync(join(root, 'section.md'), '**Status:** current — no edits.');
const compose = repo.run(writeDrafts(root, opts.top)(composeBlock));
expect(compose.exitCode, compose.stderr.toString()).toBe(0);
const bodyFile = /^PR_BODY_FILE: (\S+)$/m.exec(compose.stdout.toString())![1]!;
writeFileSync(join(root, '.gstack/tmp/pr-title.abc123'), (opts.title ?? 'v1.2.3.4 fix: publication checks') + '\n');
const fill = (block: string) => `PR_BODY_FILE=${JSON.stringify(bodyFile)}\n` +
block.replace('<title-file-name>', 'pr-title.abc123').replaceAll('<pr-number>', '9').replace('<base>', 'main');
return { ...repo, bodyFile, publish: (block: string) => repo.run(fill(block)) };
}
for (const failure of ['none', 'body', 'title'] as const) {
test(`publication scan consumes the real scanner result: ${failure}`, () => {
const root = mkdtempSync(join(tmpdir(), 'ship-scan-'));
test(`publication posts only scanned bytes through gstack-post: ${failure}`, () => {
const root = mkdtempSync(join(tmpdir(), 'ship-post-'));
try {
const bin = join(root, '.claude/skills/gstack/bin');
mkdirSync(bin, { recursive: true });
writeFileSync(join(bin, 'gstack-config'), '#!/bin/sh\nprintf "private\\n"\n', { mode: 0o755 });
writeFileSync(join(bin, 'gstack-redact'), `#!/bin/sh
kind=title
case "$*" in *--from-file*) kind=body ;; esac
printf '%s\\n' "$kind" >> "$HOME/scans"
if [ "$kind" = "$FAIL_AT" ]; then
exec ${JSON.stringify(process.execPath)} ${JSON.stringify(scanner)} --from-file "$HOME/missing-input" --json
fi
exec ${JSON.stringify(process.execPath)} ${JSON.stringify(scanner)} "$@"
`, { mode: 0o755 });
const block = template.match(/```bash\n(: "\$\{NEW_TITLE:[\s\S]*?)\n```/)?.[1];
expect(block).toBeDefined();
writeFileSync(join(root, 'section.md'), '**Status:** current — no edits.');
const result = Bun.spawnSync(['bash', '-c', writeDrafts(root)(block!)], {
cwd: root,
env: { ...process.env, HOME: root, NEW_TITLE: 'v1.2.3.4 fix: publication checks', FAIL_AT: failure,
DOCS_SECTION_FILE: join(root, 'section.md') },
stdout: 'pipe', stderr: 'pipe', timeout: 10_000,
const secret = 'AKIA' + '1234567890ABCDEF';
const c = composed(root, {
top: failure === 'body' ? `## Summary\nkey ${secret}\n\n## Documentation` : undefined,
title: failure === 'title' ? `v1.2.3.4 fix: ${secret}` : undefined,
});
const output = result.stdout.toString();
const error = result.stderr.toString();
const calls = readFileSync(join(root, 'scans'), 'utf8').trim().split('\n');
const result = c.publish(createBlock);
if (failure === 'none') {
expect(result.exitCode).toBe(0);
expect(calls).toEqual(['body', 'title']);
expect(output).toContain('"HIGH": 0');
expect(result.exitCode, result.stderr.toString()).toBe(0);
expect(result.stdout.toString()).toContain('https://github.com/o/r/pull/9');
expect(c.calls()).toEqual([{ args: ['pr', 'create', '--base=main', '--title=v1.2.3.4 fix: publication checks', '--body-file=-'], stdin: readFileSync(c.bodyFile, 'utf8') }]);
} else {
expect(result.exitCode).toBe(1);
expect(error).toContain('missing-input');
expect(calls).toEqual(failure === 'body' ? ['body'] : ['body', 'title']);
if (failure === 'body') expect(output).toContain('BLOCKED');
expect(result.stdout.toString()).toContain(`HIGH RULE: aws.access_key LINE: ${failure === 'body' ? 2 : 1} PART: ${failure}`);
expect(c.calls()).toEqual([]);
}
} finally {
rmSync(root, { recursive: true, force: true });
@@ -59,9 +100,33 @@ exec ${JSON.stringify(process.execPath)} ${JSON.stringify(scanner)} "$@"
});
}
test('publication composes the saved documentation section unchanged and refuses to scan without it', () => {
const block = template.match(/```bash\n(: "\$\{NEW_TITLE:[\s\S]*?)\n```/)?.[1]!;
const compose = block.slice(block.indexOf('PR_BODY_FILE=$(mktemp'), block.indexOf('~/.claude/skills/gstack/bin/gstack-redact --from-file'));
test('MEDIUM findings publish nothing until the block reruns with the printed token', () => {
const root = mkdtempSync(join(tmpdir(), 'ship-post-medium-'));
try {
const c = composed(root, { top: '## Summary\nThanks jane.roe@acme-corp.io\n\n## Documentation' });
const first = c.publish(editBlock);
expect(first.exitCode).toBe(2);
expect(first.stdout.toString()).toContain('RULE: pii.email LINE: 2 PART: body');
expect(c.calls()).toEqual([]);
const token = /^TOKEN: (\S+)$/m.exec(first.stdout.toString())![1]!;
const flagged = 'pr-body <pr-number> --body-file "${PR_BODY_FILE:?restore the composed body path}"';
expect(editBlock).toContain(flagged);
const then = c.publish(editBlock.replace(flagged, `${flagged} --confirm ${token}`));
expect(then.exitCode, then.stderr.toString()).toBe(0);
const calls = c.calls();
expect(calls.map(call => call.args)).toEqual([
['pr', 'edit', '9', '--body-file=-'],
['pr', 'edit', '9', '--title=v1.2.3.4 fix: publication checks'],
]);
expect(calls[0]!.stdin).toBe(readFileSync(c.bodyFile, 'utf8'));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('publication composes the saved documentation section unchanged and refuses to compose without it', () => {
const block = composeBlock;
const compose = block.slice(block.indexOf('PR_BODY_FILE=$(mktemp'), block.indexOf('echo "PR_BODY_FILE:'));
const root = mkdtempSync(join(tmpdir(), 'ship-compose-'));
try {
const section = '**Status:** current — no edits.\n\n- Diagram drift: none (no diagrams in any doc).';
@@ -85,7 +150,7 @@ test('publication composes the saved documentation section unchanged and refuses
expect(unwritten.stdout.toString()).toBe('');
expect(unwritten.stderr.toString()).toContain('the body was never written');
const unset = Bun.spawnSync(['bash', '-c', block], {
cwd: root, env: { ...process.env, HOME: root, NEW_TITLE: 'v1.2.3.4 fix: publication checks', DOCS_SECTION_FILE: '' },
cwd: root, env: { ...process.env, HOME: root, DOCS_SECTION_FILE: '' },
stdout: 'pipe', stderr: 'pipe', timeout: 10_000,
});
expect(unset.exitCode).not.toBe(0);
@@ -106,7 +171,7 @@ test('publication reports unavailable triage separately from an empty successful
test('publication refreshes the open review and title without treating lookup failure as absence', () => {
const lookup = template.slice(0, template.indexOf('### Resolve Linked Spec'));
expect(lookup.replace(/\s+/g, ' ')).toContain('Errors or ambiguous matches STOP publication');
expectMentions(lookup.replace(/\s+/g, ' '), [['before', 'publishing', 'redaction']], 'lookup.replace(/\s+/g, )');
expectMentions(lookup.replace(/\s+/g, ' '), [['fresh body', 'before', 'publishing']], 'lookup.replace(/\s+/g, )');
expect(lookup).not.toContain('Ship control flow');
expect(lookup).not.toContain('|| echo "NO_PR"');
expect(lookup).not.toContain('|| echo "NO_MR"');
+5 -5
View File
@@ -59,10 +59,10 @@ test('late behavioral repairs rebuild before the docs decision and commit only r
expect(commit).toMatch(/preserve unrelated user files/i);
});
test('the title is prefixed once before the exact scanned value is published', () => {
test('the title is prefixed once in Step 18 and posted from its file unchanged', () => {
const body = compact(read('ship/sections/pr-body.md.tmpl'));
expect(body).toContain("Step 18's `NEW_TITLE` unchanged");
expect(body).not.toContain('`NEW_TITLE`, prefixed with');
expect(body).toContain('--title "$NEW_TITLE"');
expect(body).toContain('scanned `NEW_TITLE`');
expect(body).toContain("substitute Step 18's title file name");
expect(body).not.toContain('prefixed with');
expect(body).toContain('--title-file "$TITLE_FILE"');
expect(body).toContain('every retry sends the same files');
});
+22 -19
View File
@@ -1,7 +1,8 @@
/**
* /ship redaction wiring (T5/T11). The PR body + title are scanned at-sink before
* create AND edit; tool output goes in attributed fences so example credentials
* WARN-degrade instead of blocking; create/edit file from the scanned temp file.
* /ship redaction wiring (T5/T11, CEO-19). The PR body + title are published
* only through gstack-post, which scans the exact bytes it sends on create AND
* edit; tool output goes in attributed fences so example credentials
* WARN-degrade instead of blocking.
*/
import { describe, test, expect } from "bun:test";
import * as fs from "fs";
@@ -25,31 +26,33 @@ function readShipTemplateUnion(): string {
const TMPL = readShipTemplateUnion();
describe("/ship redaction wiring", () => {
test("scans the PR body via the shared bin before create", () => {
expect(TMPL).toContain("gstack-redact --from-file");
// CEO-19: gstack-post owns the scan of the exact title and body bytes it
// sends (test/gstack-post*.test.ts, test/ship-publication-gates.test.ts), so
// /ship publishes only through it and never calls gh/glab with text itself.
test("creates and edits only through gstack-post, from the composed body file and the title file", () => {
expect(TMPL).toContain('gstack-post pr-create --base <base> --title-file "$TITLE_FILE" --body-file "${PR_BODY_FILE:?restore the composed body path}"');
expect(TMPL).toContain('gstack-post pr-body <pr-number> --body-file "${PR_BODY_FILE:?restore the composed body path}"');
expect(TMPL).toContain('gstack-post pr-title <pr-number> --title-file "$TITLE_FILE"');
});
test("creates from the scanned temp file (exact bytes)", () => {
expect(TMPL).toMatch(/gh pr create[\s\S]{0,120}--body-file "\$PR_BODY_FILE"/);
test("no gh/glab call carries a title or body, and no shell variable holds the title", () => {
expect(TMPL).not.toMatch(/gh pr (?:create|edit)[^\n`]*--(?:title|body)/);
expect(TMPL).not.toMatch(/glab mr (?:create|update)[^\n`]*(?:-t|-d) /);
expect(TMPL).not.toMatch(/-f title=|-F body=@"\$PR_BODY_FILE"/);
expect(TMPL).not.toContain("NEW_TITLE");
});
test("edit path also scans before sending", () => {
expect(TMPL).toMatch(/gh pr edit --body-file "\$PR_BODY_FILE"/);
const scanAt = TMPL.indexOf('gstack-redact --from-file "$PR_BODY_FILE"');
expect(scanAt).toBeGreaterThan(0);
expect(TMPL.indexOf('gh pr edit --body-file "$PR_BODY_FILE"')).toBeGreaterThan(scanAt);
test("HIGH blocks the PR (exit 1), with no confirmation path", () => {
expect(TMPL).toMatch(/\*\*1\*\* HIGH finding: BLOCKED — do not publish/);
});
test("HIGH blocks the PR (exit 3), no skip", () => {
expect(TMPL).toMatch(/BLOCKED — credential in PR body/);
test("MEDIUM needs the user's answer and the token printed for those bytes", () => {
expect(TMPL).toContain("AskUserQuestion per\n finding");
expect(TMPL).toContain("`--confirm <confirm-token>`");
expect(TMPL).toContain("any edit needs a new token");
});
test("instructs wrapping tool output in attributed fences (TENSION-3)", () => {
expect(TMPL).toMatch(/tool-attributed fences/);
expect(TMPL).toMatch(/codex-review/);
expect(TMPL).toMatch(/greptile/);
});
test("scans the title too", () => {
expect(TMPL).toContain('printf \'%s\' "$NEW_TITLE" | ~/.claude/skills/gstack/bin/gstack-redact');
expect(TMPL).toContain('gh pr create --base <base> --title "$NEW_TITLE"');
expect(TMPL).toContain('gh pr edit --title "$NEW_TITLE"');
});
});
describe("tool-attributed fence behavior (engine contract /ship relies on)", () => {
+11 -8
View File
@@ -168,21 +168,24 @@ test('late verified outputs are committed before push, without tags', () => {
expectTokens(pushFlow, ['`ALREADY_PUSHED`'], 'ship Step 17');
});
test('Step 18 resolves open PR state before preparing the exact title Step 19 scans', () => {
const title = compact(between(entryTemplate, '## Step 18:', '{{SECTION:pr-body}}'));
expectOrdered(title, ['gh pr list --head <branch-name> --state open --json number,title,url', 'Prepare the title'], 'ship Step 18');
expectTokens(title, ['glab mr list --source-branch <branch-name> --output json', '`NEW_TITLE`', '`v<NEW_VERSION> <type>: <summary>`', '`v$NEW_VERSION `'], 'ship Step 18');
expectTokens(prTemplate, [': "${NEW_TITLE:?Restore the saved Step 18 title before scanning}"', 'exit 3'], 'ship pr-body');
expectAbsent(prTemplate, ['NEW_TITLE="<final vNEW_VERSION type: summary>"'], 'ship pr-body');
test('Step 18 resolves open PR state before preparing the title file Step 19 posts', () => {
const lookup = compact(between(entryTemplate, '## Step 18:', '{{SECTION:pr-body}}'));
expectOrdered(lookup, ['gh pr list --head <branch-name> --state open --json number,title,url', 'prepares the title'], 'ship Step 18');
expectTokens(lookup, ['glab mr list --source-branch <branch-name> --output json'], 'ship Step 18');
const title = compact(between(prTemplate, '### Prepare the title (Step 18)', '## Step 19:'));
expectOrdered(title, ['{{FREE_TEXT_FILE:TITLE_FILE=pr-title}}', 'gstack-pr-title-rewrite.sh <new-version> --stdin > "$TITLE_FILE"'], 'ship Step 18 title');
expectTokens(title, ['`v<NEW_VERSION> <type>: <summary>`', '`v$NEW_VERSION `', "grep -Eq '^v<new-version>( |$)' \"$TITLE_FILE\""], 'ship Step 18 title');
expectAbsent(compact(entryTemplate + prTemplate), ['"<current title>"', 'NEW_TITLE'], 'ship Steps 18-19');
expectTokens(prTemplate, ['--title-file "$TITLE_FILE"', 'exit 1', '**3**'], 'ship pr-body');
const generated = readTemplate('ship/sections/pr-body.md');
expectTokens(generated, ['**Existing open PR/MR:**'], 'ship pr-body section');
expectTokens(generated, ['**Existing open PR/MR**'], 'ship pr-body section');
});
test('linked spec discovery reads frontmatter fields and claims closure only for completed scope', () => {
const instructions = compact(between(prTemplate, /^/, 'The PR/MR body should contain'));
expectTokens(instructions, ['`spec_branch`', '`spec_filed_at`', '`spec_issue_number`', '`## Linked Spec`', '`Closes #N`', '`Linked to #N`'], 'ship linked spec');
expectMentions(instructions, [['frontmatter', 'never', 'source']], 'ship linked spec');
const body = between(prTemplate, 'The PR/MR body should contain', '#### Redaction scan');
const body = between(prTemplate, 'The PR/MR body should contain', '#### Compose the body');
expectAbsent(body, ['CURRENT_BRANCH=', 'SPEC_ARCHIVES=', 'SPEC_FILE=$(grep'], 'ship pr body');
expectAbsent(prTemplate, ['[ -z "$SPEC_FILE" ] && exit'], 'ship pr-body');
});
+6 -6
View File
@@ -177,14 +177,14 @@ describe('/spec fail-closed redaction (shared engine)', () => {
});
describe('/spec redaction at every sink (scan-at-sink, carved: gate-and-file section)', () => {
test('scan precedes the gh issue create (pre-issue)', () => {
const scanIdx = SEC_GEN.indexOf('Re-scan before filing');
const fileIdx = SEC_GEN.indexOf('gh issue create --title');
expect(scanIdx).toBeGreaterThan(-1);
expect(fileIdx).toBeGreaterThan(scanIdx);
test('the issue is filed only through gstack-post, which scans the title and body it sends (CEO-19)', () => {
const fileIdx = SEC_GEN.indexOf('gstack-post issue-create --title-file "$TITLE_FILE" --body-file "$REDACT_FILE")');
expect(fileIdx).toBeGreaterThan(-1);
expect(SEC_GEN).not.toMatch(/gh issue create --title/);
expect(SEC_GEN.slice(fileIdx)).toMatch(/Exit 1 \(HIGH\): do NOT file/);
});
test('files from the scanned temp file (exact bytes, not a re-render)', () => {
expect(SEC_GEN).toContain('ISSUE_URL=$(gh issue create --title "$(cat "$TITLE_FILE")" --body-file "$REDACT_FILE")');
expect(SEC_GEN).toContain('POST_OUT=$(~/.claude/skills/gstack/bin/gstack-post issue-create --title-file "$TITLE_FILE" --body-file "$REDACT_FILE"); POST_CODE=$?');
// CEO-12: the title is an agent-written file; no heredoc or quoted placeholder carries it.
expect(SEC_GEN).toContain('TITLE_FILE=$(mktemp "${_GT:?}/title.XXXXXX")');
expect(SEC_GEN).not.toContain('<<\'GSTACK_TITLE\'');