@mvanhorn/printing-press-publish

Generate a ship-ready CLI for an API with a lean research -> generate -> build -> shipcheck loop.

View in AI SkillSafe app
2 scan findings
0 downloads
0 stars
0 demos
SKILL.md
nameprinting-press-publish
descriptionPublish a generated CLI to the printing-press-library repo
version0.1.0
min-binary-version4.0.0
allowed-toolsBash, Read, Write, Edit, Glob, Grep, AskUserQuestion
created_byuser

/printing-press publish

Publish a generated CLI from your local library to the printing-press-library repo as a pull request.

/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:

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:

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 -->
# 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.

{
  "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.

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:

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:

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"; 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:
    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:

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:

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:

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:

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:

"$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:

    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:

    USE_SSH=false
    if ssh -T [email protected] 2>&1 | grep -q "successfully authenticated"; then
      USE_SSH=true
    fi
    
  3. Clone based on access:

    Push access (HAS_PUSH is true):

    # Clone directly — origin IS the upstream
    if [ "$USE_SSH" = "true" ]; then
      REPO_URL="[email protected]: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):

    # 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="[email protected]:$FORK.git"
      UPSTREAM_URL="[email protected]: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:

    {
      "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):

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:

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:

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:

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.

# 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:

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:

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 .printing-press-release.json; do
  EXISTING_LEDGER="$(find "$PUBLISH_REPO_DIR/library" -mindepth 3 -maxdepth 3 -path "*/<api-slug>/$LEDGER_FILE" -print -quit)"
  if [ -n "$EXISTING_LEDGER" ]; then
    cp "$EXISTING_LEDGER" "$RELEASE_LEDGER_TMP/$LEDGER_FILE"
  fi
done

# Copy staged CLI into a same-category swap dir before deleting the current
# public-library entry. This keeps a failed copy from leaving the publish repo
# with the old CLI removed.
cp -R "$STAGED_CLI_DIR/." "$PUBLISH_SWAP_DIR/"

for LEDGER_FILE in CHANGELOG.md .printing-press-release.json; do
  if [ -f "$RELEASE_LEDGER_TMP/$LEDGER_FILE" ]; then
    cp "$RELEASE_LEDGER_TMP/$LEDGER_FILE" "$PUBLISH_SWAP_DIR/$LEDGER_FILE"
  fi
done

# Remove existing version (handles category changes), then atomically install
# the prepared replacement within the destination category.
rm -rf "$PUBLISH_REPO_DIR/library"/*/"<api-slug>"
mv "$PUBLISH_SWAP_DIR" "$DEST_CLI_DIR"
rm -rf "$RELEASE_LEDGER_TMP"
trap - EXIT

# Reprints must preserve the base CLI's runtime version declaration layout as
# well as its ledger files. Fresh prints can move `var version = ...` between
# files, but the public library's release-ledger guard rejects those moves in a
# normal publish PR because the post-merge release workflow owns version stamps.
cd "$PUBLISH_REPO_DIR"
VERSION_DECL_BASE_REF=upstream/main
if ! git rev-parse --verify --quiet "$VERSION_DECL_BASE_REF" >/dev/null; then
  VERSION_DECL_BASE_REF=origin/main
fi
VERSION_DECL_DIFF="$(git diff --unified=0 "$VERSION_DECL_BASE_REF" -- \
  "library/*/<api-slug>/internal/cli/root.go" \
  "library/*/<api-slug>/internal/cli/version.go" \
  "library/*/<api-slug>/cmd/<api-slug>-pp-mcp/main.go")" || {
  echo "failed to compare runtime version declarations with ${VERSION_DECL_BASE_REF}" >&2
  exit 1
}
printf '%s\n' "$VERSION_DECL_DIFF" \
  | grep -E '^[+-][[:space:]]*var version[[:space:]]*=' || true

# If the command prints a change, reconcile the replacement to the base tree:
# - A root.go declaration stays in root.go with the exact stamped value; remove
#   only the duplicate declaration from version.go and keep its command code.
# - A version.go declaration stays in version.go with the exact stamped value;
#   remove any fresh declaration added elsewhere in the internal CLI package.
# - An MCP main declaration stays in MCP main with the exact stamped value. If
#   the base MCP main hardcodes the version instead, preserve that expression.
# - If the base has no declaration in one of these runtime surfaces, preserve
#   that no declaration layout and its existing literal/reference form. Do not
#   introduce the fresh print's 0.0.0-dev declaration.
# Re-run the diff command after editing. Do not continue until the command prints no matching lines.

# Remove root-level binaries (should not be committed). publish package
# already strips these before the copy; this rm -f is belt-and-suspenders
# for the agent path. Cover the names local build paths can drop: bare slug,
# CLI binary, live-dogfood probe binary, and MCP peer.
rm -f "$PUBLISH_REPO_DIR/library/<category>/<api-slug>/<api-slug>" \
      "$PUBLISH_REPO_DIR/library/<category>/<api-slug>/<cli-name>" \
      "$PUBLISH_REPO_DIR/library/<category>/<api-slug>/<cli-name>-dogfood" \
      "$PUBLISH_REPO_DIR/library/<category>/<api-slug>/<api-slug>-pp-mcp"

# Defense-in-depth: validate printer attribution before README and registry surfaces.
PRINTER=$(jq -r '.printer // ""' "$PUBLISH_REPO_DIR/library/<category>/<api-slug>/.printing-press.json")
PRINTER_NAME=$(jq -r '.printer_name // ""' "$PUBLISH_REPO_DIR/library/<category>/<api-slug>/.printing-press.json")
if [ -z "$PRINTER" ]; then
  echo "ERROR: manifest .printer is empty. Set 'git config --global github.user <your-handle>' and re-print before publishing."
  exit 1
fi
if [ "$PRINTER" = "USER" ] || [ "$PRINTER" = "user" ]; then
  echo "ERROR: manifest .printer is the literal sentinel \"$PRINTER\" (git config github.user was unset at print time). Set it and re-print before publishing."
  exit 1
fi
if [ -z "$PRINTER_NAME" ]; then
  echo "ERROR: manifest .printer_name is empty. Set 'git config --global user.name <your display name>' and re-print before publishing."
  exit 1
fi

# Do NOT regenerate or commit `cli-skills/pp-<api-slug>/SKILL.md` or
# `registry.json` here. Both are regenerated post-merge by the library's
# `generate-skills.yml` and `generate-registry.yml` workflows via
# `[skip ci]` bot commits. The library's `Fail on changes to generated
# artifacts` check in `verify-library-conventions.yml` hard-fails any PR
# whose diff against base touches these files, regardless of fork vs
# same-repo origin. The library no longer has an in-PR auto-fix path;
# do not re-introduce a mirror or registry regen here. Also do NOT hand-update
# CHANGELOG.md, .printing-press-release.json, or runtime version strings for
# release accounting; the library release-ledger workflow owns those post-merge.

# Verify this changed/new CLI builds and has no reachable Go vulnerabilities from the publish repo
cd "$PUBLISH_REPO_DIR/library/<category>/<api-slug>" \
  && go build ./... \
  && go run golang.org/x/vuln/cmd/[email protected] ./...

Keep vulnerability verification scoped to library/<category>/<api-slug> in publish PRs. The public library is a historical collection and cannot be kept fully current on every unrelated PR; whole-library govulncheck sweeps belong in a scheduled/reporting workflow, while blocking CI should scan only added or changed CLI modules.

After the publish repo copy and build verification are complete, remove the staging directory:

rm -rf "$STAGING_PARENT"

Note: staged_dir is keyed by the API slug (e.g., espn), matching the publish repo's directory layout. The copy step is a same-name copy, not a rename.

Step 6.5: Record Customizations

Before collision detection or branch creation, inspect the packaged CLI's customizations index:

The index ships in one of two shapes: the per-patch directory .printing-press-patches/ (current) or the legacy single-array .printing-press-patches.json (older prints, not yet normalized). Validate whichever is present:

PATCHES_DIR="$PUBLISH_REPO_DIR/library/<category>/<api-slug>/.printing-press-patches"
PATCHES_INDEX="$PUBLISH_REPO_DIR/library/<category>/<api-slug>/.printing-press-patches.json"
if [ -d "$PATCHES_DIR" ]; then
  # Per-patch directory: every <id>.json must be a JSON object carrying the same
  # provenance the legacy single-array file kept at its top level (now per file),
  # so validation is at parity with the legacy branch below. _meta.json
  # (CLI-global lists) and .gitkeep are exempt.
  for f in "$PATCHES_DIR"/*.json; do
    [ -e "$f" ] || continue
    [ "$(basename "$f")" = "_meta.json" ] && continue
    if ! jq -e '
      (type == "object") and
      (.schema_version | type == "number") and
      (.id | type == "string" and length > 0) and
      (.applied_at | type == "string" and length > 0) and
      (.base_run_id | type == "string" and length > 0) and
      (.base_printing_press_version | type == "string" and length > 0)
    ' "$f" >/dev/null; then
      echo "ERROR: packaged CLI has a malformed patch file $f. Reprint with a current cli-printing-press binary before publishing."
      exit 1
    fi
  done
elif [ -f "$PATCHES_INDEX" ]; then
  if ! jq -e '
    (.schema_version | type == "number") and
    (.applied_at | type == "string" and length > 0) and
    (.base_run_id | type == "string" and length > 0) and
    (.base_printing_press_version | type == "string" and length > 0) and
    (.patches | type == "array")
  ' "$PATCHES_INDEX" >/dev/null; then
    echo "ERROR: packaged CLI has malformed .printing-press-patches.json. Reprint with a current cli-printing-press binary before publishing."
    exit 1
  fi
else

Embed badges

Add these to your README to show the skill's verification status.

SkillSafe verified badge
Verified badge
[![SkillSafe verified badge](https://api.skillsafe.ai/v1/badge/@mvanhorn/printing-press-publish/verified)](https://skillsafe.ai/skill/@mvanhorn/printing-press-publish/)
Installs badge
Installs badge
[![Installs badge](https://api.skillsafe.ai/v1/badge/@mvanhorn/printing-press-publish/installs)](https://skillsafe.ai/skill/@mvanhorn/printing-press-publish/)
Scan badge
Scan badge
[![Scan badge](https://api.skillsafe.ai/v1/badge/@mvanhorn/printing-press-publish/scan)](https://skillsafe.ai/skill/@mvanhorn/printing-press-publish/)
Eval pass rate badge
Eval pass rate
[![Eval pass rate badge](https://api.skillsafe.ai/v1/badge/@mvanhorn/printing-press-publish/eval)](https://skillsafe.ai/skill/@mvanhorn/printing-press-publish/)