@mvanhorn/printing-press-publish
Generate a ship-ready CLI for an API with a lean research -> generate -> build -> shipcheck loop.
| name | printing-press-publish |
| description | Publish a generated CLI to the printing-press-library repo |
| version | 0.1.0 |
| min-binary-version | 4.0.0 |
| allowed-tools | Bash, Read, Write, Edit, Glob, Grep, AskUserQuestion |
| created_by | user |
/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, otherwisenull.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):
- Exact match: If the argument matches a directory name (API slug) exactly, use it
- CLI name match: If no exact match, try matching against
cli_namefields, then derive the API slug from the manifest'sapi_namefield - Suffix match: If no match yet, try
<argument>-pp-cliagainstcli_namefields - Glob match: If no suffix match, search for entries where
cli_nameorapi_namecontains the argument as a substring. Cap at 5 most-recent matches. If multiple matches, present them via AskUserQuestion and let the user pick - No match: List all available CLIs and ask the user to pick or re-enter
- 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:
If the manifest has a
categoryfield, present it for confirmation:"Publishing as <category>. OK?" Give the user the option to change it
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-appstop-level summary, not just inline threads. Summaries can carry actionableComments Outside Diffblocks 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 reviewafter ~3 minutes; if that doesn't recover, post@greptileai reviewyourself 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:
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")Detect git protocol:
USE_SSH=false if ssh -T [email protected] 2>&1 | grep -q "successfully authenticated"; then USE_SSH=true fiClone based on access:
Push access (
HAS_PUSHistrue):# 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_PUSHisfalse):# 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 upstreamCache 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. Theaccessfield determines the flow for all subsequent steps. Thegh_userfield is used for cross-repo PR heads. Themodule_path_basealways 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
Loading...
Select a file to preview
Analyzing security...
Checking scan reports and verification data.
Bill of Materials
Everything this skill can do — files, network, commands, and more.