mcpField
P
printing-press-publish

printing-press-publish

github.com/mvanhorn开发4.9k6.9k 次安装今天更新
代码仓库

关于 printing-press-publish

printing-press-publish 是一项将本地生成的命令行工具通过拉取请求发布到 printing-press-library 的技能,也可为生成受阻的 API 单独提交问题日志。

主要功能

  1. 01将工具名称解析为库条目
  2. 02通过拉取请求发布生成的工具
  3. 03提交受阻 API 日志拉取请求
  4. 04检查 GitHub CLI 身份验证
  5. 05执行发布与真实测试门槛

适用场景

  1. 01向公共库提交生成的工具
  2. 02记录可能反复出现的 API 阻碍
  3. 03解析库条目后发布工具

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/mvanhorn/cli-printing-press --skill printing-press-publish

安装后在 Claude Code 中输入/printing-press-publish确认已加载。

详细介绍

/printing-press publish

将生成的 CLI 从本地库发布到 printing-press-library 仓库,并创建拉取请求。

/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 形式限制

此技能只会创建生成的 CLI 发布 PR,或在使用 --blocked-api-journal 时创建 blocked-apis.json 日志 PR。它绝不会用仅包含文档、计划、提案或规范的 PR,来代替尚未准备好发布的 CLI。如果生成、验证或实时测试受阻,请报告确切的阻碍并停止。

必须由用户直接发起

发布操作可能会分叉 mvanhorn/printing-press-library、推送分支并创建或更新 PR。在设置或验证之前,请检查调用上下文。如果此技能是从 printing-press-polish 的 Publish Offer 链式延续调用的,包括通过 AskUserQuestion 回答或自动解析的 polish 建议调用,请立即停止,并告知用户在新消息中发送 /printing-press-publish <cli-name> --from-polish。用户新发送的请求只要明确要求发布就足够;对于直接发布请求,不要再额外要求确认。

如果用户新发送的请求包含 --from-polish,请在终态步骤中记录 POLISH_HANDOFF=true,并在解析 CLI 名称时忽略此标记。此标记不是第二次确认,也不会传递给 cli-printing-press;它只用于在新一轮对话中的发布完成后,保留独立 polish 原有的发布后回顾提议。

如果请求包含 --blocked-api-journal,请进入下文的受阻 API 日志模式,而不是走常规的已打印 CLI 发布流程。用户在 /printing-press 的暂停路径菜单中明确选择“Add to blocked-API journal”后,也可以调用此模式;该上级菜单选择已足以授权写入公共库日志。此日志专用模式无需用户在新一轮对话中再次调用。

如果用户新发送的请求包含 --skip-live-test=<reason>,请将确切的非空原因记录为 SKIP_LIVE_TEST_REASON,并在解析 CLI 名称前移除此标记。这是发布时实时测试关卡唯一支持的豁免方式。仅在身份验证不可用、已知上游故障、LAN 无法访问硬件 API,或其他类似且经操作员批准的具体情况下使用;绝不要根据常规延迟或较早的 Phase 5 标记推断可以跳过测试。

公共库将 library/<category>/<api-slug>/.printing-press.json 和 manifest.json 视为注册表显示字段的事实来源。发布 PR 中不要编辑 registry.json、README 目录单元格或 cli-skills/pp-<api-slug>/SKILL.md;这三者都会在合并后由该库自己的工作流通过机器人重新生成。库中的 verify-library-conventions.yml 检查项 Fail on changes to generated artifacts 会严格拒绝任何与基线相比改动了 registry.json 或 cli-skills/pp-*/SKILL.md 的 PR——无论它来自分叉仓库还是同一仓库。因此,包含其中任一文件的发布 PR 会在评审前被拒绝。

公共库还负责各 CLI 的发布记录。发布 PR 中不要手动更新 CHANGELOG.md、.printing-press-release.json 或运行时代码中的 var version = ...。新打印的 CLI 可以包含空白的发布记录模板;库的合并后工作流会在合并后分配最终的 YYYY.M.N 版本号,并写入运行时版本。替换公共库中已有的 CLI 时,请保留其现有发布记录文件,以免在重新打印 PR 中丢失变更日志历史。

blocked-apis.json 则不同:它是人工维护的公共库日志,而不是生成的注册表内容。仅包含日志的 PR 可以编辑 blocked-apis.json,但不得暂存 library/、registry.json、README 目录单元格或 cli-skills/。

阻止 API 日志模式

仅当调用包含 --blocked-api-journal 时使用此模式。它会记录一次被暂缓的 /printing-press 尝试;在机器或上游问题发生变化之前,此阻碍很可能会对其他用户重复出现。

调用方必须提供以下字段:

  • slug:规范 API slug,而非 CLI 二进制文件名。
  • attempted_at:YYYY-MM-DD。
  • verdict:hold。
  • reason:简洁的阻碍原因,不得包含机密、本地路径、Cookie、令牌或特定账户的详细信息。
  • blocking_issue:已知时填写 Printing Press issue 编号,否则填写 null。
  • permanent:布尔值。

如果调用方未提供其中某个字段,只能根据当前运行上下文推断安全值。如果缺少 reason 或原因含糊不清,请停止并询问一句具体的阻碍描述;不要写入无用的日志条目。

运行常规的 Setup、Configuration、限定范围的 clone 清理和 GitHub 身份验证检查,然后按照常规发布流程准备公共库 clone:如有需要则创建 fork,确保 upstream 指向 mvanhorn/printing-press-library,获取 upstream,并在编辑前将 clone 重置到 upstream/main。

然后仅更新 $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

创建日志分支和 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>

向 mvanhorn/printing-press-library 创建 PR,并在正文中说明:

  • 被暂缓的 API slug 和原因
  • 阻碍是永久性的,还是与 blocking_issue 相关
  • 预期的 Phase 0 行为:今后运行 /printing-press <api-slug> 时,会在重复尝试之前发出警告

PR 创建后,报告其 URL 并停止。不要继续执行常规的 printed-CLI 包、实时测试、注册表或技能镜像步骤。

设置

开始任何操作之前:

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

运行设置约定后,从标准输出中提取 PRINTING_PRESS_BIN=<abs-path> 这一行。此技能中后续每次调用 cli-printing-press ... 都必须使用该绝对路径(替换为实际值,而不是字面量 $PRINTING_PRESS_BIN);上方的 export PATH 只影响其运行的单次 Bash 工具调用,因此后续调用会启动新的 shell,此时裸写的 cli-printing-press 会根据用户的默认 PATH 解析,过时的全局版本可能会覆盖本地构建版本。

如果设置输出了 [go-toolchain-old] 或 [low-disk],请告知用户此提示,并继续操作,除非设置同时输出了 [setup-error]。[go-toolchain-old] 表示后续 Go 命令可能会下载所需工具链,也可能会在下载受阻时失败;[low-disk] 表示本次运行可能需要数 GiB 空间,用于生成文件、Go 构建缓存、模块下载或仓库 clone。

提取二进制文件路径后,检查二进制文件版本兼容性。读取此技能 YAML frontmatter 中的 min-binary-version 字段。运行 <PRINTING_PRESS_BIN> version --json 并从输出中解析版本号。使用 semver 规则将其与 min-binary-version 比较。如果已安装的二进制文件版本低于最低要求,请立即停止并告知用户:“cli-printing-press 二进制文件 vX.Y.Z 低于最低要求 vA.B.C。请运行 go install github.com/mvanhorn/cli-printing-press/v4/cmd/cli-printing-press@latest 进行更新。”

配置

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 以 JSON 格式存储持久发布设置。首次发布时,请使用默认值创建该文件。用户可以编辑该文件以更改库仓库或模块路径前缀。

{
  "managed_by": "printing-press-publish",
  "repo_url": "https://github.com/mvanhorn/printing-press-library",
  "access": "push",
  "protocol": "ssh",
  "clone_path": "<home>/printing-press/.publish-repo-<scope>",
  "scope_dir": "/absolute/path/to/source/worktree",
  "module_path_base": "github.com/mvanhorn/printing-press-library/library"
}

module_path_base 字段用于设置已发布 CLI 的 Go 模块路径前缀。打包期间,完整模块路径按 <module_path_base>/<category>/<api-slug> 构建。如果用户希望将 CLI 发布到不同的仓库或路径,请编辑此字段。 将 clone_path 和 scope_dir 存储为展开后的绝对路径,以便清理时无需依赖特定 shell 的 ~ 展开。执行清理前,必须有 managed_by 字段。

范围克隆清理

创建或复用 $PUBLISH_REPO_DIR 前,清理源工作树已不存在的范围发布克隆。这样可以让并发工作树彼此隔离,同时避免每个短期工作树都永久累积一个库克隆。

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

步骤 1:前置条件

确认 gh 已通过身份验证:

gh auth status

如果此操作失败,请停止并告知用户:“GitHub CLI 尚未通过身份验证。请先运行 gh auth login。”

步骤 2:解析 API Slug

运行:

cli-printing-press library list --json

将 JSON 输出解析为 CLI 列表。现在,库以 API slug(目录名称)为键,而不是 CLI 名称。

名称解析顺序(与 score 技能保持一致):

  1. **完全匹配:**如果参数与目录名称(API slug)完全一致,则使用该目录名称
  2. **CLI 名称匹配:**如果没有完全匹配项,则尝试匹配 cli_name 字段,然后从清单的 api_name 字段推导 API slug
  3. **后缀匹配:**如果仍然没有匹配项,则将 <argument>-pp-cli 与 cli_name 字段进行匹配
  4. **Glob 匹配:**如果没有后缀匹配项,则查找 cli_name 或 api_name 中包含该参数的条目。最多显示最近修改的 5 个匹配项。如果有多个匹配项,通过 AskUserQuestion 呈现并让用户选择
  5. **无匹配项:**列出所有可用 CLI,并请用户选择或重新输入
  6. **没有参数:**如果调用时未提供名称,则按修改时间排序列出所有 CLI,并让用户选择

解析完成后,读取清单的 api_name 字段以获取 API slug。后续所有操作(分支名称、注册表条目、冲突检测、路径构建)都使用此 slug。清单中的 cli_name 仅用于二进制级别的操作。

呈现匹配项时,以便于理解的格式显示 API slug 和修改时间(例如“2 小时前”“3 天前”)。

步骤 3:确定类别

从解析出的 CLI 目录读取 .printing-press.json。

类别解析顺序:

  1. 如果清单中有 category 字段,请先让用户确认:

    “将以 发布。确定吗?” 并让用户选择是否更改

  2. 如果清单未提供类别,则通过 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

步骤 3.5:Greptile 审查约定——创建 PR 前阅读

每个提交到公开库的 PR 都会接受自动化 Greptile 审查,并运行 Greptile policy gate CI 作业。权威约定见库的 AGENTS.md → "Automated code review with Greptile";要点如下:

  • **合并前必须解决 Greptile 提出的所有问题——0-5 分是置信度信号,不是门槛。**所有问题均已解决的 4/5 可以就绪;仍有未解决 P1 问题的 5/5 则不行。将每个 P0 和 P1 视为阻塞项;P2 需要修复,或通过具体回复说明延期处理。
  • 审查是增量式的:每次推送都会触发新一轮审查,可能发现新的问题。推动 PR 达到稳定的绿色状态——绝不能在第一轮后就宣布完成。
  • **阅读 greptile-apps 最新的顶层摘要,不要只看行内讨论串。**即使讨论串列表为空,摘要也可能包含可执行的 Comments Outside Diff 区块。宣布就绪前,运行仓库的审查状态辅助工具:
    python3 .github/scripts/pr-review-state/greptile_feedback.py <PR_NUMBER>
    
  • 超时恢复:如果策略门因 Timed out waiting for Greptile Review to complete 而失败(大型新 CLI 差异是常见诱因),该门会在约 3 分钟后自动发布 @greptileai review;如果仍未恢复,请自行发布 @greptileai review 并等待。
  • 分数门槛:策略门要求当前 head SHA 上最新的 Greptile 评论包含 Confidence Score: ≥ 4/5。新的推送会重新运行该门——最终 head 上的分数必须达到阈值。

步骤 4:验证

运行:

cli-printing-press publish validate --dir <cli-dir> --json

此步骤中的 govulncheck 仅针对 <cli-dir>。它使用默认的 govulncheck ./... 模式,因此,可达符号的发现结果会阻止发布,而仅依赖但没有调用路径的易受攻击模块不会成为发布阻碍。请勿将其替换为完整的公共库扫描或 govulncheck -show verbose。

解析 JSON 结果。向用户显示每项检查结果:

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)

如果 "passed": false,请报告未通过的检查并停止。不要创建不完整的 PR。manifest 检查是公共库来源信息约定的权威依据:当前的 schema_version、run_id、printing_press_version、printer、printer_name,以及在声明支持 MCP 时所需的 MCP 元数据文件。如果此检查未通过,请告知用户,在创建库 PR 前,需使用当前的 Printing Press 元数据重新打印或重新打包。

保存结果中的 help_output 字段——PR 描述会用到它。

第 4.5 步:实时端到端门禁

在操作托管发布克隆之前,针对即将发布的 CLI 重新运行实时行为门禁。第 4 步证明源代码可以构建且结构验证通过;本步骤证明当前编辑后的代码树仍能正常调用真实上游 API。不要依赖生成或润色阶段遗留的旧 phase5-acceptance.json,因为自写入该标记后,CLI 可能已被手动修改。

标记失效与同步。 接受标记包含源代码指纹;写入标记后,只要修改任何 .go 文件,publish package 就会因“phase5 marker source fingerprint does not match”而失败。每次更改源代码后都要重新运行此实时门禁,并将标记写入两个副本:嵌入的 $CLI_DIR/.manuscripts/<run>/proofs/ 和归档的 $PRESS_MANUSCRIPTS/<api>/<run>/proofs/(手稿优先从归档位置查找,证明文件优先从嵌入位置查找——任一位置存在过期副本都会阻止打包)。

从 CLI 清单中解析 Phase 5 证明目录:

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 标记与经过验证的源代码树绑定。实时 dogfood 写入器会自动记录 source_fingerprint 和每个文件的哈希值。发布验证会根据当前 CLI 目录重新计算指纹,并拒绝与已更改代码树不匹配的标记;如果标记中包含相关信息,还会指出已更改的源文件。仅修改 README 不会影响此指纹,也不会使门禁失效。

如果未设置 SKIP_LIVE_TEST_REASON,请运行完整的实时 dogfood,并在该证明目录中写入新的接受标记。将原始 --json 记录发送到所有手稿目录之外的私有临时目录中。其中包含 API 响应正文和主机绝对路径。mktemp -d 会创建权限为 0700 的目录;记录文件的权限为 0600。门禁结束后,删除该目录:失败时,先打印失败的命令,再删除;成功时,继续操作前先删除。publish package 会强制执行相同的边界:无论文件名如何,都会排除实时 dogfood 和实时门禁 JSON 报告(顶层 tests 数组中的条目带有 output_sample,或包含 dir + binary + verdict + tests),以及 publish-live-gate*.json、*-publish-live-gate.json、dogfood-results*.json、*-dogfood-results.json 和 pipeline/ 树,包括先前 dogfood 运行时已保存到 proofs 下的副本。它仍会复制 phase5-acceptance.json 和 phase5-skip.json。其他随附的手稿文本中,printing-press 下的主目录路径或点文件目录路径,以及任何绝对 .runstate 路径,都会分别替换为 <cli-dir> 和 <runstate>。

LIVE_GATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/printing-press-publish.XXXXXX")
chmod 700 "$LIVE_GATE_DIR"
LIVE_GATE_JSON="$LIVE_GATE_DIR/${API_SLUG}-${RUN_ID}-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 ! (
  umask 077
  set +e
  "$PRINTING_PRESS_BIN" "${LIVE_GATE_ARGS[@]}" >"$LIVE_GATE_JSON"
  live_gate_status=$?
  chmod 600 "$LIVE_GATE_JSON" 2>/dev/null || true
  exit "$live_gate_status"
); then
  echo "Publish live gate failed. See $PROOFS_DIR/phase5-acceptance.json."
  jq -r '.tests[]? | select(.status == "fail") | "- \(.command) [\(.kind)]: \(.reason // "failed")"' "$LIVE_GATE_JSON" 2>/dev/null || true
  rm -rf "$LIVE_GATE_DIR"
  exit 1
fi
rm -rf "$LIVE_GATE_DIR"

失败时,按照第 4 步中 passed: false 的处理方式立即停止:不创建托管克隆、不创建分支、不打包、不创建 PR。报告失败的命令、存在时的退出代码、stderr 或原因摘要,以及新生成的证明文件路径,以便操作人员重新运行 dogfood 并修复 CLI。

如果通过 --skip-live-test=<reason> 设置了 SKIP_LIVE_TEST_REASON,则写入新的跳过标记,而不运行 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=""

然后重新运行第 4 步的验证:

"$PRINTING_PRESS_BIN" publish validate --dir "$CLI_DIR" --json

此二次验证用于证明新接受标记或跳过标记满足打包和发布所依赖的相同 Phase 5 契约。如果验证失败,请在进入第 5 步之前停止。

第 5 步:托管克隆

发布技能会在 $PUBLISH_REPO_DIR 管理自己的库仓库克隆。

首次设置

如果 $PUBLISH_REPO_DIR 不存在:

  1. 检测推送权限:

    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. 检测 git 协议:

    USE_SSH=false
    if ssh -T [email protected] 2>&1 | grep -q "successfully authenticated"; then
      USE_SSH=true
    fi
    
  3. 根据访问权限克隆:

    有推送权限(HAS_PUSH 为 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>
    

    无推送权限(HAS_PUSH 为 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. 缓存配置:

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

    写入 $PUBLISH_CONFIG。access 字段决定所有后续步骤的流程。gh_user 字段用于跨仓库 PR 分支。module_path_base 始终指向上游仓库(PR 会提交到该仓库)。

后续发布

读取 $PUBLISH_CONFIG,然后重新检查访问权限,以防权限发生变化(用户已获授推送权限,或权限已被撤销):

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

如果克隆因访问权限变更而被移除,请重新执行上文的首次设置。否则,更新克隆,使其与规范上游保持一致:

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

验证克隆是否正常:

git rev-parse --is-inside-work-tree
test "$(git rev-parse --abbrev-ref HEAD)" = "main"

如果失败,说明克隆已损坏。移除 $PUBLISH_REPO_DIR 并重新执行首次设置。

中断状态恢复

创建新分支前,检查是否有未提交的更改:

cd "$PUBLISH_REPO_DIR"
git status --porcelain

如果有未提交的更改,请通过 AskUserQuestion 询问用户:

  • “重置并重新开始”
  • “继续使用现有更改”

如果选择重置,请运行 git checkout -- . && git clean -fd。

打包前的发布状态快照

在步骤 6 修改受管理的克隆之前,记录此 API slug 是否已存在于公共库目录树中。步骤 6 会移除并替换 library/*/<api-slug>,因此打包后做出的任何冲突或发布路径决策都必须使用此打包前快照,而不是重新运行 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

步骤 6:打包

读取 $PUBLISH_CONFIG 以获取 module_path_base。使用 API slug(而不是 CLI 名称)构造完整模块路径:

MODULE_PATH="<module_path_base>/<category>/<api-slug>"

例如:github.com/mvanhorn/printing-press-library/library/productivity/notion

在 --dest 模式下,必须指定 --module-path。 使用 --dest 打包时,始终传入 --module-path "$MODULE_PATH"。如果省略该参数,go.mod/导入路径重写(由该标志控制的 RewriteModulePath)会被静默跳过,因此打包后的 CLI 仍会保留 module <cli-name>,库 CI 会因模块路径不匹配而拒绝 PR。publish package 会在重写后验证暂存树的模块路径:使用 --module-path 时,暂存的 go.mod 必须声明完全相同的路径(仅使用 CLI 名称的模块仍会失败);不使用 --module-path 时,则必须使用规范的 github.com/mvanhorn/printing-press-library/library/ 前缀。在源代码树上单独运行 publish validate 时,此检查会作为警告显示——重写前预期模块名称仅为 CLI 名称;权威的失败结果会出现在打包步骤中。

运行带有 --target 的 publish package,将 CLI 暂存到唯一的临时目录,然后将其复制到发布仓库:

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"

# Reprints pass the existing public-library entry so package stamps its
# runtime version declaration layout instead of leaving 0.0.0-dev.
BASE_CLI_DIR="$(find "$PUBLISH_REPO_DIR/library" -mindepth 2 -maxdepth 2 -type d -name "<api-slug>" -print -quit)"
PACKAGE_BASE_ARGS=()
if [ -n "$BASE_CLI_DIR" ]; then
  PACKAGE_BASE_ARGS=(--base-dir "$BASE_CLI_DIR")
fi

cli-printing-press publish package \
  --dir <cli-dir> \
  --category <category> \
  --target "$STAGING_DIR" \
  --module-path "$MODULE_PATH" \
  "${PACKAGE_BASE_ARGS[@]}" \
  --json

解析 JSON 结果。记录 staged_dir、module_path、manuscripts_included 和 run_id。module_path 字段会确认打包后的 CLI 的 go.mod 和导入路径中设置的 Go 模块路径。

publish package 会在返回成功前,对暂存的 CLI(包括复制的手稿)执行强制供应商前缀密钥扫描。如果报告检测到 vendor-prefix tokens detected,请停止操作,并移除或编辑报告中指出的文件与行,然后重试。这是一项硬性门槛,不依赖 gitleaks、trufflehog 或目标仓库的推送保护。

然后将暂存的 CLI 复制到发布仓库,替换任何现有版本;如果是重印,则保留公共库的发布账本文件:

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 and existing shipcheck reports 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.
# Fresh prints strip dogfood-results.json and workflow-verify-report.json from
# the staged tree; reprints must copy those catalog files back so the overlay
# does not delete them.
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 dogfood-results.json workflow-verify-report.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 dogfood-results.json workflow-verify-report.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, stop. Do not hand-edit version declarations.
# publish package --base-dir should already have reconciled 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 publish package with --base-dir pointing at the existing library entry,
# then repeat this diff. 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"

# publish package removes `.printing-press-live-check-*` directories and
# compiled executables at any depth from the staged tree before this copy.
# Do not copy those paths back from <cli-dir>; a depth-1 rm does not see them.

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

发布 PR 中的漏洞验证范围应限于 library/<category>/<api-slug>。公共库是一个历史集合,无法在每个无关 PR 中都保持完全最新;对整个库运行 govulncheck 扫描应属于定期报告工作流,而阻断式 CI 只应扫描新增或更改的 CLI 模块。

发布仓库复制和构建验证完成后,移除暂存目录:

rm -rf "$STAGING_PARENT"

注意:staged_dir 以 API slug 为键(例如 espn),与发布仓库的目录布局一致。复制步骤是同名复制,不是重命名。

步骤 6.5:记录自定义修改

在进行冲突检测或创建分支之前,检查打包的 CLI 自定义修改索引:

索引有两种形式:按补丁划分的目录 .printing-press-patches/(当前形式),或旧版单数组文件 .printing-press-patches.json(较早生成的版本,尚未规范化)。验证存在的形式:

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
  echo "ERROR: packaged CLI is missing its patches index (.printing-press-patches/ or .printing-press-patches.json). Reprint with a current cli-printing-press binary before publishing."
  exit 1
fi

使用当前版本的 cli-printing-press generate 新生成的 CLI 会包含一个仅有 .gitkeep 的 .printing-press-patches/ 目录。如果生成后没有进行手动自定义修改,请保持该目录不变。如果两种形式都不存在,说明 CLI 是由较旧的二进制文件生成的;请使用当前版本的 cli-printing-press 重新打印,而不要手动合成确定性来源字段。

步骤 6.6:记录贡献者署名

如果执行此次发布的人员不是 CLI 的原始创建者,请将其记录为贡献者,以便在 README 署名栏、NOTICE 和公共注册表中致谢。此命令具有幂等性——会跳过创建者和已列出的人员,因此每次发布时运行都安全:

"$PRINTING_PRESS_BIN" contributors add \
  --dir "$PUBLISH_REPO_DIR/library/<category>/<api-slug>" \
  || echo "note: this binary predates 'contributors add'; skipping contributor recording"

此步骤尽力执行:contributors add 是一个追加命令,因此不包含该命令的旧版二进制文件会跳过记录,而不会阻止发布(min-binary-version 下限仅跟踪主版本号)。如果此次发布是重新打印(从零开始重新生成),请传入 --front,以便将重新打印者列在贡献者名单首位。切勿手动编辑 contributors[] 或 creator 块——创建者信息是永久的,且名单由该命令管理(遵循清单为准的规则)。

如果你在打印或发布过程中修改了生成的 CLI 文件,请在创建库 PR 之前为每项自定义修改记录一条简洁条目——每个补丁对应一个 .printing-press-patches/<id>.json 文件(该目录取代旧版 patches[] 数组,因此并发 PR 不会发生冲突)。这些条目是持久的手动编辑约定,用于告知后续 Agent 和重新生成工具:生成器输出之外的哪些内容必须保留。

使用以下格式(一个文件:.printing-press-patches/<id>.json):

{
  "schema_version": 2,
  "id": "<api-slug>-<short-feature-name>",
  "applied_at": "<YYYY-MM-DD>",
  "base_run_id": "<copy from .printing-press.json>",
  "base_printing_press_version": "<copy from .printing-press.json>",
  "summary": "What changed (one sentence).",
  "reason": "Why the generated output needed this customization.",
  "files": ["internal/cli/example.go"],
  "validated_outcome": "Optional: focused check that proved the customization.",
  "upstream_issue": "Optional: https://github.com/mvanhorn/cli-printing-press/issues/<n>"
}

规则:

  • 文件名使用补丁的 id。使用以 API slug 为前缀的 kebab-case ID,便于在公共库中使用 grep 查找。
  • summary 和 reason 应简短。每个条目都是索引,而非 git 差异的副本。
  • 如果非 Go 支持文件属于同一项代码级自定义修改,请将其列入 files。仅涉及 README/SKILL.md 的润色无需添加补丁清单条目。publish validate 会读取这些记录;如果记录的 files[] 路径不存在、每条补丁记录缺少 schema_version 或声明了不受支持的版本,或声明的 call_sites / markers / marker 字符串未出现在记录的文件中,则验证会失败。每个 call_sites / markers / marker 检查项都必须指定 files[],以免其他位置残留的子字符串掩盖自定义修改已被删除的事实。检查项只会在这些记录的文件中验证。
  • 行内 // PATCH(...) 源代码注释是可选的导航提示。公共库验证器要求存在补丁索引(目录或旧版文件)且条目格式正确;不要求标记与注释配对。
  • 如果某条目仅用于规避已不再适用的旧验证器或流水线 Bug,请删除过时的规避文件,而不要继续保留。

权威的公共库编写约定,请参阅 mvanhorn/printing-press-library 中 AGENTS.md 的“.printing-press-patches/ 记录库侧自定义修改”章节。

步骤 7:冲突检测与解决

托管克隆更新完成后,在创建分支或 PR 之前检查名称冲突。此步骤取代了之前的“检查现有 PR”步骤。

检测

按以下顺序运行这些检查:

1. 检查托管克隆中的合并 CLI:

MERGED_COLLISION="$PREEXISTING_MERGED_COLLISION"
MERGED_PATHS="$PREEXISTING_MERGED_PATHS"

使用第 5 步的预打包快照。此处不要重新运行 ls "$PUBLISH_REPO_DIR/library"/*/"<api-slug>":第 6 步已将新包复制到该路径,因此重新运行 ls 会让每次新的发布都看起来像合并冲突。如果 MERGED_COLLISION=true,请记下 MERGED_PATHS 中的类别路径。

2. 检查所有开放 PR(不限作者):

gh pr list --repo mvanhorn/printing-press-library --head "feat/<api-slug>" --state open --json number,title,url,author

如果列表非空,记录 PR_COLLISION=true。对每个 PR,记下 PR 编号、URL 和作者登录名。

3. 找出自己的 PR:

使用 --author @me 筛选第 2 步中的 PR 列表:

对于基于 fork 的 PR,head 中包含用户名作为前缀:

ACCESS=$(jq -r .access "$PUBLISH_CONFIG")
GH_USER=$(jq -r .gh_user "$PUBLISH_CONFIG")

if [ "$ACCESS" = "fork" ]; then
  HEAD_REF="$GH_USER:feat/<api-slug>"
else
  HEAD_REF="feat/<api-slug>"
fi

gh pr list --repo mvanhorn/printing-press-library --head "$HEAD_REF" --state open --author @me --json number,title,url

如果找到,记录 OWN_PR=true,并保存 EXISTING_PR_NUMBER 和 EXISTING_PR_URL。

如果没有找到开放 PR,还要检查同一分支上是否有之前已合并的 PR——不限作者,不仅限于你:

MERGED_PR=$(gh pr list --repo mvanhorn/printing-press-library --head "$HEAD_REF" --state merged --json number --jq '.[0].number' 2>/dev/null)

如果 MERGED_PR 非空,说明该分支名已被使用并合并。设置 BRANCH_MERGED=true,以便第 8 步创建新分支名(例如 feat/<api-slug>-YYYYMMDD),而不是重用已合并的分支。不要强行推送到已合并的分支——gh pr edit 会悄悄更新一个已关闭且无人关注的 PR。

不限作者的查询还能发现压缩合并遗留分支:GitHub 的 squash 合并会将源分支留在远程仓库中,其压缩合并前的提交引用看起来“领先于 main”,但内容与 squash 提交等效。如果没有这项检查,该技能会将遗留分支误判为全新发布,随后 git push -u 会因远程分支已存在而失败。使用时间戳可以彻底避开这个问题。

无冲突

如果不存在已合并的 CLI,且没有匹配的开放 PR(你自己的 PR 除外),请根据自己的 PR 检查设置 EXISTING_PR_NUMBER(如果没有则留空),然后正常继续执行第 8 步。

如果找到了你自己的现有开放 PR,请告知用户:

“找到你针对 <api-slug> 的开放 PR #N。将使用新版本更新该 PR。”

检测到冲突 — 显示信息

向用户展示找到的内容:

⚠️  Name collision detected for <api-slug>

  Merged: <category>/<api-slug> exists in the library
  Open PR: #<number> by <author> — <url>

显示所有适用的行。如果 OWN_PR=true,请在该 PR 旁标注“(你的)”。

解决方案

通过 AskUserQuestion 提供三个选项:

如果 OWN_PR=true(存在你自己的开放 PR):

  • 更新 — 使用新版本更新你现有的 PR(默认选项,保留当前行为)
  • 并行发布 — 为你的版本添加限定词并重命名,与现有版本一同发布
  • 取消 — 取消发布

如果存在 PR 冲突,但属于其他用户,或只有合并冲突:

  • 替换 — 有意覆盖现有 CLI
  • 并行发布 — 为你的版本添加限定词并重命名,与现有版本一同发布
  • 取消 — 取消发布并查看现有 CLI/PR
更新路径(自己的 PR)

这是带有分歧保护的现有更新流程。设置 EXISTING_PR_NUMBER,使用检测步骤中的值,然后继续执行第 8 步。第 8 步会获取当前 PR 分支的最新提交,检查新包是否会撤销仅存在于分支上的修复,之后才处理强制推送和 PR 描述更新。

替换路径

**对于已合并的 CLI 或你自己的 PR:**标准确认:

“这将替换现有的 <api-slug>。是否继续?”

**对于其他用户的 PR:**更强的确认,注明其他作者:

“⚠️ 这将替换 <author> 的 <api-slug>(PR #N)。你确定吗?”

确认后:

  • PR 描述必须包含:⚠️ **Replaces existing \`** — <reason provided by user or "newer version">`
  • 设置 EXISTING_PR_NUMBER=""(创建新 PR,不更新对方的 PR)
  • 正常继续执行第 8 步
Alongside 路径(重命名)

1. 从清单的 api_name 字段中提取原始 API slug:

# Read from .printing-press.json in the publish repo's staged CLI
ORIGINAL_API_SLUG=$(cat "$PUBLISH_REPO_DIR/library/<category>/<api-slug>/.printing-press.json" | jq -r '.api_name')

2. 使用 slug 格式生成重命名建议。根据所选 slug 推导新的 CLI 名称:

  • 数字:<api-slug>-2(如果冲突,则尝试 -3、-4 等)
  • 非数字:<api-slug>-alt
  • 自定义:提示用户输入一个限定词

用户选择 slug 后,计算:

NEW_API_SLUG="<chosen-slug>"
NEW_CLI_NAME="${NEW_API_SLUG}-pp-cli"

向用户展示格式:

“重命名格式:<api-slug>-<qualifier>。请选择一个限定词:”

  1. 2 → <api-slug>-2
  2. alt → <api-slug>-alt
  3. 输入自定义限定词

3. 展示每项建议前,确认其不会发生冲突:

# Check merged (read the git tree, not the sparse working dir)
git -C "$PUBLISH_REPO_DIR" ls-tree -r --name-only HEAD \
  | sed -n 's#^\(library/[^/]*/<suggestion>\)/.*#\1#p' | sort -u
# Check open PRs
gh pr list --repo mvanhorn/printing-press-library --head "feat/<suggestion>" --state open --json number

如果建议存在冲突,则跳过该建议或递增数字后缀。

4. 在发布仓库中重命名 CLI:

由于步骤 6 已将暂存的 CLI 复制到 $PUBLISH_REPO_DIR,因此重命名操作针对该目录进行。注意:--old-name/--new-name 仍使用 CLI 名称格式(例如 dub-pp-cli),因为 RenameCLI 会替换内容——使用纯 slug 会造成附带损坏。--dir 路径使用以 slug 为键的目录。重命名还会改写 go.mod、残留的模块路径 slug、安装程序 slug、环境变量前缀以及 research.json 中的 api_name(包括 .manuscripts/ 下的文件)。成功重命名后,请勿手动修复这些内容。

cli-printing-press publish rename \
  --dir "$PUBLISH_REPO_DIR/library/<category>/<api-slug>" \
  --old-name <old-cli-name> \
  --new-name "$NEW_CLI_NAME" \
  --json

解析 JSON 结果。确认 "success": true。注意,new_dir 此时应为 $PUBLISH_REPO_DIR/library/<category>/$NEW_API_SLUG。

5. 为步骤 8 更新所有下游引用:

  • 分支名称:feat/$NEW_API_SLUG(不是旧 slug)
  • PR 标题:feat($NEW_API_SLUG): add $NEW_API_SLUG
  • 提交消息:feat($NEW_API_SLUG): add $NEW_API_SLUG
  • Registry.json 条目:name → $NEW_API_SLUG
  • 设置 EXISTING_PR_NUMBER=""(重命名后的 CLI 始终创建新 PR)

使用新名称继续执行步骤 8。

退出路径

显示现有内容的链接:

  • 如果已合并:“现有 CLI:library/<category>/<api-slug>/”
  • 如果有未关闭的 PR:“未关闭的 PR:”

退出发布流程。如果步骤 6 已将文件写入 $PUBLISH_REPO_DIR,则在托管克隆中使用 git checkout -- . && git clean -fd 清理。

步骤 8:创建分支、提交和 PR

创建分支

如果已设置 EXISTING_PR_NUMBER(更新现有 PR):

在替换当前 PR 分支之前,先获取并检查该分支。最新的 origin/main 加上新打包的 library/<category>/<api-slug>/ 树 就是拟议的更新。远程 PR 分支也可能包含 drive-to-green 循环中已接受的评审修复。这些仅存在于该分支的修改不得在未告知的情况下被删除。

UPDATE_BRANCH="feat/<api-slug>"
UPDATE_BASE_REF="refs/printing-press-update-base/<api-slug>"

git fetch origin "+main:refs/remotes/origin/main" "+$UPDATE_BRANCH:$UPDATE_BASE_REF"

# Show the scoped change from the current PR head to the new packaged working
# tree. This is informational for clean updates and mandatory context for holds.
git diff --stat "$UPDATE_BASE_REF" -- "library/<category>/<api-slug>/"

# Branch-only paths are files that exist on the current PR branch but are absent
# from the new packaged working tree. These are always a hold because a
# force-push would delete them.
WORKTREE_PATHS=$(find "library/<category>/<api-slug>" -type f -print 2>/dev/null | sort)
BRANCH_ONLY_PATHS=$(comm -23 \
  <(git ls-tree -r --name-only "$UPDATE_BASE_REF" -- "library/<category>/<api-slug>/" | sort) \
  <([ -n "$WORKTREE_PATHS" ] && printf '%s\n' "$WORKTREE_PATHS" || true))

# Modified paths need human review only when a branch patch relative to
# origin/main is not present in the new packaged working tree. A strict superset
# passes: if the branch patch can be reverse-applied from the working tree, the
# fix is still there even if the file also has fresh generated changes.
BRANCH_ONLY_EDITS=$(git diff --name-only origin/main "$UPDATE_BASE_REF" -- "library/<category>/<api-slug>/" | while read -r path; do
  [ -n "$path" ] || continue
  if git diff origin/main "$UPDATE_BASE_REF" -- "$path" | git apply --check --reverse >/dev/null 2>&1; then
    continue
  else
    printf '%s\n' "$path"
  fi
done | sort -u)

if [ -n "$BRANCH_ONLY_PATHS$BRANCH_ONLY_EDITS" ]; then
  echo "HOLD: updating PR #$EXISTING_PR_NUMBER would overwrite branch-only changes."
  if [ -n "$BRANCH_ONLY_PATHS" ]; then
    echo "Files present on the PR branch but missing from the new package:"
    printf '%s\n' "$BRANCH_ONLY_PATHS" | sed 's/^/- /'
  fi
  if [ -n "$BRANCH_ONLY_EDITS" ]; then
    echo "Files edited on the PR branch and changed again by the new package:"
    printf '%s\n' "$BRANCH_ONLY_EDITS" | sed 's/^/- /'
  fi
  echo "Do not push yet. Reconcile by restoring the branch-only fixes onto the new tree, or ask the user for explicit overwrite confirmation after showing the paths above."
  exit 1
fi

如果守卫检查退出,请通过 AskUserQuestion 向用户提供两个选项:

  • 先协调 — 将列出的仅存在于该分支的文件或修改恢复到新打包的树中;保留或添加与代码级修复对应的 .printing-press-patches/<id>.json 记录;重新运行 Step 6 验证,然后再次运行此分歧守卫。
  • 确认后覆盖 — 只有在用户确认列出的路径已过时后,才继续操作,并在 PR 正文中注明被覆盖的仅存在于该分支的路径。

如果守卫未发现仅存在于该分支的路径或修改,则覆盖本地分支:

git checkout -B feat/<api-slug>

如果 EXISTING_PR_NUMBER 为空且 BRANCH_MERGED 为 true(之前的 PR 已合并):

自动创建带时间戳的分支 — 不要重复使用已合并的分支名称:

git checkout -b feat/<api-slug>-$(date +%Y%m%d)

如果 EXISTING_PR_NUMBER 为空且未设置 BRANCH_MERGED(没有未关闭或已合并的 PR):

检查是否存在过时分支和冲突 PR:

# Check local and remote branches
LOCAL_BRANCH=$(git branch --list "feat/<api-slug>" | head -1)
REMOTE_BRANCH=$(git ls-remote --heads origin "feat/<api-slug>" 2>/dev/null | head -1)

# If a remote branch exists, check who owns it
if [ -n "$REMOTE_BRANCH" ]; then
  # Check for ANY open PR on this branch (not just ours)
  OTHER_PR=$(gh pr list --repo mvanhorn/printing-press-library --head "feat/<api-slug>" --state open --json number,author --jq '.[0]' 2>/dev/null)
fi

如果此分支上存在其他用户的未关闭 PR(OTHER_PR 不为空,且作者不是 @me):

“有人已为 <api-slug> 创建了未关闭的 PR(PR #N,作者为 @author)。正在创建带时间戳的分支以避免冲突。”

自动创建带时间戳的分支:feat/<api-slug>-YYYYMMDD。不要提供覆盖选项 — 这会覆盖对方的工作。

如果分支存在但没有冲突 PR(先前已关闭或已合并的 PR 留下的过时分支):

通过 AskUserQuestion 询问:

“发现过时分支 feat/<api-slug>(可能来自之前的发布)。要覆盖它吗?”

  • “覆盖现有分支” — 重用分支名称
  • “创建带时间戳的变体(feat/-YYYYMMDD)”

如果分支不存在: 正常创建。

# New branch:
git checkout -b feat/<api-slug>

# Overwrite existing:
git checkout -B feat/<api-slug>
提交并推送
cd "$PUBLISH_REPO_DIR"
git add -A library/
# The staged package has already stripped local binaries and passed the
# mandatory secret/PII scans, so it is the source of truth for this publish.
# Force-add the whole CLI directory after the broad add: destination-repo or
# package-local .gitignore rules such as `*-pp-cli`, `*-pp-mcp`,
# `/.manuscripts/`, or report filenames must not silently suppress required
# publish artifacts under cmd/, .manuscripts/, or metadata files.
git add -f "library/<category>/<api-slug>/"

# Pre-commit scope guard: only this CLI's replacement plus any pre-existing
# merged paths for the same slug may be staged. This catches stale untracked
# fragments from previous publish branches before they leak into the wrong PR.
EXPECTED_STAGE_PREFIXES=$(printf '%s\n' "library/<category>/<api-slug>/" "$PREEXISTING_MERGED_PATHS" | sed '/^$/d; s#/*$#/#' | sort -u)
UNEXPECTED_STAGED=$(git diff --cached --name-only | awk -v prefixes="$EXPECTED_STAGE_PREFIXES" '
BEGIN {
  n = split(prefixes, p, "\n")
  while ((getline line) > 0) {
    matched = 0
    for (i = 1; i <= n; i++) {
      if (p[i] != "" && (line == p[i] || index(line, p[i]) == 1)) {
        matched = 1
        break
      }
    }
    if (!matched) print line
  }
}')
if [ -n "$UNEXPECTED_STAGED" ]; then
  echo "ERROR: publish staged paths outside the expected CLI scope:" >&2
  printf '%s\n' "$UNEXPECTED_STAGED" | sed 's/^/- /' >&2
  echo "Reset the managed clone and rerun publish package before committing." >&2
  exit 1
fi
git commit -m "feat(<api-slug>): add <api-slug>"

推送到 origin(对于无推送权限的用户,这是 fork;对于有推送权限的用户,这是上游仓库):

如果正在更新现有 PR(已设置 EXISTING_PR_NUMBER):

# Only run this after the update-path divergence guard above has passed, or
# after the user explicitly confirmed an intentional overwrite of the named
# branch-only paths.
git push --force-with-lease -u origin feat/<api-slug>

如果正在创建新 PR,且你之前选择了“覆盖现有分支”:

git push --force-with-lease -u origin feat/<api-slug>

否则(新分支,无冲突):

git push -u origin feat/<api-slug>
获取已推送提交的 SHA

推送后,获取最新提交的 SHA。此值用于在 PR 正文中构建稳定的手稿链接(见下文“编写 PR 描述”)。

HEAD_SHA=$(git rev-parse HEAD)

在 PR 存续期间,该 SHA 在 mvanhorn/printing-press-library 上始终可解析(GitHub 会将 fork PR 的最新提交镜像到上游的 refs/pull/<N>/head),即使 PR 已合并且分支已删除,也仍然有效。此技能每次运行都会在推送后获取新的 HEAD_SHA 并重写正文,因此技能执行更新后,链接仍会指向最新内容。如果在此技能之外对分支执行强制推送,请重新运行 /printing-press-publish 以刷新正文 — 之前的链接仍然有效,但会指向此次外部推送之前的手稿内容。

创建或更新 PR

从 $PUBLISH_CONFIG 读取 access 和 gh_user。它们决定了如何调用 gh pr create。

对于基于 fork 的 PR(access 为 fork):使用 --head <gh_user>:feat/<api-slug>,让 GitHub 从 fork 向上游创建跨仓库 PR。如果不指定 --head,gh pr create 会尝试在上游仓库查找分支(用户无权向该仓库推送),并因此失败。

对于具有推送权限的 PR(access 为 push):使用 --head feat/<api-slug>,让 GitHub 从此流程刚刚推送的分支创建 PR,即使托管克隆或 Shell 会话当前检出了其他分支也不受影响。 根据以下内容构建 PR 描述:

  • 清单(description、api_name、category、printing_press_version、spec_url)
  • 第 6 步后打包的 CLI 中清单的 novel_features 数组
  • 第 4 步中捕获的 help_output
  • CLI 的 README(前 2–3 段,或注明缺少 README)
  • .manuscripts/<run-id>/research/ 和 .manuscripts/<run-id>/proofs/ 下每个文件的链接。每个链接都必须是完整的 https://github.com/mvanhorn/printing-press-library/blob/<HEAD_SHA>/library/<category>/<api-slug>/.manuscripts/<run-id>/<subdir>/<filename> URL——绝不能使用相对路径(GitHub 会将相对路径解析到 …/pull/ 下,生成失效的 …/pull/library/… URL),也不能链接目录(blob 视图需要文件)。列出实际文件,不要编造或遗漏文件。
  • 第 4 步的验证结果
  • 第 4.5 步的发布实时门禁结果,包括任何明确的 --skip-live-test 原因
  • 列出所有缺失清单字段的 Gaps 部分

打包后,从 $PUBLISH_REPO_DIR/library/<category>/<api-slug>/.printing-press.json 读取 novel_features。保留清单中的顺序。不要根据 README 文案、SKILL 文案、根目录帮助信息或对本次运行的记忆来生成此部分:这些内容可能经过摘要或手动编辑,而打包后的清单才是发布时的事实来源。对于每个条目,包含命令、名称和描述。如果数组为空,写入 No novel commands recorded in .printing-press.json.,并在 Gaps 中将该字段列为缺失;不要省略此部分。

还要包含发布路径行,以区分新发布、重新发布、PR 更新和冲突重命名:

  • New print — 没有已合并的 CLI,也没有现有 PR 与此 slug 匹配。
  • Update existing PR #<N> — 此次发布更新一个开放 PR。
  • Reprint/replace — 此次发布前,库中已存在一个已合并的 CLI,且所选路径会替换它。此判断必须基于 PREEXISTING_MERGED_COLLISION=true,不能基于打包后的目录树。
  • Alongside print — 此次发布为避免冲突而重命名了 API slug;请包含原始 slug。 如果 /printing-press-reprint 在没有先前公共库来源的情况下移交了降级重新发布,则使用 New print,并且仅当移交信息中包含该上下文时才添加降级重新发布说明。

强制要求:构建 PR 正文前,必须清除工作区中的个人身份信息。 库仓库是公开的。检查任何实时测试结果、验收数据或手稿摘录中的组织名称、团队成员姓名和电子邮件地址。替换为通用描述(“该工作区”“5 名团队成员”“12 名用户”)。团队键(例如 “ESP”)可以保留,但不能包含组织名称(例如 “Acme Corp”)。完整政策请参阅 printing-press 技能中的 references/secret-protection.md。

将构建好的 PR 正文写入临时 Markdown 文件,并通过 --body-file 传入。创建和更新 PR 时都必须这样做。不要将正文内联写入 shell 参数;较长的 fenced 帮助输出、Markdown 表格和反引号很容易被破坏。

PR 描述模板:

## <api-slug>

<If this is a Replace path, add: "⚠️ **Replaces existing `<api-slug>`** — <reason from user>">

<description from manifest, or "No description available">

**API:** <api_name> | **Category:** <category> | **Press version:** <printing_press_version>
**Spec:** <spec_url or "Not specified">

### Publication Path

<New print | Update existing PR #N | Reprint/replace | Alongside print from <original-api-slug>>

### CLI Shape

\`\`\`bash
$ <cli-name> --help
<help_output from validation>
\`\`\`

### Novel Commands

| Command | Name | Description |
|---------|------|-------------|
| `<command>` | <name> | <description> |

### What This CLI Does

<First 2-3 paragraphs from README.md in the CLI directory, or "README not found">

### Manuscripts



- [<label>](https://github.com/mvanhorn/printing-press-library/blob/<HEAD_SHA>/library/<category>/<api-slug>/.manuscripts/<run-id>/research/<filename>)
- [<label>](https://github.com/mvanhorn/printing-press-library/blob/<HEAD_SHA>/library/<category>/<api-slug>/.manuscripts/<run-id>/research/<filename>)
- … (one bullet for each remaining file in `.manuscripts/<run-id>/research/`)
- [<label>](https://github.com/mvanhorn/printing-press-library/blob/<HEAD_SHA>/library/<category>/<api-slug>/.manuscripts/<run-id>/proofs/<filename>)
- [<label>](https://github.com/mvanhorn/printing-press-library/blob/<HEAD_SHA>/library/<category>/<api-slug>/.manuscripts/<run-id>/proofs/<filename>)
- … (one bullet for each remaining file in `.manuscripts/<run-id>/proofs/`)

### Validation Results

| Check | Result |
|-------|--------|
| Manifest | PASS/FAIL |
| Phase 5 | PASS/FAIL |
| go mod tidy | PASS/FAIL |
| govulncheck (this CLI only, reachable findings) | PASS/FAIL |
| go vet | PASS/FAIL |
| go build | PASS/FAIL |
| --help | PASS/FAIL |
| --version | PASS/FAIL |
| Manuscripts | PRESENT/MISSING |

### Publish Live Gate

<If Step 4.5 ran dogfood: "Full live dogfood reran at publish time and passed. Proof: `<proof path or manuscript link>`">
<If Step 4.5 was skipped: "Skipped with explicit reason: `<SKIP_LIVE_TEST_REASON>`">

### Gaps

<List any missing manifest fields, or omit this section if everything is present>

如果正在更新现有 PR(已设置 EXISTING_PR_NUMBER):

cd "$PUBLISH_REPO_DIR"
PR_BODY_FILE="$(mktemp)"
# Write the constructed PR body Markdown to "$PR_BODY_FILE".
gh pr edit "$EXISTING_PR_NUMBER" \
  --repo mvanhorn/printing-press-library \
  --body-file "$PR_BODY_FILE"
rm -f "$PR_BODY_FILE"

显示完整 PR URL:“Updated PR: <EXISTING_PR_URL>”(使用完整的 https:// URL,而非简写)。

如果正在创建新 PR:

cd "$PUBLISH_REPO_DIR"

# Read access mode from config
ACCESS=$(jq -r .access "$PUBLISH_CONFIG")
GH_USER=$(jq -r .gh_user "$PUBLISH_CONFIG")

if [ "$ACCESS" = "fork" ]; then
  PR_HEAD_REF="$GH_USER:feat/<api-slug>"
else
  PR_HEAD_REF="feat/<api-slug>"
fi

PR_BODY_FILE="$(mktemp)"
# Write the constructed PR body Markdown to "$PR_BODY_FILE".

gh pr create \
  --repo mvanhorn/printing-press-library \
  --head "$PR_HEAD_REF" \
  --base main \
  --title "feat(<api-slug>): add <api-slug>" \
  --body-file "$PR_BODY_FILE"

rm -f "$PR_BODY_FILE"

显示完整 PR URL(例如 https://github.com/mvanhorn/printing-press-library/pull/10),不要使用 org/repo#N 格式的简写。完整 URL 可在所有终端和上下文中点击。

PR 开启后

PR 开启后,就会进入公共库仓库的审查约定。该约定由 mvanhorn/printing-press-library AGENTS.md → "Automated code review with Greptile" 负责维护;请阅读其中的规范版本。从 cli-printing-press 调用此技能的 Agent 不会加载该库的 AGENTS.md,因此这里概述了相关要求。

Greptile 会增量审查:每次推送提交都会触发新一轮审查,可能会发现上一轮没有的问题。这是一个循环,而不是一次性检查——要让 PR 达到稳定通过状态,不能在第一轮后就宣布完成。

推动 PR 达到稳定通过状态

持续迭代,直到以下条件全部满足,并由最近一次修复提交触发的审查予以确认:

  • 解决所有 Greptile 发现的问题。 0–5 分是置信度信号,不是门槛——标准是解决每一项问题。所有问题都解决后,4/5 即可;如果还有未解决的 P1,即使是 5/5 也不行。对于每条 P0/P1/P2 讨论串,都要推送修复,或给出不应触发该问题的具体理由——不能只说“暂不修复”,而要说明为什么当前代码写法正确,或为什么延期处理是合理的。策略门槛还要求当前头部 SHA 的最新得分 ≥ 4/5,因此最终头部必须达到此阈值。
  • 所有 CI 检查均通过。 包括 verify-library-conventions、Govulncheck 以及 PR 上的其他工作流。

请从两个互不重叠的界面查看发现的问题:

  • gh pr view <PR> --repo <owner>/<repo> --comments 会返回顶层议题对话(Greptile 的摘要评论、评分、CI 机器人评论)。
  • gh api repos/<owner>/<repo>/pulls/<PR>/comments 会返回锚定在差异行上的审查评论——Greptile 会在这里发布每条 P0/P1/P2 发现的问题,这些内容不会包含在 --comments 中。如果跳过此调用,Agent 就可能在所有行内讨论串仍未解决时,悄然宣布“所有问题都已解决”。

监控是运行框架的职责,不要手动编写忙循环。 使用环境提供的任何 PR 活动监控方式——收到审查/CI 事件时及时响应;如果环境不会推送事件,则按间隔重新检查。每次推送修复后,都要等待重新触发的审查完成,再判断是否结束;新一轮审查可能会重新打开门槛。

不要为了满足审查发现的问题而手动编辑 registry.json 或 cli-skills/pp-<api-slug>/SKILL.md——这两个文件会在合并后由机器人通过 [skip ci] 提交重新生成,而库中的 Fail on changes to generated artifacts 检查会预先拒绝任何修改这两个文件的 PR。

终端状态——然后交还控制权

PR 稳定通过后,这项技能的工作就完成了。不要合并,也不要轮询等待合并——合并到公共库需要由维护者手动审核,这不属于这项技能的职责,(对于分支仓库的贡献者来说)也不属于用户的职责。

从 $PUBLISH_CONFIG 读取 access(jq -r .access "$PUBLISH_CONFIG"),确定接下来要做什么:

  • 如果 access 是 push(有推送权限的维护者/管理员):为 PR 添加 awaiting-maintainer 标签,表示 PR 已准备好接受人工审核:
    gh pr edit <PR> --repo mvanhorn/printing-press-library --add-label awaiting-maintainer
    
  • 如果 access 是 fork(社区贡献者):你无法合并上游 PR,也无法为其添加标签。PR 通过后,无需再做其他操作。

然后**报告终端状态并将控制权交还给调用方。**默认情况下,不要通过这项技能提供复盘或后续选项——是否这样做由调用 publish 的一方决定。printing-press 流水线会在发布后提供复盘环节;如果是用户直接调用且没有使用 --from-polish,流程到此结束。

如果 POLISH_HANDOFF=true,请在 PR 通过后将复盘作为可选的后续步骤提供。这样既保留了独立的 polish -> publish 工作流,也不会让 polish 在同一轮中的 AskUserQuestion 回答创建或更新公共库 PR。

通过 AskUserQuestion 提示:

"PR 已创建:<PR_URL>。要进行复盘吗?这会将本次会话中发现的系统性问题(生成器遗漏、评分器错误、技能文档偏差)整理成 GitHub issue,提交给 Printing Press 维护者。每次复盘都能为下一个 CLI 项目提高质量,而且你现在最清楚本次会话的上下文。"

  1. 不用,我完成了(默认)
  2. 是,现在进行复盘

如果用户选择是,请调用 /printing-press-retro。

密钥与个人身份信息保护

创建 PR 之前,请确认打包后的 CLI 中没有泄露任何密钥。

**这一点非常重要,因为该库是公开的。**如果 PR 中泄露了 API 密钥,就会构成安全事件——任何人都能看到,即使之后关闭了 PR 也一样。

Printing Press 会检查的内容(确定性检查)

如果用户提供了 API 密钥,生成技能(/printing-press)会在阶段 5.6 进行精确值扫描。运行 publish 时,Printing Press 自身的疏漏理应已被发现。但用户可能在生成与发布之间编辑过文件。

publish 会检查的内容
  1. 强制二进制扫描:cli-printing-press publish package 会扫描暂存的 CLI 和手稿,查找看起来有效的供应商前缀令牌(sk-or-v1-*、sk_live_*、ghp_*、ghs_*、xoxb-*、AKIA* 等)。如果扫描因检测到 vendor-prefix tokens detected 而失败,请将该软件包视为不可发布。在移除或遮盖报告的文件和行号中发现的问题之前,不要复制、提交、推送或创建 PR。

  2. 如果已知用户的确切 API 密钥值,请在创建 PR 前扫描打包目录。此举可以发现 /printing-press 阶段 5.6 之后的编辑或新增手稿:

    if [ -n "$API_KEY_VALUE" ] && [ ${#API_KEY_VALUE} -ge 16 ]; then
      if grep -rF "$API_KEY_VALUE" "$PUBLISH_REPO_DIR/library/<category>/<api-slug>" 2>/dev/null; then
        echo "BLOCKING: API key value found in staged publish tree."
        exit 1
      fi
    fi
    
  3. 如果已安装 gitleaks 或 trufflehog,请在暂存目录上运行,作为补充检查:

    if command -v gitleaks >/dev/null 2>&1; then
      gitleaks detect --source "<staging-dir>/library" --no-git --verbose 2>&1
    elif command -v trufflehog >/dev/null 2>&1; then
      trufflehog filesystem "<staging-dir>/library" 2>&1
    fi
    

    这些工具使用特定供应商的模式(Steam 密钥、Stripe 密钥、GitHub 令牌),误报率较低。它们的发现可以补充强制检查所覆盖的范围。继续之前,请先审查所有发现。

  4. 始终执行轻量级结构检查:

    • 确认暂存目录中没有包含真实凭据的 .env 文件、session-state.json 或 config.toml
    • 检查 README 示例是否使用 "your-key-here" 占位符,而非真实值
    • 检查手稿(如有包含)是否含有身份验证标头或 Cookie 值
  5. 暂存目录中绝不能包含:

    • .env 文件
    • session-state.json
    • 含有真实凭据的配置文件
    • 未清除身份验证标头的 HAR 捕获文件

如果强制二进制扫描或精确值扫描发现问题,请停止。对于外部工具或轻量级结构检查发现的问题,请警告用户并询问是否继续。对于这些非强制检查发现的问题,最终决定权在用户手中。

PII 模式扫描(强制)

除上述机密扫描外,还要运行 printing-press 技能中 references/secret-protection.md 的 PII 模式扫描 步骤。该步骤包含 Tier 1 模式集和扫描循环。它可以捕获实时内部试用期间收集到、但文字指导中未提及的 PII——电子邮件地址、真实参会者姓名、账户 标识符——在这些内容发布到公共库仓库之前将其拦截。

扫描分为两个层级:

  • Tier 1(自动静默脱敏): 以供应商前缀为锚点的承载令牌 (Bearer cal_live_*、Bearer sk_live_*、Bearer ghp_*、xoxp-* 等)。 误报率几乎为零。
  • Tier 2(警告,并批量提示用户): 通用电子邮件地址、通用承载令牌、 首字母大写的名+姓模式。允许列表会自动抑制源自规格的 API 词汇(“Event Types”“Booking Links”)。

暂存目录的脱敏前副本会保存在 <staging>.pre-pii-scrub/,以便用户在误脱敏时恢复内容。

在此扫描功能出现之前,已有两次 PII 泄漏被发布到公共库。 扫描是文字指导本身无法提供的机械防护层。

错误处理

  • gh 未通过身份验证: 在步骤 1 中检测到后,告知用户运行 gh auth login
  • 未找到 CLI: 在步骤 2 中显示可用的 CLI,让用户选择
  • 验证失败: 在步骤 4 中显示各项检查结果,然后停止
  • 无法访问仓库: 在步骤 5 中清楚报告
  • 创建 Fork 失败: 如果用户已有不同名称的 Fork,或组织限制了 Fork,gh repo fork 可能会失败。报告错误,并建议用户通过 GitHub 网页界面手动创建 Fork。
  • 冲突检查失败: 如果 gh pr list 或 ls 命令失败(网络、身份验证),发出警告但不阻止操作——按不存在冲突继续
  • 重命名失败: 显示 publish rename --json 返回的错误。提供使用不同限定符重试或退出的选项。如果发布仓库处于部分完成状态,在重试前运行 git checkout -- . && git clean -fd 重置
  • 分支冲突(不存在现有 PR): 在步骤 8 中询问用户(覆盖或使用时间戳)
  • 推送失败: 对于 Fork 用户,确保他们推送到自己的 Fork(origin),而不是上游仓库。报告错误,并建议检查 gh auth status 和 git remote -v
  • 跨仓库 PR 创建失败: 如果 gh pr create --head user:branch 因 “head not found” 而失败,说明分支尚未推送到 Fork。使用 git ls-remote origin feat/<api-slug> 验证

评论

支持 Markdown