From b104c3df4b8c76c5530d42968ce4ef0fd6748761 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 7 Oct 2026 20:07:13 +0000 Subject: [PATCH] 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> --- bin/gstack-pr-title-rewrite.sh | 12 +- review/greptile-triage.md | 17 ++- scripts/resolvers/redact-doc.ts | 15 +- ship/SKILL.md | 13 +- ship/SKILL.md.tmpl | 9 +- ship/sections/manifest.json | 2 +- ship/sections/pr-body.md | 131 +++++++++------- ship/sections/pr-body.md.tmpl | 124 ++++++++------- spec/sections/gate-and-file.md | 33 ++-- spec/sections/gate-and-file.md.tmpl | 26 ++-- test/generated-bash-placeholders.test.ts | 28 ++-- test/helpers/placeholder-allowlist.ts | 2 + test/pr-title-rewrite.test.ts | 17 +++ test/redact-doc-resolver.test.ts | 26 +++- ...regression-pr1169-mktemp-fallbacks.test.ts | 7 +- test/ship-control-flow.test.ts | 2 +- test/ship-publication-gates.test.ts | 141 +++++++++++++----- test/ship-reentry-gates.test.ts | 10 +- test/ship-template-redaction.test.ts | 41 ++--- test/ship-workflow-clarity.test.ts | 19 ++- test/spec-template-invariants.test.ts | 12 +- 21 files changed, 419 insertions(+), 268 deletions(-) diff --git a/bin/gstack-pr-title-rewrite.sh b/bin/gstack-pr-title-rewrite.sh index 8251f621f..e75c4fb3f 100755 --- a/bin/gstack-pr-title-rewrite.sh +++ b/bin/gstack-pr-title-rewrite.sh @@ -2,7 +2,10 @@ # Rewrite a PR/MR title to start with v. # # Usage: bin/gstack-pr-title-rewrite.sh -# Output: corrected title on stdout. +# gh pr view N --json title -q .title | bin/gstack-pr-title-rewrite.sh --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. Three cases: # 1. Already starts with "v" -> no change. @@ -25,12 +28,17 @@ set -euo pipefail if [ $# -lt 2 ]; then - echo "usage: $0 " >&2 + echo "usage: $0 " >&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. diff --git a/review/greptile-triage.md b/review/greptile-triage.md index 0d7992784..2608b6857 100644 --- a/review/greptile-triage.md +++ b/review/greptile-triage.md @@ -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/" -[ -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//replies -F body=@$REPLY_FILE" >&2; exit 1; } -gh api "repos/$REPO/pulls/$PR_NUMBER/comments//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 --body-file $REPLY_FILE" >&2; exit 1; } +~/.claude/skills/gstack/bin/gstack-post reply "$PR_NUMBER" --to --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/" -[ -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 ` 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. --- diff --git a/scripts/resolvers/redact-doc.ts b/scripts/resolvers/redact-doc.ts index 4568459e2..c3738a7d9 100644 --- a/scripts/resolvers/redact-doc.ts +++ b/scripts/resolvers/redact-doc.ts @@ -6,10 +6,13 @@ * and /cso never drift from the engine. * {{REDACT_INVOCATION_BLOCK:}} → the canonical scan-at-sink bash + prose * for one enforcement point. 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 = { '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)`; diff --git a/ship/SKILL.md b/ship/SKILL.md index 1ffde620c..d40b6f8e7 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -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" ""`. -2. For a new PR/MR, compose `v : `. -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 - `: `; 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 diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 278f68990..3cc3f173c 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -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" ""`. -2. For a new PR/MR, compose `v : `. -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 - `: `; no version prefix. +The pr-body section prepares the title from that result, then publishes. {{SECTION:pr-body}} diff --git a/ship/sections/manifest.json b/ship/sections/manifest.json index ac2bba19f..9bcd6ebf8 100644 --- a/ship/sections/manifest.json +++ b/ship/sections/manifest.json @@ -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)" } ] } diff --git a/ship/sections/pr-body.md b/ship/sections/pr-body.md index 1efa6b427..c54e39227 100644 --- a/ship/sections/pr-body.md +++ b/ship/sections/pr-body.md @@ -1,10 +1,40 @@ +### 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/" + gh pr view --json title -q .title | ~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh --stdin > "$TITLE_FILE" || exit 1 + ``` + GitLab reads `glab mr view --output json | jq -r .title` into the same pipe. +2. For a new PR/MR, write `v : ` 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( |$)' "$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 `: `; 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 @@ -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_REST_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" -[ -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** (`` 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 --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/" +~/.claude/skills/gstack/bin/gstack-post pr-body --body-file "${PR_BODY_FILE:?restore the composed body path}" && \ +~/.claude/skills/gstack/bin/gstack-post pr-title --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 `. 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 -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/" +~/.claude/skills/gstack/bin/gstack-post pr-create --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 ` + 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 ` 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. diff --git a/ship/sections/pr-body.md.tmpl b/ship/sections/pr-body.md.tmpl index 807155261..82fc35658 100644 --- a/ship/sections/pr-body.md.tmpl +++ b/ship/sections/pr-body.md.tmpl @@ -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/" + gh pr view --json title -q .title | ~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh --stdin > "$TITLE_FILE" || exit 1 + ``` + GitLab reads `glab mr view --output json | jq -r .title` into the same pipe. +2. For a new PR/MR, write `v : ` 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( |$)' "$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 `: `; 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 @@ -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_REST_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" -[ -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** (`` 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 --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/" +~/.claude/skills/gstack/bin/gstack-post pr-body --body-file "${PR_BODY_FILE:?restore the composed body path}" && \ +~/.claude/skills/gstack/bin/gstack-post pr-title --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 `. 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 -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/" +~/.claude/skills/gstack/bin/gstack-post pr-create --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 ` + 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 ` 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. diff --git a/spec/sections/gate-and-file.md b/spec/sections/gate-and-file.md index 65efe4a4a..f0089905a 100644 --- a/spec/sections/gate-and-file.md +++ b/spec/sections/gate-and-file.md @@ -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/" TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" APPROACH_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" -[ -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 ` 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 diff --git a/spec/sections/gate-and-file.md.tmpl b/spec/sections/gate-and-file.md.tmpl index 1b2a26313..31bd13768 100644 --- a/spec/sections/gate-and-file.md.tmpl +++ b/spec/sections/gate-and-file.md.tmpl @@ -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/" TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" APPROACH_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" -[ -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 ` 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 diff --git a/test/generated-bash-placeholders.test.ts b/test/generated-bash-placeholders.test.ts index ca91847e7..5f8ff5369 100644 --- a/test/generated-bash-placeholders.test.ts +++ b/test/generated-bash-placeholders.test.ts @@ -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) => { @@ -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//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('', names.REPLY_FILE).replaceAll('', '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('', draft.REDACT_FILE).replaceAll('', files.TITLE_FILE).replaceAll('', 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([]); }); }); diff --git a/test/helpers/placeholder-allowlist.ts b/test/helpers/placeholder-allowlist.ts index 903604eaa..b60d3a66f 100644 --- a/test/helpers/placeholder-allowlist.ts +++ b/test/helpers/placeholder-allowlist.ts @@ -47,6 +47,8 @@ export const IDENTIFIER_PLACEHOLDERS: Record = { '': { grammar: ID, what: 'option key' }, '': { grammar: ID, what: 'question summary as a kebab-case slug' }, '': { grammar: NUM, what: 'GitHub comment id from the fetched JSON' }, + '': { grammar: NUM, what: 'PR/MR number from the open-PR lookup or the early-PR create output' }, + '': { grammar: /^[0-9]+(?:\.[0-9]+)*$/, what: 'NEW_VERSION from the version bump (dot-separated digits)' }, '': { grammar: /^[0-9]*$/, what: 'filed issue number, or empty when none was filed' }, '': { grammar: NUM, what: 'canary check number' }, '': { grammar: NUM, what: 'GitHub Actions run id' }, diff --git a/test/pr-title-rewrite.test.ts b/test/pr-title-rewrite.test.ts index d97f4c921..0a6b0d8be 100644 --- a/test/pr-title-rewrite.test.ts +++ b/test/pr-title-rewrite.test.ts @@ -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(''); + } + }); }); diff --git a/test/redact-doc-resolver.test.ts b/test/redact-doc-resolver.test.ts index 588b59dad..f64f46a40 100644 --- a/test/redact-doc-resolver.test.ts +++ b/test/redact-doc-resolver.test.ts @@ -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/"'); @@ -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); + }); +}); diff --git a/test/regression-pr1169-mktemp-fallbacks.test.ts b/test/regression-pr1169-mktemp-fallbacks.test.ts index f08d8280a..da7285ba1 100644 --- a/test/regression-pr1169-mktemp-fallbacks.test.ts +++ b/test/regression-pr1169-mktemp-fallbacks.test.ts @@ -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 --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", () => { diff --git a/test/ship-control-flow.test.ts b/test/ship-control-flow.test.ts index 84b4a079a..10988d45c 100644 --- a/test/ship-control-flow.test.ts +++ b/test/ship-control-flow.test.ts @@ -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'); }); }); diff --git a/test/ship-publication-gates.test.ts b/test/ship-publication-gates.test.ts index 452b581b3..ec6607c21 100644 --- a/test/ship-publication-gates.test.ts +++ b/test/ship-publication-gates.test.ts @@ -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('', 'pr-body-top.abc123').replace('', '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('', 'pr-title.abc123').replaceAll('', '9').replace('', '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 --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"'); diff --git a/test/ship-reentry-gates.test.ts b/test/ship-reentry-gates.test.ts index 5a4e1a02b..8b7dad13d 100644 --- a/test/ship-reentry-gates.test.ts +++ b/test/ship-reentry-gates.test.ts @@ -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'); }); diff --git a/test/ship-template-redaction.test.ts b/test/ship-template-redaction.test.ts index 31190f9f9..6e2fd3745 100644 --- a/test/ship-template-redaction.test.ts +++ b/test/ship-template-redaction.test.ts @@ -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 --title-file "$TITLE_FILE" --body-file "${PR_BODY_FILE:?restore the composed body path}"'); + expect(TMPL).toContain('gstack-post pr-body --body-file "${PR_BODY_FILE:?restore the composed body path}"'); + expect(TMPL).toContain('gstack-post pr-title --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 `"); + 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 --title "$NEW_TITLE"'); - expect(TMPL).toContain('gh pr edit --title "$NEW_TITLE"'); - }); }); describe("tool-attributed fence behavior (engine contract /ship relies on)", () => { diff --git a/test/ship-workflow-clarity.test.ts b/test/ship-workflow-clarity.test.ts index 962bead93..43767e187 100644 --- a/test/ship-workflow-clarity.test.ts +++ b/test/ship-workflow-clarity.test.ts @@ -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 --state open --json number,title,url', 'Prepare the title'], 'ship Step 18'); - expectTokens(title, ['glab mr list --source-branch --output json', '`NEW_TITLE`', '`v : `', '`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=""'], '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 --state open --json number,title,url', 'prepares the title'], 'ship Step 18'); + expectTokens(lookup, ['glab mr list --source-branch --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 --stdin > "$TITLE_FILE"'], 'ship Step 18 title'); + expectTokens(title, ['`v : `', '`v$NEW_VERSION `', "grep -Eq '^v( |$)' \"$TITLE_FILE\""], 'ship Step 18 title'); + expectAbsent(compact(entryTemplate + prTemplate), ['""', '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'); }); diff --git a/test/spec-template-invariants.test.ts b/test/spec-template-invariants.test.ts index 729ba1611..7180dade9 100644 --- a/test/spec-template-invariants.test.ts +++ b/test/spec-template-invariants.test.ts @@ -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\'');