# Printing Press Publish

> Publish a generated CLI to the printing-press-library repo

- Skill: `mvanhorn-cli-printing-press/printing-press-publish` (Agent Skill)
- Install (CLI): `npx skillmds add mvanhorn-cli-printing-press/printing-press-publish`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mvanhorn-cli-printing-press/printing-press-publish/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: mvanhorn (https://skillmd.com/u/mvanhorn-cli-printing-press)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/mvanhorn-cli-printing-press/printing-press-publish

---


# /printing-press publish

Publish a generated CLI from your local library to the [printing-press-library](https://github.com/mvanhorn/printing-press-library) repo as a pull request.

```bash
/printing-press publish notion-pp-cli
/printing-press publish notion
/printing-press publish notion --from-polish
/printing-press publish notion --skip-live-test=auth-unavailable
/printing-press publish notion --blocked-api-journal notion
/printing-press publish
```

## PR shape guard

This skill opens only a generated CLI publish PR or, with
`--blocked-api-journal`, a `blocked-apis.json` journal PR. It never opens a
docs-only, plan, proposal, or spec PR as a substitute for a CLI that is not
ready to publish. If generation, validation, or live testing is blocked, report
the exact blocker and stop.

## Direct User Invocation Required

Publishing can fork `mvanhorn/printing-press-library`, push a branch, and open or
update a PR. Before setup or validation, check the invocation context. If this
skill was invoked as a chained continuation from `printing-press-polish`'s
Publish Offer, including an `AskUserQuestion` answer or auto-resolved polish
recommendation, stop immediately and tell the user to send
`/printing-press-publish <cli-name> --from-polish` in a fresh message. A fresh
user-authored request that explicitly asks to publish is sufficient; do not add
another confirmation prompt on top of a direct publish request.

If the fresh user-authored request includes `--from-polish`, record
`POLISH_HANDOFF=true` for the terminal-state step and ignore that marker when
resolving the CLI name. The marker is not a second confirmation and is not
passed to `cli-printing-press`; it only preserves standalone polish's old
post-publish retro offer after the fresh-turn publish completes.

If the request includes `--blocked-api-journal`, enter **Blocked API Journal
Mode** below instead of the normal printed-CLI publish flow. This mode may be
invoked from `/printing-press`'s hold-path menu after the user explicitly chose
"Add to blocked-API journal"; that parent menu choice is sufficient user
authorization for the public-library journal write. Do not require a second
fresh-turn invocation for this journal-only mode.

If the fresh user-authored request includes `--skip-live-test=<reason>`, record
the exact non-empty reason as `SKIP_LIVE_TEST_REASON` and remove the flag before
resolving the CLI name. This is the only supported escape valve for the
publish-time live test gate. Use it only for auth-unavailable, known upstream
outage, LAN-unreachable hardware APIs, or similarly concrete operator-approved
cases; never infer a skip from ordinary latency or from the presence of an
older Phase 5 marker.

The public library treats `library/<category>/<api-slug>/.printing-press.json`
and `manifest.json` as the source of truth for registry-display fields. Do not
edit `registry.json`, README catalog cells, or `cli-skills/pp-<api-slug>/SKILL.md`
in publish PRs; all three are bot-regenerated post-merge by the library's own
workflows. The library's `Fail on changes to generated artifacts` check in
`verify-library-conventions.yml` hard-fails any PR — fork or same-repo — whose
diff against base touches `registry.json` or `cli-skills/pp-*/SKILL.md`, so a
publish that includes either is pre-rejected before review.

The public library also owns per-CLI release accounting. Do not manually bump
`CHANGELOG.md`, `.printing-press-release.json`, or runtime `var version = ...`
for a publish PR. Fresh printed CLIs may include blank release-ledger skeletons;
the library's post-merge workflow assigns the final `YYYY.M.N` release and
stamps the runtime version after merge. When replacing an existing public
library CLI, preserve its existing release-ledger files so changelog history is
not lost in the reprint PR.

`blocked-apis.json` is different: it is a hand-maintained public-library journal,
not a generated registry surface. Journal-only PRs may edit `blocked-apis.json`
and must not stage `library/`, `registry.json`, README catalog cells, or
`cli-skills/`.

## Blocked API Journal Mode

Use this mode only when the invocation includes `--blocked-api-journal`. It
records a held `/printing-press` attempt whose blocker is likely to repeat for
other users until a machine or upstream issue changes.

Required fields from the caller:

- `slug`: canonical API slug, not the CLI binary name.
- `attempted_at`: `YYYY-MM-DD`.
- `verdict`: `hold`.
- `reason`: concise blocker reason, with no secrets, local paths, cookies,
  tokens, or account-specific details.
- `blocking_issue`: Printing Press issue number if known, otherwise `null`.
- `permanent`: boolean.

If the caller did not provide one of these fields, infer only safe values from
the current run context. If `reason` is missing or vague, stop and ask for one
specific blocker sentence; do not write an unhelpful journal entry.

Run the normal Setup, Configuration, scoped clone cleanup, and GitHub auth
checks, then prepare the public-library clone exactly as the normal publish
flow does: fork if needed, ensure `upstream` points to
`mvanhorn/printing-press-library`, fetch `upstream`, and reset the clone to
`upstream/main` before editing.

Then update only `$PUBLISH_REPO_DIR/blocked-apis.json`:

```bash
cd "$PUBLISH_REPO_DIR"
if [ ! -f blocked-apis.json ]; then
  printf '[]\n' > blocked-apis.json
fi
jq --arg slug "<api-slug>" \
   --arg attempted_at "<YYYY-MM-DD>" \
   --arg verdict "hold" \
   --arg reason "<reason>" \
   --argjson blocking_issue '<number-or-null>' \
   --argjson permanent '<true-or-false>' '
  (if type == "array" then . else [] end)
  | map(select(.slug != $slug))
  + [{
      slug: $slug,
      attempted_at: $attempted_at,
      verdict: $verdict,
      reason: $reason,
      blocking_issue: $blocking_issue,
      permanent: $permanent
    }]
  | sort_by(.slug)
' blocked-apis.json > blocked-apis.json.tmp || {
  rm -f blocked-apis.json.tmp
  echo "Error: jq failed to update blocked-apis.json"
  exit 1
}
if ! jq empty blocked-apis.json.tmp; then
  rm -f blocked-apis.json.tmp
  echo "Error: blocked-apis.json update produced invalid JSON"
  exit 1
fi
mv blocked-apis.json.tmp blocked-apis.json
```

Create a journal branch and PR:

```bash
git checkout -B chore/blocked-api-<api-slug>
git add blocked-apis.json
git commit -m "chore(<api-slug>): journal blocked API"
git push --force-with-lease -u origin chore/blocked-api-<api-slug>
```

Open the PR against `mvanhorn/printing-press-library` with a body that includes:

- the held API slug and reason
- whether the block is permanent or tied to `blocking_issue`
- the expected Phase 0 behavior: future `/printing-press <api-slug>` runs warn
  before repeating the attempt

After the PR is open, report the URL and stop. Do not continue into normal
printed-CLI package, live-test, registry, or skill-mirror steps.

## Setup

Before doing anything else:

<!-- PRESS_SETUP_CONTRACT_START -->
```bash
# min-binary-version: 4.0.0

# Derive scope first — needed for local build detection
_scope_dir="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")"
_scope_dir="$(cd "$_scope_dir" && pwd -P)"

# Prefer local build when running from inside the printing-press repo.
_press_repo=false
if [ -x "$_scope_dir/cli-printing-press" ] && [ -d "$_scope_dir/cmd/cli-printing-press" ]; then
  _press_repo=true
  export PATH="$_scope_dir:$PATH"
  echo "Using local build: $_scope_dir/cli-printing-press"
elif ! command -v cli-printing-press >/dev/null 2>&1; then
  if [ -x "$HOME/go/bin/cli-printing-press" ]; then
    echo "cli-printing-press found at ~/go/bin/cli-printing-press but not on PATH."
    echo "Add GOPATH/bin to your PATH:  export PATH=\"\$HOME/go/bin:\$PATH\""
  else
    echo "cli-printing-press binary not found."
    echo "Install with:  go install github.com/mvanhorn/cli-printing-press/v4/cmd/cli-printing-press@latest"
  fi
  return 1 2>/dev/null || exit 1
fi

# Resolve and emit the absolute path the agent must use for every later
# `cli-printing-press` invocation. `export PATH` above only affects this one
# Bash tool call; subsequent calls open a fresh shell and resolve bare
# `cli-printing-press` against the user's default PATH, where a stale global
# can silently shadow the local build. The agent captures this marker and
# substitutes the absolute path into every later invocation.
if [ "$_press_repo" = "true" ]; then
  PRINTING_PRESS_BIN="$_scope_dir/cli-printing-press"
else
  PRINTING_PRESS_BIN="$(command -v cli-printing-press 2>/dev/null || true)"
fi
if ! command -v go >/dev/null 2>&1; then
  echo ""
  echo "[setup-error] Go toolchain not found."
  echo ""
  echo "This Printing Press flow runs Go-based build or validation commands."
  echo "Install Go 1.26.6 or newer from https://go.dev/dl/, then verify with:"
  echo "  go version"
  echo "Then re-run this skill."
  echo ""
  return 1 2>/dev/null || exit 1
fi
echo "PRINTING_PRESS_BIN=$PRINTING_PRESS_BIN"

_pp_semver_lt() {
  if [ -z "${PP_SEMVER_A:-}" ] || [ -z "${PP_SEMVER_B:-}" ]; then
    echo "[setup-error] semver comparison inputs are missing." >&2
    return 2
  fi
  awk -v a="${PP_SEMVER_A:-}" -v b="${PP_SEMVER_B:-}" 'BEGIN {
    split(a, x, "."); split(b, y, ".")
    for (i = 1; i <= 3; i++) {
      if ((x[i] + 0) < (y[i] + 0)) exit 0
      if ((x[i] + 0) > (y[i] + 0)) exit 1
    }
    exit 1
  }'
}

_pp_go_version_norm() {
  printf '%s\n' "${PP_GO_VERSION_INPUT:-}" | sed -nE 's/.*go([0-9]+)\.([0-9]+)(\.([0-9]+))?.*/\1.\2.\4/p' | sed -E 's/\.$/.0/'
}

_pp_check_go_currency() {
  _pp_go_installed="$(PP_GO_VERSION_INPUT="$(go env GOVERSION 2>/dev/null)" _pp_go_version_norm)"
  _pp_go_required="$(PP_GO_VERSION_INPUT="$(go version "$PRINTING_PRESS_BIN" 2>/dev/null)" _pp_go_version_norm)"
  PP_SEMVER_A="$_pp_go_installed"
  PP_SEMVER_B="$_pp_go_required"
  if [ -z "$_pp_go_installed" ] || [ -z "$_pp_go_required" ] || ! _pp_semver_lt; then
    return 0
  fi

  echo ""
  if [ "${GOTOOLCHAIN:-auto}" = "local" ]; then
    echo "[setup-error] Go $_pp_go_required or newer is required by this cli-printing-press binary (installed: $_pp_go_installed)."
    echo "GOTOOLCHAIN=local disables automatic toolchain downloads, so later Go quality gates would fail."
    echo "Install Go $_pp_go_required or newer from https://go.dev/dl/, or unset GOTOOLCHAIN."
    echo ""
    return 1
  fi

  echo "[go-toolchain-old] Go $_pp_go_required or newer is required by this cli-printing-press binary (installed: $_pp_go_installed)."
  echo "PRESS_GO_INSTALLED=$_pp_go_installed"
  echo "PRESS_GO_REQUIRED=$_pp_go_required"
  echo "Default GOTOOLCHAIN behavior may download the required toolchain during Go commands."
  echo ""
  return 0
}
_pp_check_go_currency || { return 1 2>/dev/null || exit 1; }

PRESS_BASE="$(basename "$_scope_dir" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"
if [ -z "$PRESS_BASE" ]; then
  PRESS_BASE="workspace"
fi

PRESS_SCOPE="$PRESS_BASE-$(printf '%s' "$_scope_dir" | shasum -a 256 | cut -c1-8)"
PRESS_HOME="${PRINTING_PRESS_HOME:-$HOME/printing-press}"
PRESS_RUNSTATE="$PRESS_HOME/.runstate/$PRESS_SCOPE"
PRESS_LIBRARY="$PRESS_HOME/library"
PRESS_MANUSCRIPTS="$PRESS_HOME/manuscripts"
PRESS_CURRENT="$PRESS_RUNSTATE/current"

_pp_check_disk_space() {
  _pp_disk_warn_kb="${PRINTING_PRESS_DISK_WARN_KB:-3145728}"
  _pp_disk_fail_kb="${PRINTING_PRESS_DISK_FAIL_KB:-524288}"
  case "$_pp_disk_warn_kb$_pp_disk_fail_kb" in
    ""|*[!0-9]*) return 0 ;;
  esac

  _pp_disk_path="$PRESS_HOME"
  while [ ! -e "$_pp_disk_path" ] && [ "$_pp_disk_path" != "/" ]; do
    _pp_disk_path="$(dirname "$_pp_disk_path")"
  done

  _pp_disk_avail_kb="$(df -Pk "$_pp_disk_path" 2>/dev/null | awk 'BEGIN {
    if ((getline header) <= 0 || (getline record) <= 0) exit
    field_count = split(record, fields)
    if (field_count >= 4) print fields[4]
  }')"
  case "$_pp_disk_avail_kb" in
    ""|*[!0-9]*) return 0 ;;
  esac

  if [ "$_pp_disk_avail_kb" -lt "$_pp_disk_fail_kb" ]; then
    echo ""
    echo "[setup-error] Critically low disk space on the Printing Press workspace volume."
    echo "PRESS_DISK_PATH=$_pp_disk_path"
    echo "PRESS_DISK_AVAIL_KB=$_pp_disk_avail_kb"
    echo "PRESS_DISK_FAIL_KB=$_pp_disk_fail_kb"
    echo "Free disk space or set PRINTING_PRESS_HOME to a volume with more room, then re-run this skill."
    echo ""
    return 1
  fi

  if [ "$_pp_disk_avail_kb" -lt "$_pp_disk_warn_kb" ]; then
    echo ""
    echo "[low-disk] Printing Press workspace volume is low on free space."
    echo "PRESS_DISK_PATH=$_pp_disk_path"
    echo "PRESS_DISK_AVAIL_KB=$_pp_disk_avail_kb"
    echo "PRESS_DISK_WARN_KB=$_pp_disk_warn_kb"
    echo "This flow may need several GiB for generated files, Go build cache, module downloads, or repository clones."
    echo ""
  fi
}
_pp_check_disk_space || { return 1 2>/dev/null || exit 1; }

mkdir -p "$PRESS_RUNSTATE" "$PRESS_LIBRARY" "$PRESS_MANUSCRIPTS" "$PRESS_CURRENT"
```
<!-- PRESS_SETUP_CONTRACT_END -->

After running the setup contract, capture the `PRINTING_PRESS_BIN=<abs-path>` line from stdout. **Every subsequent `cli-printing-press ...` invocation in this skill must use that absolute path** (substitute the value, not the literal `$PRINTING_PRESS_BIN` token) — `export PATH` above only affects the single Bash tool call it runs in, so later calls open a fresh shell where bare `cli-printing-press` resolves against the user's default `PATH` and a stale global can shadow the local build.

If setup emitted `[go-toolchain-old]` or `[low-disk]`, surface the advisory to the user and continue unless setup also emitted `[setup-error]`. `[go-toolchain-old]` means later Go commands may download the required toolchain or fail when downloads are blocked; `[low-disk]` means this run may need several GiB for generated files, Go build cache, module downloads, or repository clones.

After capturing the binary path, check binary version compatibility. Read the `min-binary-version` field from this skill's YAML frontmatter. Run `<PRINTING_PRESS_BIN> version --json` and parse the version from the output. Compare it to `min-binary-version` using semver rules. If the installed binary is older than the minimum, stop immediately and tell the user: "cli-printing-press binary vX.Y.Z is older than the minimum required vA.B.C. Run `go install github.com/mvanhorn/cli-printing-press/v4/cmd/cli-printing-press@latest` to update."

## Configuration

```
PUBLISH_REPO_URL="https://github.com/mvanhorn/printing-press-library"
PUBLISH_REPO_DIR="$PRESS_HOME/.publish-repo-$PRESS_SCOPE"
PUBLISH_CONFIG="$PRESS_HOME/.publish-config-$PRESS_SCOPE.json"
```

### Publish config

`$PUBLISH_CONFIG` stores persistent publish settings as JSON. On first publish, create it with defaults. The user can edit it to change the library repo or module path base.

```json
{
  "managed_by": "printing-press-publish",
  "repo_url": "https://github.com/mvanhorn/printing-press-library",
  "access": "push",
  "protocol": "ssh",
  "clone_path": "<home>/printing-press/.publish-repo-<scope>",
  "scope_dir": "/absolute/path/to/source/worktree",
  "module_path_base": "github.com/mvanhorn/printing-press-library/library"
}
```

The `module_path_base` field sets the Go module path prefix for published CLIs. During packaging, the full module path is constructed as `<module_path_base>/<category>/<api-slug>`. If the user wants CLIs published to a different repo or path, they edit this field.
Store expanded absolute paths for `clone_path` and `scope_dir` so cleanup can
check them without relying on shell-specific `~` expansion. The `managed_by`
field is required before cleanup may delete anything.

### Scoped clone cleanup

Before creating or reusing `$PUBLISH_REPO_DIR`, prune scoped publish clones whose
source worktree no longer exists. This keeps concurrent worktrees isolated
without accumulating one library clone forever per short-lived worktree.

```bash
find "$PRESS_HOME" -maxdepth 1 -name '.publish-config-*.json' -type f | while read -r cfg; do
  [ "$cfg" = "$PUBLISH_CONFIG" ] && continue
  managed_by=$(jq -r '.managed_by // empty' "$cfg" 2>/dev/null || true)
  scope_dir=$(jq -r '.scope_dir // empty' "$cfg" 2>/dev/null || true)
  clone_path=$(jq -r '.clone_path // empty' "$cfg" 2>/dev/null || true)
  [ "$managed_by" = "printing-press-publish" ] || continue
  [ -z "$scope_dir" ] && continue
  [ -e "$scope_dir" ] && continue
  [ -d "$clone_path/.git" ] || continue
  case "$clone_path" in "$PRESS_HOME"/.publish-repo-*) ;; *) continue ;; esac
  origin=$(git -C "$clone_path" remote get-url origin 2>/dev/null || true)
  case "$origin" in *mvanhorn/printing-press-library*|*/*/printing-press-library*) ;; *) continue ;; esac
  [ -z "$(git -C "$clone_path" status --porcelain)" ] || continue
  [ "$(git -C "$clone_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" = "main" ] || continue
  rm -rf "$clone_path" "$cfg"
done
```

## Step 1: Prerequisites

Verify `gh` is authenticated:

```bash
gh auth status
```

If this fails, stop and tell the user: "GitHub CLI is not authenticated. Run `gh auth login` first."

## Step 2: Resolve API Slug

Run:

```bash
cli-printing-press library list --json
```

Parse the JSON output into a list of CLIs. The library is now keyed by API slug (the directory name), not CLI name.

**Name resolution order** (matches the score skill for consistency):

1. **Exact match:** If the argument matches a directory name (API slug) exactly, use it
2. **CLI name match:** If no exact match, try matching against `cli_name` fields, then derive the API slug from the manifest's `api_name` field
3. **Suffix match:** If no match yet, try `<argument>-pp-cli` against `cli_name` fields
4. **Glob match:** If no suffix match, search for entries where `cli_name` or `api_name` contains the argument as a substring. Cap at 5 most-recent matches. If multiple matches, present them via AskUserQuestion and let the user pick
5. **No match:** List all available CLIs and ask the user to pick or re-enter
6. **No argument:** If invoked with no name, list all CLIs sorted by modification time and let the user pick

Once resolved, read the manifest's `api_name` field to get the API slug. Use this slug for all downstream operations (branch names, registry entries, collision detection, path construction). The `cli_name` from the manifest is only used for binary-level operations.

When presenting matches, show the API slug and modification time in a human-friendly format (e.g., "2 hours ago", "3 days ago").

## Step 3: Determine Category

Read `.printing-press.json` from the resolved CLI directory.

**Category resolution order:**

1. If the manifest has a `category` field, present it for confirmation:
   > "Publishing as **<category>**. OK?"
   Give the user the option to change it

2. If the manifest does not provide a category, present the full list via AskUserQuestion:
   - developer-tools, monitoring, cloud, project-management
   - productivity, social-and-messaging, sales-and-crm, marketing
   - payments, auth, commerce, ai, food-and-dining, health, maps, media-and-entertainment, devices, other
   - travel

## Step 3.5: The Greptile review contract — read before opening the PR

Every PR into the public library gets an automated Greptile review plus a `Greptile policy gate` CI job. The canonical contract is the library's [`AGENTS.md → "Automated code review with Greptile"`](https://github.com/mvanhorn/printing-press-library/blob/main/AGENTS.md#automated-code-review-with-greptile); the essentials:

- **The bar is resolving every Greptile finding before merge — the 0-5 score is a confidence signal, not the gate.** A 4/5 with everything resolved is ready; a 5/5 with open P1s is not. Treat every P0 and P1 as blocking; P2s need a fix or a concrete deferral reply.
- **Reviews are incremental**: every push re-triggers a fresh review that can surface new findings. Drive the PR to a *stable* green — never declare done after round one.
- **Read the latest `greptile-apps` top-level summary, not just inline threads.** Summaries can carry actionable `Comments Outside Diff` blocks even when the thread list is empty. Run the repo's review-state helper before declaring ready:
  ```bash
  python3 .github/scripts/pr-review-state/greptile_feedback.py <PR_NUMBER>
  ```
- **Timeout recovery**: if the policy gate fails with `Timed out waiting for Greptile Review to complete` (large new-CLI diffs are the common trigger), the gate auto-posts `@greptileai review` after ~3 minutes; if that doesn't recover, post `@greptileai review` yourself and wait.
- **The score gate**: the policy gate requires the latest Greptile comment on the current head SHA to carry `Confidence Score: ≥ 4/5`. A new push re-runs it — keep the score meeting threshold on the final head.

## Step 4: Validate

Run:

```bash
cli-printing-press publish validate --dir <cli-dir> --json
```

`govulncheck` in this step is intentionally scoped to `<cli-dir>` only. It
uses the default `govulncheck ./...` mode so reachable symbol findings block
publish, while merely-required vulnerable modules without a call path do not
become release blockers. Do not replace this with a full public-library scan or
`govulncheck -show verbose`.

Parse the JSON result. Display each check result to the user:

```
Validating <api-slug>...
  manifest        PASS
  phase5          PASS
  go mod tidy     PASS
  govulncheck     PASS
  go vet          PASS
  go build        PASS
  --help          PASS
  --version       PASS
  manuscripts     WARN (no manuscripts found)
```

If `"passed": false`, report the failing checks and **stop**. Do not create a partial PR.
The `manifest` check is authoritative for the public-library provenance
contract: current `schema_version`, `run_id`, `printing_press_version`,
`printer`, `printer_name`, and MCP metadata files when MCP is advertised. If it
fails, tell the user to re-print or re-package with current Printing Press
metadata before opening the library PR.

Save the `help_output` field from the result — it's used in the PR description.

## Step 4.5: Live End-to-End Gate

Before touching the managed publish clone, rerun the live behavioral gate
against the CLI that is about to be published. Step 4 proves the source builds
and validates structurally; this step proves the current post-edit tree still
works against the real upstream API. Do not rely on an older
`phase5-acceptance.json` from generation or polish because the CLI may have
been hand-edited since that marker was written.

**Marker invalidation and sync.** The acceptance marker carries a source
fingerprint; any `.go` edit after it was written makes `publish package` fail
with "phase5 marker source fingerprint does not match". Re-run this live gate
after every source change and write the marker to **both** copies: the embedded
`$CLI_DIR/.manuscripts/<run>/proofs/` and the archived
`$PRESS_MANUSCRIPTS/<api>/<run>/proofs/` (manuscript lookup is archive-first;
proof lookup is embedded-first — a stale copy in either location blocks
packaging).

Resolve the Phase 5 proofs directory from the CLI manifest:

```bash
MANIFEST="$CLI_DIR/.printing-press.json"
API_SLUG=$(jq -r '.api_name // empty' "$MANIFEST")
CLI_NAME=$(jq -r '.cli_name // empty' "$MANIFEST")
RUN_ID=$(jq -r '.run_id // empty' "$MANIFEST")
AUTH_TYPE=$(jq -r '.auth_type // "none"' "$MANIFEST")
AUTH_ENV=$(jq -r '.auth_env_vars[0] // empty' "$MANIFEST")

if [ -z "$API_SLUG" ] || [ -z "$RUN_ID" ]; then
  echo "ERROR: manifest is missing api_name or run_id; cannot run publish live gate."
  exit 1
fi

PROOFS_DIR="$CLI_DIR/.manuscripts/$RUN_ID/proofs"
if [ ! -d "$PROOFS_DIR" ] && [ -n "$API_SLUG" ] && [ -d "$PRESS_MANUSCRIPTS/$API_SLUG/$RUN_ID/proofs" ]; then
  PROOFS_DIR="$PRESS_MANUSCRIPTS/$API_SLUG/$RUN_ID/proofs"
elif [ ! -d "$PROOFS_DIR" ] && [ -n "$CLI_NAME" ] && [ -d "$PRESS_MANUSCRIPTS/$CLI_NAME/$RUN_ID/proofs" ]; then
  PROOFS_DIR="$PRESS_MANUSCRIPTS/$CLI_NAME/$RUN_ID/proofs"
fi
mkdir -p "$PROOFS_DIR"

RESEARCH_DIR="$(dirname "$PROOFS_DIR")/research"
if [ ! -f "$RESEARCH_DIR/research.json" ] && [ -f "$(dirname "$PROOFS_DIR")/research.json" ]; then
  RESEARCH_DIR="$(dirname "$PROOFS_DIR")"
fi
if [ ! -f "$RESEARCH_DIR/research.json" ]; then
  echo "ERROR: publish live gate requires the run research.json at $RESEARCH_DIR." >&2
  exit 1
fi
```

Phase 5 markers are bound to the source tree that was exercised. The live
dogfood writer records `source_fingerprint` and per-file hashes automatically.
Publish validation recomputes the fingerprint from the current CLI directory
and refuses a marker from a drifted tree, naming changed source files when the
marker has them. README-only edits are outside this fingerprint and do not
invalidate the gate.

If `SKIP_LIVE_TEST_REASON` is unset, run full live dogfood and write a fresh
acceptance marker into that proofs directory:

```bash
LIVE_GATE_JSON="$PROOFS_DIR/publish-live-gate.json"
LIVE_GATE_ARGS=(
  dogfood
  --dir "$CLI_DIR"
  --live
  --level full
  --timeout 120s
  --research-dir "$RESEARCH_DIR"
  --write-acceptance "$PROOFS_DIR/phase5-acceptance.json"
  --json
)
if [ -n "$AUTH_ENV" ]; then
  LIVE_GATE_ARGS+=(--auth-env "$AUTH_ENV")
fi

rm -f "$PROOFS_DIR/phase5-skip.json"
if ! "$PRINTING_PRESS_BIN" "${LIVE_GATE_ARGS[@]}" >"$LIVE_GATE_JSON"; then
  echo "Publish live gate failed. See $LIVE_GATE_JSON and $PROOFS_DIR/phase5-acceptance.json."
  jq -r '.tests[]? | select(.status == "fail") | "- \(.command) [\(.kind)]: \(.reason // "failed")"' "$LIVE_GATE_JSON" 2>/dev/null || true
  exit 1
fi
```

On failure, stop exactly like Step 4's `passed: false`: no managed clone, no
branch, no package, no PR. Report the failed command, exit code when present,
stderr or reason snippet, and the path to the fresh proof files so the operator
can re-run dogfood and fix the CLI.

If `SKIP_LIVE_TEST_REASON` is set from `--skip-live-test=<reason>`, write a
fresh skip marker instead of running dogfood:

```bash
SKIP_REASON_LOWER=$(printf '%s' "$SKIP_LIVE_TEST_REASON" | tr '[:upper:]' '[:lower:]')
case "$AUTH_TYPE" in
  api_key|bearer_token|oauth2)
    ;;
  none)
    case "$SKIP_REASON_LOWER" in
      *upstream*outage*|lan-unreachable-from-generation-host)
        ;;
      *)
        echo "ERROR: --skip-live-test is only valid for auth_type=none during a known upstream outage or LAN-unreachable hardware case."
        exit 1
        ;;
    esac
    ;;
  *)
    echo "ERROR: --skip-live-test is not valid for auth_type=$AUTH_TYPE. Run the live gate instead."
    exit 1
    ;;
esac

API_KEY_AVAILABLE=false
if [ -n "$AUTH_ENV" ] && [ -n "${!AUTH_ENV:-}" ]; then
  API_KEY_AVAILABLE=true
fi

SOURCE_FILES=$(find "$CLI_DIR" \( -type d \( -name '.git' -o -name '.manuscripts' -o -name '.printing-press' \) -prune \) -o -type f \( -name '*.go' -o -name 'go.mod' -o -name 'go.sum' -o -name 'spec.json' -o -name 'spec.yaml' -o -name 'spec.yml' \) -print | LC_ALL=C sort)
SOURCE_FINGERPRINT=$(
  while IFS= read -r SOURCE_FILE; do
    [ -z "$SOURCE_FILE" ] && continue
    SOURCE_REL="${SOURCE_FILE#"$CLI_DIR"/}"
    SOURCE_HASH=$(shasum -a 256 "$SOURCE_FILE" | sed 's/[[:space:]].*//')
    printf '%s\0%s\n' "$SOURCE_REL" "$SOURCE_HASH"
  done <<EOF | shasum -a 256 | sed 's/[[:space:]].*//'
$SOURCE_FILES
EOF
)
if [ -z "$SOURCE_FINGERPRINT" ]; then
  echo "ERROR: unable to fingerprint CLI source before writing the Phase 5 skip marker."
  exit 1
fi

rm -f "$PROOFS_DIR/phase5-acceptance.json"
jq -n \
  --arg api "$API_SLUG" \
  --arg run "$RUN_ID" \
  --arg reason "$SKIP_LIVE_TEST_REASON" \
  --arg auth "$AUTH_TYPE" \
  --arg source_fingerprint "$SOURCE_FINGERPRINT" \
  --argjson api_key_available "$API_KEY_AVAILABLE" \
  --argjson browser_session_available false \
  '{
    schema_version: 1,
    api_name: $api,
    run_id: $run,
    status: "skip",
    level: "none",
    source_fingerprint: $source_fingerprint,
    skip_reason: $reason,
    auth_context: {
      type: $auth,
      api_key_available: $api_key_available,
      browser_session_available: $browser_session_available
    }
  }' > "$PROOFS_DIR/phase5-skip.json"
if [ "$SKIP_REASON_LOWER" = "lan-unreachable-from-generation-host" ]; then
  tmp_marker=$(mktemp "${TMPDIR:-/tmp}/phase5-skip.XXXXXX")
  jq '.auth_context.local_network_only = true' "$PROOFS_DIR/phase5-skip.json" > "$tmp_marker" &&
    mv "$tmp_marker" "$PROOFS_DIR/phase5-skip.json"
fi
LIVE_GATE_JSON=""
```

Then rerun Step 4's validation:

```bash
"$PRINTING_PRESS_BIN" publish validate --dir "$CLI_DIR" --json
```

This second validation proves the fresh acceptance or skip marker satisfies the
same Phase 5 contract that package and publish rely on. If it fails, stop
before Step 5.

## Step 5: Managed Clone

The publish skill manages its own clone of the library repo at `$PUBLISH_REPO_DIR`.

### First-time setup

If `$PUBLISH_REPO_DIR` does not exist:

1. **Detect push access:**
   ```bash
   GH_USER=$(gh api user --jq '.login')
   HAS_PUSH=$(gh api repos/mvanhorn/printing-press-library --jq '.permissions.push' 2>/dev/null || echo "false")
   ```

2. **Detect git protocol:**
   ```bash
   USE_SSH=false
   if ssh -T git@github.com 2>&1 | grep -q "successfully authenticated"; then
     USE_SSH=true
   fi
   ```

3. **Clone based on access:**

   **Push access** (`HAS_PUSH` is `true`):
   ```bash
   # Clone directly — origin IS the upstream
   if [ "$USE_SSH" = "true" ]; then
     REPO_URL="git@github.com:mvanhorn/printing-press-library.git"
   else
     REPO_URL="https://github.com/mvanhorn/printing-press-library.git"
   fi
   # Lightweight clone: blobless + shallow + sparse. The publish flow only
   # touches the target CLI's own directory — it no longer regenerates the
   # cli-skills/registry mirror (see Step 6) — so materializing every other
   # CLI's source is wasted bandwidth and disk (a full clone is multiple GB;
   # this is tens of MB). The cone keeps `tools`, `cli-skills`, and the target
   # `library/<category>` so the in-category find/rm/copy operations below work
   # on a real working tree. Cross-category collision checks use `git ls-tree`
   # (which reads the full tree from the blobless clone) instead of `ls`.
   git clone --filter=blob:none --depth 1 --sparse "$REPO_URL" "$PUBLISH_REPO_DIR"
   # Skill-managed clones are owned by this flow; force LF checkout behavior so
   # Windows core.autocrlf defaults do not create CRLF-only mirror diffs.
   git -C "$PUBLISH_REPO_DIR" config core.autocrlf false
   git -C "$PUBLISH_REPO_DIR" sparse-checkout set tools cli-skills library/<category>
   ```

   **No push access** (`HAS_PUSH` is `false`):
   ```bash
   # Fork first — fail explicitly if forking is blocked
   if ! gh repo fork mvanhorn/printing-press-library --clone=false 2>&1; then
     echo "ERROR: Could not fork mvanhorn/printing-press-library."
     echo "The repo may restrict forking, or you may already have a fork with a different name."
     echo "Fork manually at https://github.com/mvanhorn/printing-press-library/fork"
     exit 1
   fi
   FORK="$GH_USER/printing-press-library"

   # Build URLs based on protocol preference
   if [ "$USE_SSH" = "true" ]; then
     FORK_URL="git@github.com:$FORK.git"
     UPSTREAM_URL="git@github.com:mvanhorn/printing-press-library.git"
   else
     FORK_URL="https://github.com/$FORK.git"
     UPSTREAM_URL="https://github.com/mvanhorn/printing-press-library.git"
   fi

   # Lightweight clone (blobless + shallow + sparse) — see the push-access
   # branch above for the rationale and cone contents.
   git clone --filter=blob:none --depth 1 --sparse "$FORK_URL" "$PUBLISH_REPO_DIR"
   # Skill-managed clones are owned by this flow; force LF checkout behavior so
   # Windows core.autocrlf defaults do not create CRLF-only mirror diffs.
   git -C "$PUBLISH_REPO_DIR" config core.autocrlf false
   cd "$PUBLISH_REPO_DIR"
   git sparse-checkout set tools cli-skills library/<category>
   git remote add upstream "$UPSTREAM_URL"
   git fetch --filter=blob:none --depth 1 upstream
   ```

4. **Cache the config:**
   ```json
   {
     "managed_by": "printing-press-publish",
     "repo_url": "https://github.com/mvanhorn/printing-press-library",
     "access": "push or fork",
     "gh_user": "<gh username>",
     "protocol": "ssh or https",
     "clone_path": "<expanded $PUBLISH_REPO_DIR>",
     "scope_dir": "<absolute source worktree path>",
     "module_path_base": "github.com/mvanhorn/printing-press-library/library"
   }
   ```
   Write to `$PUBLISH_CONFIG`. The `access` field determines the flow for all subsequent steps. The `gh_user` field is used for cross-repo PR heads. The `module_path_base` always references the upstream repo (PRs land there).

### Subsequent publishes

Read `$PUBLISH_CONFIG`, then re-check access in case it changed (user was granted push access, or access was revoked):

```bash
CURRENT_ACCESS=$(gh api repos/mvanhorn/printing-press-library --jq '.permissions.push' 2>/dev/null || echo "false")
CACHED_ACCESS=$(jq -r .access "$PUBLISH_CONFIG")

if [ "$CURRENT_ACCESS" = "true" ] && [ "$CACHED_ACCESS" = "fork" ]; then
  echo "Access upgraded to push. Reconfiguring clone..."
  rm -rf "$PUBLISH_REPO_DIR"
  # Re-run first-time setup with push access
fi
if [ "$CURRENT_ACCESS" = "false" ] && [ "$CACHED_ACCESS" = "push" ]; then
  echo "Push access revoked. Reconfiguring clone with fork..."
  rm -rf "$PUBLISH_REPO_DIR"
  # Re-run first-time setup with fork access
fi
```

If the clone was removed due to an access change, re-run first-time setup above. Otherwise, freshen the clone to match the canonical upstream:

```bash
cd "$PUBLISH_REPO_DIR"
git config core.autocrlf false

if [ "$(jq -r .access $PUBLISH_CONFIG)" = "push" ]; then
  # Push access: origin IS the upstream
  git fetch --filter=blob:none --depth 1 origin
  git checkout main
  git reset --hard origin/main
  # Remove stale untracked library fragments from prior publish branches before
  # copying this CLI. Ignored files hidden by a branch-local .gitignore can
  # become ordinary untracked files after checkout, and a later broad library
  # add must not sweep another CLI's leftovers into this PR.
  git clean -fdq library/
else
  # Fork: origin is the fork, upstream is canonical
  git fetch --filter=blob:none --depth 1 upstream
  git checkout main
  git reset --hard upstream/main
  # Remove stale untracked library fragments from prior publish branches before
  # copying this CLI. Ignored files hidden by a branch-local .gitignore can
  # become ordinary untracked files after checkout, and a later broad library
  # add must not sweep another CLI's leftovers into this PR.
  git clean -fdq library/
  # Also sync origin (fork) so git push works cleanly
  git push origin main --force-with-lease 2>/dev/null || true
fi

# Existing managed clones may already be sparse for a different publish
# category. Refresh the cone for the current target category before Step 6 uses
# filesystem-based removal and copy operations.
if git -C "$PUBLISH_REPO_DIR" config --bool core.sparseCheckout | grep -qx true; then
  git -C "$PUBLISH_REPO_DIR" sparse-checkout set tools cli-skills library/<category>
fi
```

Verify the clone is healthy:

```bash
git rev-parse --is-inside-work-tree
test "$(git rev-parse --abbrev-ref HEAD)" = "main"
```

If this fails, the clone is corrupt. Remove `$PUBLISH_REPO_DIR` and re-run first-time setup.

### Interrupted state recovery

Before creating a new branch, check for uncommitted changes:

```bash
cd "$PUBLISH_REPO_DIR"
git status --porcelain
```

If there are uncommitted changes, ask the user via AskUserQuestion:
- "Reset and start fresh"
- "Continue with existing changes"

If reset, run `git checkout -- . && git clean -fd`.

### Pre-package publication-state snapshot

Before Step 6 mutates the managed clone, record whether this API slug already
exists in the public library tree. Step 6 removes and replaces
`library/*/<api-slug>`, so any collision or publication-path decision made
after packaging must use this pre-package snapshot, not a fresh `ls`.

```bash
# Read from the git tree, not the working dir: the sparse checkout only
# materializes the target category, but a slug can collide in any category.
PREEXISTING_MERGED_PATHS=$(git -C "$PUBLISH_REPO_DIR" ls-tree -r --name-only HEAD \
  | sed -n 's#^\(library/[^/]*/<api-slug>\)/.*#\1#p' | sort -u || true)
PREEXISTING_MERGED_COLLISION=false
if [ -n "$PREEXISTING_MERGED_PATHS" ]; then
  PREEXISTING_MERGED_COLLISION=true
  # If this is a category-change reprint, materialize the existing category path
  # before Step 6 runs filesystem-based ledger preservation and removal.
  if git -C "$PUBLISH_REPO_DIR" config --bool core.sparseCheckout | grep -qx true; then
    while IFS= read -r EXISTING_MERGED_PATH; do
      [ -n "$EXISTING_MERGED_PATH" ] || continue
      if [ "$EXISTING_MERGED_PATH" != "library/<category>/<api-slug>" ]; then
        git -C "$PUBLISH_REPO_DIR" sparse-checkout add "$EXISTING_MERGED_PATH"
      fi
    done <<EOF
$PREEXISTING_MERGED_PATHS
EOF
  fi
fi
```

## Step 6: Package

Read `$PUBLISH_CONFIG` to get `module_path_base`. Construct the full module path using the API slug (not the CLI name):

```
MODULE_PATH="<module_path_base>/<category>/<api-slug>"
```

For example: `github.com/mvanhorn/printing-press-library/library/productivity/notion`

**`--module-path` is required in `--dest` mode.** When packaging with `--dest`,
always pass `--module-path "$MODULE_PATH"`. Omitting it silently skips the
go.mod/import rewrite (`RewriteModulePath` is gated on the flag), so the
packaged CLI keeps `module <cli-name>` and the library CI rejects the PR with a
module-path mismatch. `publish package` verifies the staged tree's module path
after the rewrite and fails packaging when it is not library-canonical (whether
`--module-path` was omitted or set to a non-canonical value). Standalone
`publish validate` on a source tree surfaces the check as a warning — the bare
module name is expected there pre-rewrite; the authoritative failure is in the
package step.

Run `publish package` with `--target` to stage the CLI into a unique temporary
directory, then copy it into the publish repo:

```bash
PUBLISH_STAGING_ROOT="/tmp/printing-press/publish"
mkdir -p "$PUBLISH_STAGING_ROOT"
STAGING_PARENT="$(mktemp -d "$PUBLISH_STAGING_ROOT/<api-slug>-XXXXXX")"
STAGING_DIR="$STAGING_PARENT/package"

cli-printing-press publish package \
  --dir <cli-dir> \
  --category <category> \
  --target "$STAGING_DIR" \
  --module-path "$MODULE_PATH" \
  --json
```

Parse the JSON result. Note the `staged_dir`, `module_path`, `manuscripts_included`, and `run_id`. The `module_path` field confirms the Go module path that was set in the packaged CLI's `go.mod` and import paths.

`publish package` performs the mandatory vendor-prefix secret scan over the staged CLI, including copied manuscripts, before returning success. If it reports `vendor-prefix tokens detected`, stop and remove or redact the reported file:line findings before retrying. This is a hard gate and does not depend on `gitleaks`, `trufflehog`, or destination-repo push protection.

Then copy the staged CLI into the publish repo, replacing any existing version
while preserving the public library's release ledger files when this is a
reprint:

```bash
STAGED_CLI_DIR="$STAGING_DIR/library/<category>/<api-slug>"
DEST_CATEGORY_DIR="$PUBLISH_REPO_DIR/library/<category>"
DEST_CLI_DIR="$DEST_CATEGORY_DIR/<api-slug>"

if [ ! -d "$STAGED_CLI_DIR" ]; then
  echo "missing staged CLI directory: $STAGED_CLI_DIR" >&2
  exit 1
fi
mkdir -p "$DEST_CATEGORY_DIR"

# Preserve release-ledger files from the current public-library entry before
# removing it. New CLIs omit .printing-press-release.json until the library's
# post-merge workflow stamps a real release; reprints keep existing changelog
# history and release metadata until that workflow stamps the next release.
RELEASE_LEDGER_TMP="$(mktemp -d)"
PUBLISH_SWAP_DIR="$(mktemp -d "$DEST_CATEGORY_DIR/.<api-slug>.XXXXXX")"
trap 'rm -rf "$RELEASE_LEDGER_TMP" "$PUBLISH_SWAP_DIR"' EXIT
for LEDGER_FILE in CHANGELOG.md

…(truncated)
