Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47bd454fa3 | |||
| e7a139e2cb | |||
| e4d4319919 |
@@ -66,7 +66,7 @@ flowchart TD
|
|||||||
|
|
||||||
B --> B1["skills/ack"]
|
B --> B1["skills/ack"]
|
||||||
B --> B2["skills/skiff"]
|
B --> B2["skills/skiff"]
|
||||||
B --> B3["skills/deb-publisher"]
|
B --> B3["skills/builder"]
|
||||||
|
|
||||||
C --> C1["Git 或本地目录"]
|
C --> C1["Git 或本地目录"]
|
||||||
C1 --> C2["单 Skill"]
|
C1 --> C2["单 Skill"]
|
||||||
@@ -132,7 +132,7 @@ layout: single | collection
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
[ ] ack builtin
|
[ ] ack builtin
|
||||||
[ ] deb-publisher builtin
|
[ ] builder builtin
|
||||||
[-] company custom source
|
[-] company custom source
|
||||||
[ ] company/release custom:company
|
[ ] company/release custom:company
|
||||||
[ ] company/security-review custom:company
|
[ ] company/security-review custom:company
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ docs/ack/
|
|||||||
目录读取。
|
目录读取。
|
||||||
|
|
||||||
ACK 从当前命令指定的 `--project-root/docs/ack/` 定位项目状态,不要求在
|
ACK 从当前命令指定的 `--project-root/docs/ack/` 定位项目状态,不要求在
|
||||||
`tasks.yaml` 中持久化 `repoPath` 或 `devWorktree`。自动 worker 的实际工作目录仍由
|
`tasks.yaml` 中持久化 `repoPath` 或 `devWorktree`。自动 worker 的实际工作目录由
|
||||||
`--worktree` 指定,并且必须命中绝对路径白名单 `allowedWorktrees`。
|
`--worktree` 指定;默认在 `--project-root` 工作,v0.19 起不再配置 `allowedWorktrees` 白名单。
|
||||||
|
|
||||||
## Skill 结构
|
## Skill 结构
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -58,8 +58,9 @@ description: >-
|
|||||||
6. 完善 `docs/ack/tasks.yaml` 的项目信息。纯初始化且用户没有提供真实任务时,
|
6. 完善 `docs/ack/tasks.yaml` 的项目信息。纯初始化且用户没有提供真实任务时,
|
||||||
删除模板示例任务并保留 `tasks: []`;不要虚构需求或缺陷。
|
删除模板示例任务并保留 `tasks: []`;不要虚构需求或缺陷。
|
||||||
项目状态固定从当前项目根的 `docs/ack/` 推导,不写入 `repoPath` 或 `devWorktree`;
|
项目状态固定从当前项目根的 `docs/ack/` 推导,不写入 `repoPath` 或 `devWorktree`;
|
||||||
worker 的绝对路径只保存在 `project.orchestration.allowedWorktrees` 并在派发时通过
|
worker 默认在 `--project-root`(权威状态目录)工作,不再配置
|
||||||
`--worktree` 显式选择。旧任务板中的 `repoPath`、`devWorktree` 仅兼容读取。
|
`allowedWorktrees` 白名单(v0.19 起废弃);需要隔离 worktree 时由 Coordinator 在
|
||||||
|
派发时显式指定。旧任务板中的 `repoPath`、`devWorktree` 仅兼容读取。
|
||||||
7. 检查 `docs/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留
|
7. 检查 `docs/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留
|
||||||
`verificationRegistry: {}` 与 `entries: []`,不从聊天、README 或单次失败中
|
`verificationRegistry: {}` 与 `entries: []`,不从聊天、README 或单次失败中
|
||||||
猜测并激活知识。
|
猜测并激活知识。
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ project:
|
|||||||
orchestration:
|
orchestration:
|
||||||
profileVersion: 1
|
profileVersion: 1
|
||||||
mode: "manual"
|
mode: "manual"
|
||||||
allowedWorktrees:
|
# allowedWorktrees 已废弃(v0.19 起);worker 默认在 --project-root 工作。
|
||||||
- "/home/dev/notes-web-wt/fix-preview"
|
|
||||||
modelAllowlist:
|
modelAllowlist:
|
||||||
codex:
|
codex:
|
||||||
developer:
|
developer:
|
||||||
|
|||||||
@@ -23,10 +23,10 @@
|
|||||||
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
|
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
|
||||||
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
|
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
|
||||||
allowlist,默认 profile 与角色/档位一致。
|
allowlist,默认 profile 与角色/档位一致。
|
||||||
- [ ] `allowedWorktrees` 只列出同一 Git common-dir 下已经核对的绝对 worktree。
|
- [ ] `allowedWorktrees` 已废弃(v0.19 起),新任务板不配置;worker 默认在
|
||||||
|
`--project-root` 工作,其它 worktree 由 launcher 按同 git 仓库且已注册约束放行。
|
||||||
- [ ] `tasks.yaml` 不需要保存 `repoPath` 或 `devWorktree`;项目状态从当前
|
- [ ] `tasks.yaml` 不需要保存 `repoPath` 或 `devWorktree`;项目状态从当前
|
||||||
`--project-root/docs/ack/` 推导,worker 路径由 `--worktree` 与
|
`--project-root/docs/ack/` 推导,worker 路径由 `--worktree` 显式指定。
|
||||||
`allowedWorktrees` 共同约束。
|
|
||||||
|
|
||||||
## 路径权限
|
## 路径权限
|
||||||
|
|
||||||
|
|||||||
@@ -141,9 +141,9 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
|||||||
|
|
||||||
**方式 2:新建隔离 worktree,再在其中起子 agent**
|
**方式 2:新建隔离 worktree,再在其中起子 agent**
|
||||||
|
|
||||||
创建成功后,先把 Orca 返回的绝对路径加入权威任务板的
|
创建成功后,确认新 worktree 与权威项目根属于同一 Git 仓库(launcher 按
|
||||||
`project.orchestration.allowedWorktrees` 并重新运行 `validate_tasks.py`;未进入
|
`git worktree list` 注册表 + 同 common-dir 约束校验,v0.19 起不再需要
|
||||||
allowlist 的路径不能交给 launcher。
|
`allowedWorktrees` 白名单登记)。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
orca worktree create --name <feature> --base-branch <base> --json
|
orca worktree create --name <feature> --base-branch <base> --json
|
||||||
|
|||||||
@@ -111,9 +111,9 @@ intents:
|
|||||||
- `build`:调用 artifact 的 build entrypoint。DEB 必须记录包名、版本、架构和
|
- `build`:调用 artifact 的 build entrypoint。DEB 必须记录包名、版本、架构和
|
||||||
SHA-256;OCI image 必须记录完整引用、platform 和 digest。产物必须绑定当前 source
|
SHA-256;OCI image 必须记录完整引用、platform 和 digest。产物必须绑定当前 source
|
||||||
revision,不能在目标机器重新拉源码构建。
|
revision,不能在目标机器重新拉源码构建。
|
||||||
- `publish`:验证 artifact/destination 类型兼容,上传精确产物。DEB 可使用已安装的
|
- `publish`:验证 artifact/destination 类型兼容,上传精确产物。DEB 与 Docker 均可使用
|
||||||
`deb-publisher`;Docker 只有在用户明确指定 `$publish-docker-image` 时才加载该
|
已安装的 `builder`;Docker 轨道保持显式触发——只有用户明确点名(builder / 发布镜像)
|
||||||
explicit-only skill,否则必须走契约中已审查的 upload entrypoint。项目入口只接受
|
时才加载,否则必须走契约中已审查的 upload entrypoint。项目入口只接受
|
||||||
刚校验的精确 artifact。preview/staging 使用不可覆盖的 commit/PR 标识,不隐式使用
|
刚校验的精确 artifact。preview/staging 使用不可覆盖的 commit/PR 标识,不隐式使用
|
||||||
`latest`。既没有可用 skill 也没有 upload 入口时标记 blocked。
|
`latest`。既没有可用 skill 也没有 upload 入口时标记 blocked。
|
||||||
- `deploy`:把同一不可变 artifact 交给 environment 的 deploy entrypoint;获取目标
|
- `deploy`:把同一不可变 artifact 交给 environment 的 deploy entrypoint;获取目标
|
||||||
@@ -147,7 +147,7 @@ ID 指向不同 commit、digest 或目标时停止,不覆盖或另建伪装成
|
|||||||
## 7. 与低层 Skill 的边界
|
## 7. 与低层 Skill 的边界
|
||||||
|
|
||||||
ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总证据,不复制低层 skill 的
|
ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总证据,不复制低层 skill 的
|
||||||
上传、镜像或 Git 发布实现。`deb-publisher`、`publish-docker-image` 和
|
上传、镜像或 Git 发布实现。`builder` 和
|
||||||
`manage-release` 仍是可独立使用、独立安装的能力;缺失时 ACK 使用契约中已审查的
|
`manage-release` 仍是可独立使用、独立安装的能力;缺失时 ACK 使用契约中已审查的
|
||||||
项目 entrypoint,二者都不可用时把对应步骤标为 `blocked`。低层 skill 自身要求显式
|
项目 entrypoint,二者都不可用时把对应步骤标为 `blocked`。低层 skill 自身要求显式
|
||||||
调用时,ACK 不能绕过它的触发与授权边界。
|
调用时,ACK 不能绕过它的触发与授权边界。
|
||||||
|
|||||||
@@ -89,9 +89,9 @@ docs/ack/
|
|||||||
不再参与路径绑定。
|
不再参与路径绑定。
|
||||||
- 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,并保留顶层
|
- 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,并保留顶层
|
||||||
`deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。
|
`deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。
|
||||||
- `project.orchestration.allowedWorktrees` 使用已核对的绝对 worker worktree;模型
|
- `allowedWorktrees` 已废弃(v0.19 起),新任务板不生成该字段;worker 默认在
|
||||||
allowlist、profiles 和 defaults 使用项目实际允许值。不要把完整启动命令、
|
`--project-root` 工作。模型 allowlist、profiles 和 defaults 使用项目实际允许值。
|
||||||
`extraArgs`、`env` 或任意 executable 写进任务板。
|
不要把完整启动命令、`extraArgs`、`env` 或任意 executable 写进任务板。
|
||||||
- 非服务项目的 `baseUrl` 写为 `n/a`。
|
- 非服务项目的 `baseUrl` 写为 `n/a`。
|
||||||
- 没有真实任务时使用 `tasks: []`,不要保留或虚构示例任务。
|
- 没有真实任务时使用 `tasks: []`,不要保留或虚构示例任务。
|
||||||
|
|
||||||
|
|||||||
@@ -141,8 +141,8 @@ receipt 仅用于审计,不能在后续轮次单独授权复用。不要自行
|
|||||||
新 worktree 时可先运行
|
新 worktree 时可先运行
|
||||||
`orca worktree create --name <feature> --base-branch <base> --json`,再对返回的绝对
|
`orca worktree create --name <feature> --base-branch <base> --json`,再对返回的绝对
|
||||||
worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调用 launcher
|
worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调用 launcher
|
||||||
前,先把新
|
前,确认新 worktree 与权威项目根同一 Git 仓库即可(v0.19 起不再需要登记
|
||||||
路径加入 `project.orchestration.allowedWorktrees` 并重新运行任务板校验。profile
|
`allowedWorktrees`)。profile
|
||||||
只允许 `read-only` 或 `workspace-write`;v0.10 的 full-access 授权通道尚未实现,
|
只允许 `read-only` 或 `workspace-write`;v0.10 的 full-access 授权通道尚未实现,
|
||||||
任何 bypass、YOLO/force 或关闭 sandbox 的请求都必须失败,不能手写命令兜底。
|
任何 bypass、YOLO/force 或关闭 sandbox 的请求都必须失败,不能手写命令兜底。
|
||||||
选型与升级见 `model-routing.md`。
|
选型与升级见 `model-routing.md`。
|
||||||
|
|||||||
@@ -79,9 +79,10 @@ worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的
|
|||||||
- `reasoningEffort`:CLI 支持时显式声明;
|
- `reasoningEffort`:CLI 支持时显式声明;
|
||||||
- `permissionMode`:只允许 `read-only` 或 `workspace-write`。
|
- `permissionMode`:只允许 `read-only` 或 `workspace-write`。
|
||||||
|
|
||||||
`project.orchestration` 还必须声明 `allowedWorktrees`、按 CLI/角色/档位分组的
|
`project.orchestration` 必须声明按 CLI/角色/档位分组的 `modelAllowlist`、命名
|
||||||
`modelAllowlist`、命名 `profiles` 和角色 `defaults`。launcher 请求的绝对 worktree
|
`profiles` 和角色 `defaults`(`allowedWorktrees` 自 v0.19 起废弃,不再配置)。
|
||||||
必须命中 allowlist,profile 的模型也必须命中对应 CLI/角色/档位的精确列表。
|
launcher 请求的 worktree 必须是同 Git 仓库的已注册 worktree,profile 的模型也必须
|
||||||
|
命中对应 CLI/角色/档位的精确列表。
|
||||||
|
|
||||||
模型名称、reasoning effort 和权限模式必须来自结构化字段。禁止在 profile、项目
|
模型名称、reasoning effort 和权限模式必须来自结构化字段。禁止在 profile、项目
|
||||||
覆盖层或派发内容中保存自由 `command`、额外 argv、shell 片段、环境变量覆盖或隐式
|
覆盖层或派发内容中保存自由 `command`、额外 argv、shell 片段、环境变量覆盖或隐式
|
||||||
@@ -135,8 +136,9 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
|||||||
```
|
```
|
||||||
|
|
||||||
`--project-root` 始终指向保存权威 `docs/ack/tasks.yaml` 的项目根;`--worktree` 是本次
|
`--project-root` 始终指向保存权威 `docs/ack/tasks.yaml` 的项目根;`--worktree` 是本次
|
||||||
worker 实际工作的绝对路径,两者可以不同。后者仍必须属于同一项目并命中
|
worker 实际工作的绝对路径,两者可以不同。后者仍必须与项目根属于同一 Git
|
||||||
`project.orchestration.allowedWorktrees`。
|
仓库(v0.19 起由 launcher 按 `git worktree list` 注册表 + 同 common-dir 校验,
|
||||||
|
不再依赖 `allowedWorktrees` 白名单)。
|
||||||
|
|
||||||
项目状态文件固定从 `--project-root/docs/ack/` 解析;任务板不需要保存 `repoPath` 或
|
项目状态文件固定从 `--project-root/docs/ack/` 解析;任务板不需要保存 `repoPath` 或
|
||||||
`devWorktree`。旧任务板中的这两个字段仅作兼容信息,launcher 不使用它们授权或定位。
|
`devWorktree`。旧任务板中的这两个字段仅作兼容信息,launcher 不使用它们授权或定位。
|
||||||
|
|||||||
@@ -108,9 +108,9 @@ profile 只允许 `read-only` 或 `workspace-write`。v0.10 不提供可信的 f
|
|||||||
launcher fail closed,不能改用手写命令兜底。模型档位与升级规则见
|
launcher fail closed,不能改用手写命令兜底。模型档位与升级规则见
|
||||||
`model-routing.md`。
|
`model-routing.md`。
|
||||||
|
|
||||||
需要隔离/并行时,可以先用 Orca 创建 worktree。Coordinator 必须先把返回的绝对
|
需要隔离/并行时,可以先用 Orca 创建 worktree(须与项目根同一 Git 仓库)。
|
||||||
路径加入 `project.orchestration.allowedWorktrees` 并重新校验 `tasks.yaml`,然后才在
|
v0.19 起不再登记 `allowedWorktrees`,直接在该路径上走 `plan` -> 带 expected
|
||||||
该路径上走 `plan` -> 带 expected fingerprint 的 `launch`,并把 receipt 留作审计。
|
fingerprint 的 `launch`,并把 receipt 留作审计。
|
||||||
既有会话可以由用户直接操作,但不能进入 ACK v0.10 的自动派发信任路径。
|
既有会话可以由用户直接操作,但不能进入 ACK v0.10 的自动派发信任路径。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -557,20 +557,13 @@ def assert_git_control_entry(worktree: Path) -> None:
|
|||||||
def capture_worktree_identity(
|
def capture_worktree_identity(
|
||||||
project_root: Path,
|
project_root: Path,
|
||||||
worktree_value: str,
|
worktree_value: str,
|
||||||
allowed_worktrees: object,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if not isinstance(allowed_worktrees, list):
|
# allowedWorktrees 白名单已废弃(v0.19 起)。worktree 合法性由以下硬约束保证:
|
||||||
raise LaunchError("project.orchestration.allowedWorktrees 必须是列表")
|
# 1) 必须是 Git worktree 根目录(不是任意子目录);
|
||||||
|
# 2) 必须出现在 `git worktree list` 注册表中;
|
||||||
|
# 3) 必须与 --project-root 属于同一 Git common-dir(同仓库)。
|
||||||
|
# worker 默认就在 --project-root(权威状态目录)工作,天然满足这些约束。
|
||||||
worktree = canonical_directory(worktree_value, "worker worktree")
|
worktree = canonical_directory(worktree_value, "worker worktree")
|
||||||
configured_paths: list[Path] = []
|
|
||||||
for index, configured in enumerate(allowed_worktrees):
|
|
||||||
if not isinstance(configured, str):
|
|
||||||
raise LaunchError(f"allowedWorktrees[{index}] 必须是字符串")
|
|
||||||
configured_paths.append(
|
|
||||||
canonical_directory(configured, f"allowedWorktrees[{index}]")
|
|
||||||
)
|
|
||||||
if worktree not in configured_paths:
|
|
||||||
raise LaunchError(f"worker worktree 不在 allowedWorktrees 中: {worktree}")
|
|
||||||
|
|
||||||
git = resolve_executable("git")
|
git = resolve_executable("git")
|
||||||
assert_git_control_entry(project_root)
|
assert_git_control_entry(project_root)
|
||||||
@@ -699,7 +692,6 @@ def build_plan(
|
|||||||
worktree = capture_worktree_identity(
|
worktree = capture_worktree_identity(
|
||||||
project_root,
|
project_root,
|
||||||
worktree_value,
|
worktree_value,
|
||||||
orchestration.get("allowedWorktrees"),
|
|
||||||
)
|
)
|
||||||
executable = resolve_executable(str(profile["cli"]))
|
executable = resolve_executable(str(profile["cli"]))
|
||||||
executable_stat = executable.stat()
|
executable_stat = executable.stat()
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ def executable_basename_matches_cli(executable: str, cli: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
return cli == "grok" and GROK_EXECUTABLE_NAME_RE.fullmatch(name) is not None
|
return cli == "grok" and GROK_EXECUTABLE_NAME_RE.fullmatch(name) is not None
|
||||||
|
|
||||||
|
# allowedWorktrees 已废弃(v0.19 起):新任务板不再配置,旧任务板遗留该字段仍被容忍。
|
||||||
|
# ORCHESTRATION_FIELDS 是“允许出现”的字段全集(含废弃字段),
|
||||||
|
# REQUIRED_ORCHESTRATION_FIELDS 是“必须存在”的子集(不含废弃字段)。
|
||||||
ORCHESTRATION_FIELDS = frozenset(
|
ORCHESTRATION_FIELDS = frozenset(
|
||||||
{
|
{
|
||||||
"profileVersion",
|
"profileVersion",
|
||||||
@@ -56,6 +59,15 @@ ORCHESTRATION_FIELDS = frozenset(
|
|||||||
"defaults",
|
"defaults",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
REQUIRED_ORCHESTRATION_FIELDS = frozenset(
|
||||||
|
{
|
||||||
|
"profileVersion",
|
||||||
|
"mode",
|
||||||
|
"modelAllowlist",
|
||||||
|
"profiles",
|
||||||
|
"defaults",
|
||||||
|
}
|
||||||
|
)
|
||||||
PROFILE_FIELDS = frozenset(
|
PROFILE_FIELDS = frozenset(
|
||||||
{
|
{
|
||||||
"role",
|
"role",
|
||||||
@@ -341,11 +353,10 @@ def validate_orchestration(
|
|||||||
return [f"{where}: must be an object"]
|
return [f"{where}: must be an object"]
|
||||||
|
|
||||||
errors = _unknown_fields(orchestration, ORCHESTRATION_FIELDS, where)
|
errors = _unknown_fields(orchestration, ORCHESTRATION_FIELDS, where)
|
||||||
errors.extend(_missing_fields(orchestration, ORCHESTRATION_FIELDS, where))
|
errors.extend(_missing_fields(orchestration, REQUIRED_ORCHESTRATION_FIELDS, where))
|
||||||
|
|
||||||
profile_version = orchestration.get("profileVersion")
|
profile_version = orchestration.get("profileVersion")
|
||||||
mode = orchestration.get("mode")
|
mode = orchestration.get("mode")
|
||||||
allowed_worktrees = orchestration.get("allowedWorktrees")
|
|
||||||
allowlist = orchestration.get("modelAllowlist")
|
allowlist = orchestration.get("modelAllowlist")
|
||||||
profiles = orchestration.get("profiles")
|
profiles = orchestration.get("profiles")
|
||||||
defaults = orchestration.get("defaults")
|
defaults = orchestration.get("defaults")
|
||||||
@@ -355,20 +366,9 @@ def validate_orchestration(
|
|||||||
if not isinstance(mode, str) or mode not in ORCHESTRATION_MODES:
|
if not isinstance(mode, str) or mode not in ORCHESTRATION_MODES:
|
||||||
errors.append(f"{where}.mode: must be orca/manual")
|
errors.append(f"{where}.mode: must be orca/manual")
|
||||||
|
|
||||||
if not isinstance(allowed_worktrees, list):
|
# allowedWorktrees 已废弃:worker 默认在 --project-root 工作,
|
||||||
errors.append(f"{where}.allowedWorktrees: must be a list")
|
# 其它 worktree 由 launcher 按“同 git 仓库且已注册”约束(capture_worktree_identity)。
|
||||||
else:
|
# 旧任务板中遗留的该字段被容忍,不再校验。
|
||||||
if mode == "orca" and not allowed_worktrees:
|
|
||||||
errors.append(f"{where}.allowedWorktrees: Orca mode requires at least one path")
|
|
||||||
seen_worktrees: set[str] = set()
|
|
||||||
for index, worktree in enumerate(allowed_worktrees):
|
|
||||||
item_where = f"{where}.allowedWorktrees[{index}]"
|
|
||||||
if not _is_absolute_safe_path(worktree):
|
|
||||||
errors.append(f"{item_where}: must be a safe absolute path other than root")
|
|
||||||
elif worktree in seen_worktrees:
|
|
||||||
errors.append(f"{item_where}: duplicate worktree {worktree!r}")
|
|
||||||
else:
|
|
||||||
seen_worktrees.add(worktree)
|
|
||||||
|
|
||||||
errors.extend(_validate_model_allowlist(allowlist, f"{where}.modelAllowlist"))
|
errors.extend(_validate_model_allowlist(allowlist, f"{where}.modelAllowlist"))
|
||||||
|
|
||||||
@@ -757,11 +757,6 @@ def validate_worker_receipt(
|
|||||||
profile = candidate
|
profile = candidate
|
||||||
else:
|
else:
|
||||||
errors.append(f"{where}.profileId: unknown profile {profile_id!r}")
|
errors.append(f"{where}.profileId: unknown profile {profile_id!r}")
|
||||||
allowed = orchestration.get("allowedWorktrees")
|
|
||||||
if isinstance(worktree, dict) and isinstance(allowed, list):
|
|
||||||
if worktree.get("path") not in allowed:
|
|
||||||
errors.append(f"{where}.worktree.path: is not in allowedWorktrees")
|
|
||||||
|
|
||||||
if profile is not None:
|
if profile is not None:
|
||||||
try:
|
try:
|
||||||
expected_profile_hash = profile_hash(
|
expected_profile_hash = profile_hash(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -28,10 +28,10 @@ project:
|
|||||||
# attachments: "<field_id>"
|
# attachments: "<field_id>"
|
||||||
# updatedAt: "<field_id>"
|
# updatedAt: "<field_id>"
|
||||||
orchestration:
|
orchestration:
|
||||||
|
# worker 默认在 --project-root(权威状态目录)工作;不再配置 allowedWorktrees 白名单
|
||||||
|
# (v0.19 起废弃)。需要隔离 worktree 时由 Coordinator 在派发时显式指定。
|
||||||
profileVersion: 1
|
profileVersion: 1
|
||||||
mode: "orca"
|
mode: "orca"
|
||||||
allowedWorktrees:
|
|
||||||
- "<dev_worktree>"
|
|
||||||
modelAllowlist:
|
modelAllowlist:
|
||||||
codex:
|
codex:
|
||||||
developer:
|
developer:
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# builder
|
||||||
|
|
||||||
|
按统一契约完成项目的 DEB 包与 Docker 镜像构建和发布。规范本体见
|
||||||
|
[references/contract.md](references/contract.md),`scripts/check.py` 是契约的
|
||||||
|
可执行校验器。
|
||||||
|
|
||||||
|
## 什么时候使用
|
||||||
|
|
||||||
|
- "帮我构建这个项目的 DEB / Docker 镜像"
|
||||||
|
- "把 1.2.3 发布到包仓库 / 镜像仓库"
|
||||||
|
- "检查这个项目的 Makefile 是否符合 builder 契约"
|
||||||
|
- "看看项目现在的发布流程"
|
||||||
|
|
||||||
|
只构建不上传时明确说明即可;上传永远需要你显式授权。
|
||||||
|
|
||||||
|
## 项目接入契约
|
||||||
|
|
||||||
|
1. 用 create-makefile skill 生成或修正 Makefile(目标 `help/build/clean/version`
|
||||||
|
+ 条件 `deb/docker/push*`,变量 `ARCH/VERSION/DIST_DIR/PROJECT_NAME`)。
|
||||||
|
2. 运行 `python3 -I -S <builder>/scripts/check.py .` 直到全部 PASS。
|
||||||
|
3. 在项目根 `.env` 配置发布环境变量:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DEB_SERVER_URL=https://deb.example.com
|
||||||
|
DEB_REPOSITORY=main
|
||||||
|
DEB_TOKEN=<token> # 只放 .env 或密钥系统,不进 git
|
||||||
|
DOCKER_REGISTRY=registry.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 日常发布就是两条命令:`make deb && make push-deb`、`make push-docker`。
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
```text
|
||||||
|
用 builder 检查这个项目的 Makefile 是否符合契约。
|
||||||
|
用 builder 构建当前版本的 DEB 和镜像,先不要上传。
|
||||||
|
用 builder 把 dist/example_1.2.3_amd64.deb 发布到项目已配置的测试仓库。
|
||||||
|
用 builder 发布多平台 linux/amd64,linux/arm64 镜像。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 脚本一览
|
||||||
|
|
||||||
|
| 脚本 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `scripts/check.py` | 校验项目 Makefile 是否符合契约(`--build` 实构核对产物) |
|
||||||
|
| `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name) |
|
||||||
|
| `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
|
||||||
|
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
|
||||||
|
|
||||||
|
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.skills/skills/builder/scripts/`)、
|
||||||
|
脏工作树策略等完整规则见 contract.md。
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
---
|
||||||
|
name: builder
|
||||||
|
description: >-
|
||||||
|
按统一契约构建并发布项目的 DEB 包与 Docker 镜像:先校验项目 Makefile 是否符合
|
||||||
|
builder 契约(check.py),再 make 构建产物,经授权后用 skill 自带脚本上传并验证。
|
||||||
|
触发词:构建 deb、发布 deb、上传 deb、推送 apt 仓库、打 Debian 包、构建镜像、
|
||||||
|
发布镜像、推送 Docker 镜像、make push、检查 Makefile 是否符合规范。仅分析打包
|
||||||
|
逻辑或只构建不上传时也可使用;不会在未获授权时执行任何上传。Docker 轨道保持
|
||||||
|
显式触发:用户点名(builder/publish docker)时才走镜像发布。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Builder:DEB / Docker 构建发布
|
||||||
|
|
||||||
|
复用项目已有发布约定,安全地完成"校验 → 构建 → 检查 → 授权 → 上传 → 验证"。
|
||||||
|
|
||||||
|
分工原则:**make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。**
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
- 用户要求构建、发布、上传 `.deb` 包或 Docker/OCI 镜像。
|
||||||
|
- 用户要求检查项目 Makefile 是否符合 builder 契约。
|
||||||
|
- 用户要求梳理或接通项目现有的 DEB/镜像发布流程。
|
||||||
|
|
||||||
|
不适用:本地安装/卸载 DEB;RPM/APK/语言包管理器;从零设计全新打包体系(先出方案);
|
||||||
|
普通编码与 Dockerfile 编辑。
|
||||||
|
|
||||||
|
## 工作流
|
||||||
|
|
||||||
|
### 0. 校验契约
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -I -S <skill-dir>/scripts/check.py <project-dir> # 静态检查
|
||||||
|
python3 -I -S <skill-dir>/scripts/check.py <project-dir> --build # 额外实构 deb 并核对产物
|
||||||
|
```
|
||||||
|
|
||||||
|
任一 FAIL:停下修复(引导用 create-makefile skill 补齐),不要绕过校验继续发布。
|
||||||
|
完整要求见 [contract.md](references/contract.md)。存量项目未接契约时走第 6 节
|
||||||
|
fallback;成功交付一次后引导用户迁移到契约。
|
||||||
|
|
||||||
|
### 1. 确认发布边界
|
||||||
|
|
||||||
|
上传是外部写操作。仅当用户明确要求发布、上传或提交时执行;只要求查看、诊断或构建
|
||||||
|
则停在相应阶段。
|
||||||
|
|
||||||
|
执行上传前确认:
|
||||||
|
|
||||||
|
- 目标服务和仓库来自项目配置(`.env`)或用户输入,不猜测生产端点。
|
||||||
|
- 认证令牌已通过环境变量或密钥系统提供;绝不写入命令输出、文件、提交或回复,
|
||||||
|
不用 `set -x` 执行含凭据的命令。
|
||||||
|
- 相同版本是否允许覆盖;无法确认且可能覆盖时,先询问。
|
||||||
|
- Docker 轨道需要用户已明确指定目标 registry/repository/tag 后才继续。
|
||||||
|
|
||||||
|
脏工作树默认拒绝发布;用户明确接受时设置 `ALLOW_UNCOMMITTED=1` 并在汇报中注明
|
||||||
|
包含的未提交修改。
|
||||||
|
|
||||||
|
### 2. 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build ARCH=<amd64|arm64> VERSION=<version> # 主产物
|
||||||
|
make deb ARCH=<amd64|arm64> # DEB 项目
|
||||||
|
```
|
||||||
|
|
||||||
|
版本缺省由 make 从 `git describe --tags --always --dirty` 推导。构建目标若会自动
|
||||||
|
上传而当前仅获构建授权,改用纯构建目标。执行前确认所需工具可用(docker、
|
||||||
|
dpkg-deb 等)。不得擅自清理宽泛目录;脚本含 `rm -rf` 时先解析确认为受限构建目录。
|
||||||
|
|
||||||
|
### 3. 上传前检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
find $(DIST_DIR) -maxdepth 2 -type f -name '*.deb' -print
|
||||||
|
<skill-dir>/scripts/verify_deb.sh <exact-package-path.deb> [期望版本] [期望架构]
|
||||||
|
```
|
||||||
|
|
||||||
|
verify_deb.sh 输出元数据、关键内容清单和 SHA-256。匹配到多个包时不凭文件时间猜测,
|
||||||
|
向用户确认唯一产物。镜像轨道无需单独校验步骤(publish_docker.sh 自带远端 inspect)。
|
||||||
|
|
||||||
|
### 4. 发布
|
||||||
|
|
||||||
|
优先 `make push[-deb|-docker]`(契约要求的薄包装);直接调用等价:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEB_SERVER_URL=… DEB_TOKEN=… DEB_REPOSITORY=… \
|
||||||
|
<skill-dir>/scripts/upload_deb.sh <exact-package-path.deb>
|
||||||
|
|
||||||
|
DOCKER_REGISTRY=… \
|
||||||
|
<skill-dir>/scripts/publish_docker.sh # env 优先,flag 可覆盖
|
||||||
|
```
|
||||||
|
|
||||||
|
环境变量缺失时脚本会自动向上查找项目 `.env` 加载(shell 显式值优先)。不把 token
|
||||||
|
作为命令行参数;不把脚本复制进项目。upload_deb.sh 默认请求 `/api/v2/upload/package`
|
||||||
|
(multipart 字段 `package`/`token`/`repository_name`,接受 200/201),协议不符时设
|
||||||
|
`DEB_UPLOAD_PATH` 或改用项目专属逻辑。publish_docker.sh 用 buildx 一步完成构建+推送,
|
||||||
|
多平台只能走它,不能拆进 make。
|
||||||
|
|
||||||
|
### 5. 验证与汇报
|
||||||
|
|
||||||
|
发布成功不能只依据"curl 已执行"/"push 已执行"。综合检查:
|
||||||
|
|
||||||
|
- 上传命令退出码为零,HTTP 状态与响应体明确成功;镜像以 `imagetools inspect`
|
||||||
|
的远端 digest 为准。
|
||||||
|
- 若仓库提供查询/索引/下载地址,确认该版本已可见;索引异步时报告
|
||||||
|
"上传已接受,索引尚待更新",不声称完全可用。
|
||||||
|
|
||||||
|
最终回复给出:包名/镜像引用、版本、架构/platform、产物路径与 SHA-256 或远端 digest、
|
||||||
|
源 commit 与工作区状态、各阶段验证结果、未完成项或覆盖风险。
|
||||||
|
|
||||||
|
## 存量项目 fallback(legacy)
|
||||||
|
|
||||||
|
从项目根目录查找,不预设文件位置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n -i --hidden --glob '!.git' \
|
||||||
|
'build-deb|upload-deb|publish-deb|dpkg-deb|debuild|curl.*deb|\.deb\b|aptly|reprepro'
|
||||||
|
```
|
||||||
|
|
||||||
|
重点检查 Makefile、CI 配置、`debian/`、构建脚本和发布文档中的入口、变量传递方式、
|
||||||
|
端点与认证方式。优先复用已有构建入口;上传仍用 builder 脚本。交付后引导迁移到契约
|
||||||
|
(create-makefile + check.py 通过为准)。
|
||||||
|
|
||||||
|
## 修改 builder 自身时
|
||||||
|
|
||||||
|
- 上传/发布脚本是 SSOT:通用行为修改落在 `skills/builder/scripts/`,不同步复制到
|
||||||
|
业务项目。
|
||||||
|
- 契约变更先改 `scripts/check.py`,再同步 `references/contract.md`。
|
||||||
|
- 可用 `bash -n` 检查脚本语法;有 ShellCheck 时一并运行。
|
||||||
|
- 不通过真实生产上传测试脚本,除非用户明确授权并给出测试版本/仓库。
|
||||||
|
|
||||||
|
## 完成标准
|
||||||
|
|
||||||
|
- 仅分析:入口、调用链、配置来源和风险已被准确说明。
|
||||||
|
- 仅校验:check.py 结果逐条可解释,修复建议明确。
|
||||||
|
- 仅构建:产物已生成并通过 verify_deb.sh,未发生上传。
|
||||||
|
- 发布:构建检查通过,服务端接受上传,仓库可见性已验证或准确标记为待更新。
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Builder 构建发布契约 v1
|
||||||
|
|
||||||
|
本契约是 builder skill 的规范本体。`scripts/check.py` 是它的可执行形态:改契约先改
|
||||||
|
check.py,本文档跟随。所有接入项目按同一套 make 目标、产物形状和环境变量执行,
|
||||||
|
builder 脚本只做发布,不做项目特定的构建逻辑。
|
||||||
|
|
||||||
|
分工原则:**make 管构建(项目内、确定性),skill 脚本管发布(跨项目 SSOT),
|
||||||
|
Agent 只保留授权判断和歧义处理。**
|
||||||
|
|
||||||
|
## 1. Make 目标
|
||||||
|
|
||||||
|
### 必备目标(所有项目)
|
||||||
|
|
||||||
|
| 目标 | 要求 |
|
||||||
|
|------|------|
|
||||||
|
| `help` | 分组列出全部目标;首屏含当前版本 |
|
||||||
|
| `version` | 输出一行版本号,适合脚本消费 |
|
||||||
|
| `clean` | 只删除明确、受限的构建产物目录 |
|
||||||
|
| `build` | 编译/打包主产物;尊重 `ARCH`;**不得内含任何上传动作** |
|
||||||
|
|
||||||
|
### 条件目标
|
||||||
|
|
||||||
|
| 目标 | 适用 | 要求 |
|
||||||
|
|------|------|------|
|
||||||
|
| `deb` | 有 DEB 产物的项目 | 产出唯一 `$(DIST_DIR)/<name>_<version>_<arch>.deb`;只构建不上传 |
|
||||||
|
| `docker` | 有镜像的项目 | 构建本地单平台镜像 `linux/$(ARCH)`;**禁止 `--push`、禁止多平台** |
|
||||||
|
| `push-deb` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `upload_deb.sh` 上传 `dist/*.deb` |
|
||||||
|
| `push-docker` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `publish_docker.sh` |
|
||||||
|
| `push` | 单一产物类型时必备;双产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
1. 项目有 DEB 产物的判据:Makefile 配方引用 `dpkg-deb`/`debuild` 或产出 `.deb`。
|
||||||
|
有镜像的判据:项目根存在 `Dockerfile`。
|
||||||
|
2. 双产物项目必须拆 `push-deb`/`push-docker`,`push` 依序聚合两者;单产物项目一个
|
||||||
|
`push` 即可。
|
||||||
|
3. `docker` 目标只能本地构建。多平台镜像无法拆成"make 构建 + 单独推送"
|
||||||
|
(`buildx --push` 是一步),因此多平台发布只能走 `publish_docker.sh`。
|
||||||
|
4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点。
|
||||||
|
|
||||||
|
## 2. 变量
|
||||||
|
|
||||||
|
| 变量 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `ARCH` | `amd64` | 仅允许 `amd64` \| `arm64`,非法值必须 `$(error)` 报错并提示合法值 |
|
||||||
|
| `VERSION` | `` (空) | 为空时由 make 从 `git describe --tags --always --dirty` 推导 |
|
||||||
|
| `DIST_DIR` | `dist` | DEB 产物目录 |
|
||||||
|
| `PROJECT_NAME` | git 仓库名 | 包名/镜像名主体 |
|
||||||
|
|
||||||
|
## 3. 发布环境变量
|
||||||
|
|
||||||
|
### DEB 轨道
|
||||||
|
|
||||||
|
| 变量 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `DEB_SERVER_URL` | 是 | 仓库服务地址 |
|
||||||
|
| `DEB_TOKEN` | 是 | 认证令牌;只从环境读取,绝不进 argv/日志/git |
|
||||||
|
| `DEB_REPOSITORY` | 是 | 目标仓库名 |
|
||||||
|
| `DEB_UPLOAD_PATH` | 否 | 覆盖默认上传路径 `/api/v2/upload/package` |
|
||||||
|
|
||||||
|
### Docker 轨道
|
||||||
|
|
||||||
|
| 变量 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `DOCKER_REGISTRY` | 是 | registry 主机,无 scheme |
|
||||||
|
| `DOCKER_REPOSITORY` | 否 | 默认取 git 仓库名 |
|
||||||
|
| `IMAGE_TAG` | 否 | 默认 `git describe --tags --always --dirty` |
|
||||||
|
| `PLATFORMS` | 否 | 默认 `linux/amd64`;多平台如 `linux/amd64,linux/arm64` |
|
||||||
|
|
||||||
|
配置来源优先级:shell 已显式设置的值 > 项目根 `.env` > 失败并询问用户。
|
||||||
|
`.env` 由 builder 脚本自动向上查找并加载(不回显任何值);当前 shell 已设置的值
|
||||||
|
优先于 `.env`。
|
||||||
|
|
||||||
|
### 工作区安全
|
||||||
|
|
||||||
|
脏工作树(有未提交修改)默认拒绝发布;`ALLOW_UNCOMMITTED=1` 显式放行并在汇报中
|
||||||
|
注明镜像/包包含哪些未提交修改。该门在 builder 脚本层实现,不在 make 层。
|
||||||
|
|
||||||
|
## 4. 脚本解析顺序
|
||||||
|
|
||||||
|
push 目标定位 builder 脚本时按以下顺序,命中即用,不做静默兜底:
|
||||||
|
|
||||||
|
1. `$BUILDER_SKILL_DIR/scripts/`(特殊安装位置)
|
||||||
|
2. `$HOME/.skills/skills/builder/scripts/`(标准 clone 位)
|
||||||
|
|
||||||
|
两个位置都不可用时必须失败并提示:设置 `BUILDER_SKILL_DIR`,或把 skills 仓库
|
||||||
|
clone 到 `~/.skills`。
|
||||||
|
|
||||||
|
## 5. 校验
|
||||||
|
|
||||||
|
`python3 -I -S <builder-scripts>/check.py <project-dir> [--build]` 对本项目逐条检查
|
||||||
|
上述要求,任一 FAIL 退出码非零,可直接挂 CI。`--build` 额外实构 `make deb` 并核对
|
||||||
|
产物元数据(默认只静态检查配方)。校验失败时的修复路径:用 create-makefile skill
|
||||||
|
补齐或修正 Makefile,不要绕过校验器。
|
||||||
|
|
||||||
|
## 6. 存量项目(legacy fallback)
|
||||||
|
|
||||||
|
未接入契约的项目:builder 仍可按发现流程工作——从 `Makefile`、CI 配置、`debian/`
|
||||||
|
与发布文档中找已有构建/上传入口,优先复用;上传仍使用 builder 脚本。完成一次成功
|
||||||
|
交付后应引导用户用 create-makefile 把项目迁移到本契约,之后以 check.py 为准。
|
||||||
+7
-6
@@ -1,28 +1,29 @@
|
|||||||
# 镜像仓库规则
|
# 镜像仓库规则
|
||||||
|
|
||||||
执行发布前,从用户输入和当前项目文档中确定以下信息:
|
执行发布前,从用户输入和当前项目配置中确定以下信息:
|
||||||
|
|
||||||
| 字段 | 要求 |
|
| 字段 | 要求 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Registry | 必须显式确定,例如 `registry.example.com` |
|
| Registry | 必须显式确定,例如 `registry.example.com` |
|
||||||
| Repository | 必须包含项目约定的 namespace,例如 `team/service` |
|
| Repository | 必须包含项目约定的 namespace;缺省取 git 仓库名 |
|
||||||
| Tag | 必须显式确定;优先使用版本号或 Git SHA |
|
| Tag | 必须显式确定;优先使用版本号或 Git SHA |
|
||||||
| Platform | 必须显式确定,例如 `linux/amd64` 或 `linux/amd64,linux/arm64` |
|
| Platform | 必须显式确定,例如 `linux/amd64` 或 `linux/amd64,linux/arm64` |
|
||||||
| Dockerfile | 默认 `Dockerfile`,不存在或项目另有约定时明确指定 |
|
| Dockerfile | 默认 `Dockerfile`,不存在或项目另有约定时明确指定 |
|
||||||
| Context | 默认当前项目根目录 |
|
| Context | 默认当前项目根目录 |
|
||||||
|
|
||||||
## 信息来源优先级
|
信息来源优先级:
|
||||||
|
|
||||||
1. 用户本次请求中明确给出的值。
|
1. 用户本次请求中明确给出的值。
|
||||||
2. 当前项目的 `AGENTS.md` 和发布文档。
|
2. 当前项目的 `.env` 与 `AGENTS.md`、发布文档。
|
||||||
3. `Makefile`、CI 配置、Compose 文件或现有构建脚本中一致且无歧义的配置。
|
3. Makefile、CI 配置或现有构建脚本中一致且无歧义的配置。
|
||||||
4. 询问用户。
|
4. 询问用户。
|
||||||
|
|
||||||
不要从其他项目、shell history 或无关的本地配置中猜测发布目标。
|
不要从其他项目、shell history 或无关的本地配置中猜测发布目标。
|
||||||
|
|
||||||
## 认证
|
## 认证
|
||||||
|
|
||||||
使用 Docker 当前配置的 credential helper 或已有登录状态。可用不泄露凭据的只读操作检查目标是否可访问。认证缺失或过期时,停止并让用户自行完成登录。
|
使用 Docker 当前配置的 credential helper 或已有登录状态。可用不泄露凭据的只读操作
|
||||||
|
检查目标是否可访问。认证缺失或过期时,停止并让用户自行完成登录。
|
||||||
|
|
||||||
不要读取、打印或复制以下内容:
|
不要读取、打印或复制以下内容:
|
||||||
|
|
||||||
Executable
+357
@@ -0,0 +1,357 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Executable form of the builder contract (references/contract.md).
|
||||||
|
|
||||||
|
Checks a project's Makefile against the contract by probing make itself with
|
||||||
|
dry runs (`make -n`) instead of parsing Makefile text: includes, conditionals,
|
||||||
|
and variable expansion are resolved by make, so behavior is what gets judged.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 -I -S check.py <project-dir> [--build]
|
||||||
|
|
||||||
|
Exit codes: 0 = all PASS, 1 = at least one FAIL, 2 = usage/environment error.
|
||||||
|
|
||||||
|
Change the contract here first, then mirror the change into contract.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ARCH_VALUES = ("amd64", "arm64")
|
||||||
|
REQUIRED_TARGETS = ("help", "version", "clean", "build")
|
||||||
|
UPLOAD_TOKENS = (
|
||||||
|
"curl ", "curl\t", "scp ", "rsync ", "aptly ", "reprepro ",
|
||||||
|
"docker push", "buildx build --push", "buildx --push", "upload_deb.sh",
|
||||||
|
"publish_docker.sh",
|
||||||
|
)
|
||||||
|
SECRET_PATTERNS = (
|
||||||
|
re.compile(r"(TOKEN|PASSWORD|SECRET|API_KEY|PASSWD)[A-Z_]*\s*[:?]?=\s*['\"]?[^\s$({\"']+", re.IGNORECASE),
|
||||||
|
re.compile(r"\b[A-Za-z0-9_]*token[A-Za-z0-9_]*\s*[:?]?=\s*['\"]?[A-Za-z0-9._\-]{16,}", re.IGNORECASE),
|
||||||
|
)
|
||||||
|
FLOATING_TAGS = (":latest", ":stable")
|
||||||
|
DEB_SHAPE = re.compile(r"^[^_\s]+_[^_\s]+_[^_\s]+\.deb$")
|
||||||
|
VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh")
|
||||||
|
|
||||||
|
PASS = "PASS"
|
||||||
|
FAIL = "FAIL"
|
||||||
|
SKIP = "SKIP"
|
||||||
|
|
||||||
|
|
||||||
|
class Report:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.failures = 0
|
||||||
|
self.skips = 0
|
||||||
|
|
||||||
|
def add(self, status: str, number: int, title: str, detail: str) -> None:
|
||||||
|
print(f"[{status}] {number}. {title}")
|
||||||
|
for line in detail.splitlines():
|
||||||
|
print(f" {line}")
|
||||||
|
if status == FAIL:
|
||||||
|
self.failures += 1
|
||||||
|
elif status == SKIP:
|
||||||
|
self.skips += 0 if self.skips else 1
|
||||||
|
|
||||||
|
|
||||||
|
def run_make(project: Path, *args: str, timeout: int = 60) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["make", "-C", str(project), "-n", *args],
|
||||||
|
capture_output=True, text=True, timeout=timeout, check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def has_no_rule(result: subprocess.CompletedProcess[str]) -> bool:
|
||||||
|
return result.returncode != 0 and (
|
||||||
|
"No rule to make target" in result.stderr or "no rule to make target" in result.stderr.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BANNER_RE = re.compile(r"^make(?:\[[0-9]+\])?: (进入|离开|Entering|Leaving)")
|
||||||
|
|
||||||
|
|
||||||
|
def clean_make_output(result: subprocess.CompletedProcess[str]) -> list[str]:
|
||||||
|
"""Drop make directory banners and dry-run command echoes, keep real output."""
|
||||||
|
lines = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if BANNER_RE.match(line.strip()):
|
||||||
|
continue
|
||||||
|
stripped = line.lstrip()
|
||||||
|
if stripped.startswith(("echo ", "echo\t", "printf ")):
|
||||||
|
continue
|
||||||
|
lines.append(line)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def check_required_targets(report: Report, project: Path) -> dict[str, bool]:
|
||||||
|
present: dict[str, bool] = {}
|
||||||
|
lines = []
|
||||||
|
for target in REQUIRED_TARGETS:
|
||||||
|
result = run_make(project, target)
|
||||||
|
ok = result.returncode == 0
|
||||||
|
present[target] = ok
|
||||||
|
lines.append(f"{target}: {'found' if ok else 'missing'}")
|
||||||
|
report.add(PASS if all(present.values()) else FAIL, 1, "必备目标存在(help/version/clean/build)", "\n".join(lines))
|
||||||
|
return present
|
||||||
|
|
||||||
|
|
||||||
|
def check_arch_guard(report: Report, project: Path) -> None:
|
||||||
|
bad = run_make(project, "build", "ARCH=loongarch")
|
||||||
|
guard_ok = bad.returncode != 0 and ("amd64" in bad.stderr or "arm64" in bad.stderr)
|
||||||
|
default_ok = run_make(project, "build").returncode == 0
|
||||||
|
lines = [
|
||||||
|
f"invalid ARCH rejected: {'yes' if guard_ok else 'NO'}",
|
||||||
|
f"default ARCH works: {'yes' if default_ok else 'no'}",
|
||||||
|
]
|
||||||
|
hint = "" if guard_ok else "\n Hint: add `$(error ARCH must be amd64 or arm64)` guarded by an ifneq filter."
|
||||||
|
if guard_ok and default_ok:
|
||||||
|
report.add(PASS, 2, "ARCH 守卫与缺省值", "\n".join(lines + hint.splitlines()))
|
||||||
|
else:
|
||||||
|
report.add(FAIL, 2, "ARCH 守卫与缺省值", "\n".join(lines) + hint)
|
||||||
|
|
||||||
|
|
||||||
|
def check_version_output(report: Report, project: Path) -> None:
|
||||||
|
result = run_make(project, "version")
|
||||||
|
# Dry run: the echoed `@echo <version>` line IS the would-be output.
|
||||||
|
out_lines = [ln.lstrip()[5:] for ln in result.stdout.splitlines() if ln.lstrip().startswith("echo ")]
|
||||||
|
out = "\n".join(out_lines).strip()
|
||||||
|
single = len(out.splitlines()) == 1 and out != ""
|
||||||
|
report.add(
|
||||||
|
PASS if single else FAIL,
|
||||||
|
3,
|
||||||
|
"version 输出一行非空版本号",
|
||||||
|
f"stdout={out!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_build_has_no_upload(report: Report, project: Path) -> None:
|
||||||
|
result = run_make(project, "build")
|
||||||
|
text = chr(10).join(clean_make_output(result))
|
||||||
|
hits = [token for token in UPLOAD_TOKENS if token in text]
|
||||||
|
report.add(
|
||||||
|
PASS if not hits else FAIL,
|
||||||
|
4,
|
||||||
|
"build 不含上传动作",
|
||||||
|
"clean" if not hits else "found upload commands in build recipe:\n " + ", ".join(hits),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_deb_project(recipe_all: str, project: Path) -> bool:
|
||||||
|
return ".deb" in recipe_all or "dpkg-deb" in recipe_all or "debuild" in recipe_all or any(project.glob("debian/*"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_deb_recipe(report: Report, project: Path, built_deb: Path | None) -> None:
|
||||||
|
dry = run_make(project, "deb")
|
||||||
|
text = chr(10).join(clean_make_output(dry))
|
||||||
|
problems = []
|
||||||
|
if dry.returncode != 0:
|
||||||
|
problems.append(f"`make -n deb` failed: {dry.stderr.strip() or 'unknown error'}")
|
||||||
|
else:
|
||||||
|
if "dist/" not in text and "$(DIST_DIR)" not in text:
|
||||||
|
problems.append("recipe does not reference dist/ ($(DIST_DIR)) as artifact location")
|
||||||
|
hits = [token for token in UPLOAD_TOKENS if token in text]
|
||||||
|
if hits:
|
||||||
|
problems.append("recipe contains upload commands: " + ", ".join(hits))
|
||||||
|
if "rm -rf /" in text or "rm -rf ~" in text:
|
||||||
|
problems.append("recipe contains unrestricted rm -rf")
|
||||||
|
if built_deb is not None:
|
||||||
|
shape_ok = DEB_SHAPE.match(built_deb.name) is not None
|
||||||
|
if not shape_ok:
|
||||||
|
problems.append(f"artifact name does not match <name>_<version>_<arch>.deb: {built_deb.name}")
|
||||||
|
dpkg = shutil.which("dpkg-deb")
|
||||||
|
if dpkg:
|
||||||
|
info = subprocess.run([dpkg, "--field", str(built_deb), "Package"], capture_output=True, text=True, check=False)
|
||||||
|
if info.returncode != 0 or not info.stdout.strip():
|
||||||
|
problems.append(f"dpkg-deb --info failed on {built_deb.name}")
|
||||||
|
else:
|
||||||
|
problems.append("dpkg-deb unavailable; metadata not verified (--build)")
|
||||||
|
if problems:
|
||||||
|
report.add(FAIL, 5, "deb 目标产物形状与纯构建", "\n".join(problems))
|
||||||
|
else:
|
||||||
|
extra = f"\nartifact: {built_deb.name}" if built_deb else "\n(static recipe check only; run --build to verify real artifact)"
|
||||||
|
report.add(PASS, 5, "deb 目标产物形状与纯构建", extra.lstrip("\n"))
|
||||||
|
|
||||||
|
|
||||||
|
def detect_docker_project(project: Path) -> bool:
|
||||||
|
return (project / "Dockerfile").exists() or (project / "docker-compose.yaml").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def check_docker_recipe(report: Report, project: Path) -> None:
|
||||||
|
dry = run_make(project, "docker")
|
||||||
|
text = chr(10).join(clean_make_output(dry))
|
||||||
|
if has_no_rule(dry):
|
||||||
|
report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no docker target)")
|
||||||
|
return
|
||||||
|
problems = []
|
||||||
|
if "--push" in text or " docker push" in text or "docker push\n" in text:
|
||||||
|
problems.append("make docker must be local-only; pushing belongs to publish_docker.sh")
|
||||||
|
if "--platform" in text and "," in text.split("--platform")[1][:80].split()[0]:
|
||||||
|
problems.append("make docker must stay single-platform; multi-platform belongs to publish_docker.sh")
|
||||||
|
report.add(FAIL if problems else PASS, 6, "docker 目标为本地单平台构建", "\n".join(problems) or "local single-platform build")
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_RESOLVE_SNIPPETS = tuple(
|
||||||
|
f"{prefix}{name}"
|
||||||
|
for prefix in ("$$BUILDER_SKILL_DIR", "$BUILDER_SKILL_DIR", "$$HOME/.skills/skills/builder/scripts", "$HOME/.skills/skills/builder/scripts", "~/.skills/skills/builder/scripts")
|
||||||
|
for name in VALID_SCRIPT_NAMES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_push_delegates(report: Report, project: Path, dual_artifact: bool) -> None:
|
||||||
|
targets = ("push-deb", "push-docker") if dual_artifact else ("push",)
|
||||||
|
missing = []
|
||||||
|
inline = []
|
||||||
|
thin = []
|
||||||
|
for target in targets:
|
||||||
|
dry = run_make(project, target)
|
||||||
|
if has_no_rule(dry):
|
||||||
|
missing.append(target)
|
||||||
|
continue
|
||||||
|
text = chr(10).join(clean_make_output(dry))
|
||||||
|
bad_tokens = [token for token in ("curl ", "scp ", "aptly ", "reprepro ") if token in text]
|
||||||
|
if bad_tokens:
|
||||||
|
inline.append(f"{target}: inline upload command ({', '.join(bad_tokens)})")
|
||||||
|
elif not any(snippet in text for snippet in SCRIPT_RESOLVE_SNIPPETS) \
|
||||||
|
and "$(BUILDER_SCRIPT)" not in text and "upload_deb.sh" not in text \
|
||||||
|
and "publish_docker.sh" not in text:
|
||||||
|
inline.append(f"{target}: does not call a builder script (expected $BUILDER_SKILL_DIR/... or ~/.skills/... path)")
|
||||||
|
else:
|
||||||
|
thin.append(target)
|
||||||
|
problems = []
|
||||||
|
if missing:
|
||||||
|
problems.append("missing targets: " + ", ".join(missing))
|
||||||
|
problems.extend(inline)
|
||||||
|
status = PASS if not problems else FAIL
|
||||||
|
detail = "\n".join(problems) if problems else "thin wrappers: " + ", ".join(thin)
|
||||||
|
report.add(status, 7, "push 仅委托 builder 脚本(薄包装)", detail)
|
||||||
|
|
||||||
|
|
||||||
|
def check_secrets_and_tags(report: Report, project: Path) -> None:
|
||||||
|
makefile = project / "Makefile"
|
||||||
|
included_text = ""
|
||||||
|
problems = []
|
||||||
|
files = [makefile]
|
||||||
|
if makefile.exists():
|
||||||
|
for match in re.finditer(r"^include\s+(.+)$", makefile.read_text(encoding="utf-8"), re.MULTILINE):
|
||||||
|
inc = (project / match.group(1).strip()).resolve()
|
||||||
|
if inc.is_file():
|
||||||
|
files.append(inc)
|
||||||
|
for file in files:
|
||||||
|
text = file.read_text(encoding="utf-8")
|
||||||
|
rel = file.relative_to(project) if file.is_relative_to(project) else file
|
||||||
|
for pattern in SECRET_PATTERNS:
|
||||||
|
for hit in pattern.finditer(text):
|
||||||
|
problems.append(f"{rel}: possible hardcoded secret near `{hit.group(0)[:40]}...`")
|
||||||
|
for tag in FLOATING_TAGS:
|
||||||
|
for line in text.splitlines():
|
||||||
|
stripped = line.split("#", 1)[0]
|
||||||
|
if tag in stripped:
|
||||||
|
problems.append(f"{rel}: implicit floating tag `{tag}` in: {stripped.strip()[:70]}")
|
||||||
|
report.add(FAIL if problems else PASS, 8, "无内联机密、无隐式 latest/stable", "\n".join(problems) or "clean")
|
||||||
|
|
||||||
|
|
||||||
|
def check_script_paths(report: Report) -> None:
|
||||||
|
import os
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
env_dir = os.environ.get("BUILDER_SKILL_DIR")
|
||||||
|
if env_dir:
|
||||||
|
candidates.append(Path(env_dir) / "scripts")
|
||||||
|
home = Path(os.environ.get("HOME", ""))
|
||||||
|
candidates.append(home / ".skills" / "skills" / "builder" / "scripts")
|
||||||
|
found = next((c for c in candidates if c.is_dir() and any((c / n).is_file() for n in VALID_SCRIPT_NAMES)), None)
|
||||||
|
if found:
|
||||||
|
report.add(PASS, 9, "builder 脚本路径可达", str(found))
|
||||||
|
else:
|
||||||
|
report.add(FAIL, 9, "builder 脚本路径可达", "\n".join([
|
||||||
|
"none of these resolve to scripts/upload_deb.sh:",
|
||||||
|
*(f" {c}" for c in candidates),
|
||||||
|
"Fix: set BUILDER_SKILL_DIR, or clone the skills repo to ~/.skills.",
|
||||||
|
]))
|
||||||
|
|
||||||
|
|
||||||
|
def build_project(project: Path) -> Path | None:
|
||||||
|
"""Run `make deb` for real and return the produced .deb, or None."""
|
||||||
|
result = subprocess.run(["make", "-C", str(project), "deb"], capture_output=True, text=True, timeout=1800, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"--build: `make deb` failed:\n{result.stderr[-2000:]}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
debs = sorted((p for p in (project / "dist").glob("*.deb") if p.is_file()), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
return debs[0] if debs else None
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("project", type=Path, help="project directory containing the Makefile")
|
||||||
|
parser.add_argument("--build", action="store_true", help="actually run `make deb` and verify the artifact")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
project = args.project.resolve()
|
||||||
|
makefile = project / "Makefile"
|
||||||
|
if not makefile.is_file():
|
||||||
|
print(f"Error: no Makefile in {project}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if shutil.which("make") is None:
|
||||||
|
print("Error: make is required.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
report = Report()
|
||||||
|
|
||||||
|
# Gather every recipe once via dry-running all known targets (best effort).
|
||||||
|
recipe_all_parts = []
|
||||||
|
for target in (*REQUIRED_TARGETS, "deb", "docker", "push", "push-deb", "push-docker"):
|
||||||
|
result = run_make(project, target)
|
||||||
|
if result.returncode == 0:
|
||||||
|
recipe_all_parts.append(result.stdout)
|
||||||
|
recipe_all = "\n".join(recipe_all_parts)
|
||||||
|
|
||||||
|
present = check_required_targets(report, project)
|
||||||
|
|
||||||
|
built_deb: Path | None = None
|
||||||
|
deb_project = detect_deb_project(recipe_all, project)
|
||||||
|
docker_project = detect_docker_project(project)
|
||||||
|
|
||||||
|
if present["build"]:
|
||||||
|
check_arch_guard(report, project)
|
||||||
|
check_version_output(report, project)
|
||||||
|
check_build_has_no_upload(report, project)
|
||||||
|
else:
|
||||||
|
report.add(SKIP, 2, "ARCH 守卫与缺省值", "(build target missing)")
|
||||||
|
report.add(SKIP, 3, "version 输出一行非空版本号", "(version target missing)")
|
||||||
|
report.add(SKIP, 4, "build 不含上传动作", "(build target missing)")
|
||||||
|
|
||||||
|
if deb_project:
|
||||||
|
if args.build:
|
||||||
|
print("--build: running `make deb` ...")
|
||||||
|
built_deb = build_project(project)
|
||||||
|
if built_deb is None:
|
||||||
|
print("--build: no .deb produced; artifact checks degrade to recipe-only.", file=sys.stderr)
|
||||||
|
check_deb_recipe(report, project, built_deb)
|
||||||
|
else:
|
||||||
|
report.add(SKIP, 5, "deb 目标产物形状与纯构建", "(not a DEB project)")
|
||||||
|
|
||||||
|
if docker_project:
|
||||||
|
check_docker_recipe(report, project)
|
||||||
|
else:
|
||||||
|
report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no Dockerfile)")
|
||||||
|
|
||||||
|
dual = deb_project and docker_project
|
||||||
|
check_push_delegates(report, project, dual)
|
||||||
|
check_secrets_and_tags(report, project)
|
||||||
|
check_script_paths(report)
|
||||||
|
|
||||||
|
total_fail = report.failures
|
||||||
|
print()
|
||||||
|
if total_fail:
|
||||||
|
print(f"RESULT: FAILED ({total_fail} check(s) failed)")
|
||||||
|
return 1
|
||||||
|
print("RESULT: PASSED")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+164
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,30p' "$0"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build and publish a Docker image with buildx. Configuration comes from the
|
||||||
|
# environment first (optionally loaded from the project root .env); flags
|
||||||
|
# override.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# publish_docker.sh [--registry HOST] [--repository PATH] [--tag TAG] \
|
||||||
|
# [--platform LIST] [options]
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# DOCKER_REGISTRY Required (or --registry)
|
||||||
|
# DOCKER_REPOSITORY Optional, default: git repository name (or --repository)
|
||||||
|
# IMAGE_TAG Optional, default: git describe --tags --always --dirty (or --tag)
|
||||||
|
# PLATFORMS Optional, default: linux/amd64 (or --platform)
|
||||||
|
# DOCKER_DOCKERFILE Optional, default: Dockerfile (--file)
|
||||||
|
# DOCKER_CONTEXT Optional, default: . (--context)
|
||||||
|
# DOCKER_BUILDER Optional buildx builder name (--builder)
|
||||||
|
# ALLOW_UNCOMMITTED=1 Publish despite a dirty working tree
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --load Load a single-platform image instead of pushing
|
||||||
|
# --dry-run Print the resolved build without executing it
|
||||||
|
# -h, --help Show this help
|
||||||
|
|
||||||
|
project_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
||||||
|
|
||||||
|
# Load project .env without printing values; explicitly exported shell values keep precedence.
|
||||||
|
if [[ -n "$project_root" && -f "$project_root/.env" ]]; then
|
||||||
|
while IFS='=' read -r key value; do
|
||||||
|
key=${key%%[[:space:]]*}
|
||||||
|
[[ -z "$key" || "$key" == \#* ]] && continue
|
||||||
|
if [[ -n "${!key:-}" ]]; then
|
||||||
|
continue # shell value already set: wins over .env
|
||||||
|
fi
|
||||||
|
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
|
||||||
|
printf -v "$key" '%s' "$value"
|
||||||
|
export "$key"
|
||||||
|
done < <(grep -v '^[[:space:]]*$' "$project_root/.env")
|
||||||
|
fi
|
||||||
|
|
||||||
|
git_repo_name=
|
||||||
|
if [[ -n "$project_root" ]]; then
|
||||||
|
git_repo_name=$(basename "$(git -C "$project_root" rev-parse --show-toplevel)")
|
||||||
|
fi
|
||||||
|
|
||||||
|
registry=${DOCKER_REGISTRY:-}
|
||||||
|
repository=${DOCKER_REPOSITORY:-$git_repo_name}
|
||||||
|
tag=${IMAGE_TAG:-}
|
||||||
|
platform=${PLATFORMS:-linux/amd64}
|
||||||
|
dockerfile=${DOCKER_DOCKERFILE:-Dockerfile}
|
||||||
|
build_context=${DOCKER_CONTEXT:-.}
|
||||||
|
builder=${DOCKER_BUILDER:-}
|
||||||
|
mode=push
|
||||||
|
dry_run=false
|
||||||
|
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--registry) registry=$2; shift 2 ;;
|
||||||
|
--repository) repository=$2; shift 2 ;;
|
||||||
|
--tag) tag=$2; shift 2 ;;
|
||||||
|
--platform) platform=$2; shift 2 ;;
|
||||||
|
--file) dockerfile=$2; shift 2 ;;
|
||||||
|
--context) build_context=$2; shift 2 ;;
|
||||||
|
--builder) builder=$2; shift 2 ;;
|
||||||
|
--load) mode=load; shift ;;
|
||||||
|
--dry-run) dry_run=true; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "Error: unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "$registry" && ( "$registry" == *://* || "$registry" == */* ) ]]; then
|
||||||
|
echo "Error: registry must be a bare host without scheme or slash: $registry" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -z "$registry" ]]; then
|
||||||
|
echo "Error: DOCKER_REGISTRY (or --registry) is required." >&2
|
||||||
|
echo "Set it in the environment or the project root .env." >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -z "$repository" || "$repository" == /* || "$repository" == */ || "$repository" != */* ]]; then
|
||||||
|
echo "Error: repository must be namespace/name without leading or trailing slash: $repository" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -z "$tag" ]]; then
|
||||||
|
if [[ -n "$project_root" ]]; then
|
||||||
|
tag=$(git -C "$project_root" describe --tags --always --dirty 2>/dev/null) || tag=
|
||||||
|
fi
|
||||||
|
if [[ -z "$tag" ]]; then
|
||||||
|
echo "Error: IMAGE_TAG (or --tag) is required outside a git repository." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ "$tag" == *:* || "$tag" == */* ]]; then
|
||||||
|
echo "Error: tag must not contain : or /: $tag" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$tag" == latest && ${ALLOW_LATEST:-0} != 1 && "$mode" == push ]]; then
|
||||||
|
echo "Error: refusing to publish floating tag 'latest'; pass an explicit version." >&2
|
||||||
|
echo "Set ALLOW_LATEST=1 only when the user explicitly asked for 'latest'." >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
if [[ "$mode" == load && "$platform" == *,* ]]; then
|
||||||
|
echo "Error: --load cannot be combined with multiple platforms: $platform" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$dockerfile" ]]; then
|
||||||
|
echo "Error: Dockerfile not found: $dockerfile" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! -d "$build_context" ]]; then
|
||||||
|
echo "Error: build context not found: $build_context" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$dry_run" == false ]] && ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "Error: docker is required." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Dirty-tree gate: publishing uncommitted content requires explicit opt-in.
|
||||||
|
if [[ "$mode" == push && "$dry_run" == false && -n "$project_root" ]] \
|
||||||
|
&& git -C "$project_root" rev-parse HEAD >/dev/null 2>&1; then
|
||||||
|
if [[ ${ALLOW_UNCOMMITTED:-0} != 1 ]] && ! git -C "$project_root" diff-index --quiet HEAD -- 2>/dev/null; then
|
||||||
|
echo "Error: working tree has uncommitted changes; refusing to publish." >&2
|
||||||
|
echo "Commit first, or set ALLOW_UNCOMMITTED=1 to publish anyway." >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
image_ref="${registry}/${repository}:${tag}"
|
||||||
|
build_cmd=(docker buildx build --file "$dockerfile" --platform "$platform" --tag "$image_ref")
|
||||||
|
if [[ -n "$builder" ]]; then
|
||||||
|
build_cmd+=(--builder "$builder")
|
||||||
|
fi
|
||||||
|
if [[ "$mode" == push ]]; then
|
||||||
|
build_cmd+=(--push)
|
||||||
|
else
|
||||||
|
build_cmd+=(--load)
|
||||||
|
fi
|
||||||
|
build_cmd+=("$build_context")
|
||||||
|
|
||||||
|
printf 'Image: %s\n' "$image_ref"
|
||||||
|
printf 'Platform: %s\n' "$platform"
|
||||||
|
printf 'Dockerfile: %s\n' "$dockerfile"
|
||||||
|
printf 'Context: %s\n' "$build_context"
|
||||||
|
printf 'Mode: %s\n' "$mode"
|
||||||
|
|
||||||
|
if [[ "$dry_run" == true ]]; then
|
||||||
|
printf 'Command: %s\n' "${build_cmd[*]}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
"${build_cmd[@]}"
|
||||||
|
|
||||||
|
if [[ "$mode" == push ]]; then
|
||||||
|
docker buildx imagetools inspect "$image_ref"
|
||||||
|
fi
|
||||||
@@ -15,12 +15,33 @@ Options:
|
|||||||
-p UPLOAD_PATH Override DEB_UPLOAD_PATH (default: /api/v2/upload/package)
|
-p UPLOAD_PATH Override DEB_UPLOAD_PATH (default: /api/v2/upload/package)
|
||||||
-h Show help
|
-h Show help
|
||||||
|
|
||||||
The endpoint must accept multipart fields named package, token, and
|
Environment variables may live in the project root .env; this script walks up
|
||||||
repository_name. Authentication is read only from DEB_TOKEN so it is not
|
from the current directory, loads it silently (existing shell values win), and
|
||||||
exposed in the process command line.
|
never echoes variable values. The endpoint must accept multipart fields named
|
||||||
|
package, token, and repository_name. Authentication is read only from
|
||||||
|
DEB_TOKEN so it is not exposed in the process command line.
|
||||||
|
|
||||||
|
The working tree must be clean to publish; set ALLOW_UNCOMMITTED=1 to override.
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Locate project root (.git) upward from cwd for .env loading and git checks.
|
||||||
|
project_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
||||||
|
|
||||||
|
# Load project .env without printing values; explicitly exported shell values keep precedence.
|
||||||
|
if [[ -n "$project_root" && -f "$project_root/.env" ]]; then
|
||||||
|
while IFS='=' read -r key value; do
|
||||||
|
key=${key%%[[:space:]]*}
|
||||||
|
[[ -z "$key" || "$key" == \#* ]] && continue
|
||||||
|
if [[ -n "${!key:-}" ]]; then
|
||||||
|
continue # shell value already set: wins over .env
|
||||||
|
fi
|
||||||
|
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
|
||||||
|
printf -v "$key" '%s' "$value"
|
||||||
|
export "$key"
|
||||||
|
done < <(grep -v '^[[:space:]]*$' "$project_root/.env")
|
||||||
|
fi
|
||||||
|
|
||||||
server_url=${DEB_SERVER_URL:-}
|
server_url=${DEB_SERVER_URL:-}
|
||||||
repository=${DEB_REPOSITORY:-}
|
repository=${DEB_REPOSITORY:-}
|
||||||
upload_path=${DEB_UPLOAD_PATH:-/api/v2/upload/package}
|
upload_path=${DEB_UPLOAD_PATH:-/api/v2/upload/package}
|
||||||
@@ -40,9 +61,20 @@ shift $((OPTIND - 1))
|
|||||||
|
|
||||||
if [[ -z "$server_url" || -z "$repository" || -z "$token" || $# -eq 0 ]]; then
|
if [[ -z "$server_url" || -z "$repository" || -z "$token" || $# -eq 0 ]]; then
|
||||||
echo "Error: DEB_SERVER_URL, DEB_TOKEN, DEB_REPOSITORY, and at least one file are required." >&2
|
echo "Error: DEB_SERVER_URL, DEB_TOKEN, DEB_REPOSITORY, and at least one file are required." >&2
|
||||||
|
echo "Set them in the environment or the project root .env." >&2
|
||||||
usage >&2
|
usage >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Dirty-tree gate: publishing uncommitted content requires explicit opt-in.
|
||||||
|
if [[ -n "$project_root" ]] && git -C "$project_root" rev-parse HEAD >/dev/null 2>&1; then
|
||||||
|
if [[ ${ALLOW_UNCOMMITTED:-0} != 1 ]] && ! git -C "$project_root" diff-index --quiet HEAD -- 2>/dev/null; then
|
||||||
|
echo "Error: working tree has uncommitted changes; refusing to publish." >&2
|
||||||
|
echo "Commit first, or set ALLOW_UNCOMMITTED=1 to publish anyway." >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$upload_path" != /* ]]; then
|
if [[ "$upload_path" != /* ]]; then
|
||||||
echo "Error: upload path must start with /" >&2
|
echo "Error: upload path must start with /" >&2
|
||||||
exit 2
|
exit 2
|
||||||
@@ -130,4 +162,3 @@ echo "Done. Success: $success_count, Failed: $fail_count"
|
|||||||
if ((fail_count > 0)); then
|
if ((fail_count > 0)); then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
Executable
+111
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
verify_deb.sh FILE.deb [EXPECTED_VERSION] [EXPECTED_ARCH]
|
||||||
|
|
||||||
|
Prints package metadata, key content listing, and SHA-256. When an expected
|
||||||
|
version and/or architecture is given, mismatches fail with a non-zero exit.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $# -lt 1 || $# -gt 3 ]]; then
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
package=$1
|
||||||
|
expected_version=${2:-}
|
||||||
|
expected_arch=${3:-}
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
if [[ ! -f "$package" ]]; then
|
||||||
|
echo "Error: file not found: $package" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! -s "$package" ]]; then
|
||||||
|
echo "Error: empty file: $package" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if ! command -v dpkg-deb >/dev/null 2>&1; then
|
||||||
|
echo "Error: dpkg-deb is required." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== metadata =="
|
||||||
|
info=$(dpkg-deb --info "$package") || {
|
||||||
|
echo "Error: dpkg-deb --info failed; not a valid Debian package." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
printf '%s\n' "$info"
|
||||||
|
|
||||||
|
package_name=$(dpkg-deb --field "$package" Package 2>/dev/null || true)
|
||||||
|
package_version=$(dpkg-deb --field "$package" Version 2>/dev/null || true)
|
||||||
|
package_arch=$(dpkg-deb --field "$package" Architecture 2>/dev/null || true)
|
||||||
|
|
||||||
|
# Debian versions never start with 'v'; git tags usually do. Compare normalized.
|
||||||
|
expected_version=${expected_version#v}
|
||||||
|
|
||||||
|
if [[ -z "$package_name" || -z "$package_version" || -z "$package_arch" ]]; then
|
||||||
|
echo "FAIL: missing Package/Version/Architecture field." >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$expected_version" && "$package_version" != "$expected_version" ]]; then
|
||||||
|
echo "FAIL: version mismatch: expected $expected_version, got $package_version" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ -n "$expected_arch" && "$package_arch" != "$expected_arch" ]]; then
|
||||||
|
echo "FAIL: architecture mismatch: expected $expected_arch, got $package_arch" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Filename shape per contract: <name>_<version>_<arch>.deb
|
||||||
|
base=$(basename -- "$package")
|
||||||
|
if [[ ! "$base" =~ ^[^_]+_[^_]+_[^_]+\.deb$ ]]; then
|
||||||
|
echo "FAIL: filename does not match <name>_<version>_<arch>.deb: $base" >&2
|
||||||
|
fail=1
|
||||||
|
elif [[ -n "$package_version" && ! "$base" == *"${package_version}"* ]]; then
|
||||||
|
echo "FAIL: filename version does not match package Version ($package_version): $base" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== contents (top level + binaries) =="
|
||||||
|
dpkg-deb --contents "$package" | sed -n '1,40p'
|
||||||
|
|
||||||
|
echo "== maintainer scripts permissions (when present) =="
|
||||||
|
control_dir=$(mktemp -d)
|
||||||
|
trap 'rm -rf -- "$control_dir"' EXIT
|
||||||
|
if dpkg-deb --control "$package" "$control_dir" 2>/dev/null; then
|
||||||
|
found_scripts=false
|
||||||
|
for script in preinst postinst prerm postrm; do
|
||||||
|
if [[ -f "$control_dir/$script" ]]; then
|
||||||
|
found_scripts=true
|
||||||
|
mode=$(stat -c '%a' "$control_dir/$script")
|
||||||
|
if [[ $mode =~ .*[2367]$ ]]; then
|
||||||
|
echo "OK: $script mode $mode"
|
||||||
|
else
|
||||||
|
echo "FAIL: $script not executable (mode $mode)" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ "$found_scripts" == false ]]; then
|
||||||
|
echo "(no maintainer scripts)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== sha256 =="
|
||||||
|
sha256sum "$package"
|
||||||
|
|
||||||
|
if ((fail > 0)); then
|
||||||
|
echo "VERIFY: FAILED" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "VERIFY: OK"
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# deb-publisher
|
|
||||||
|
|
||||||
帮助 Agent 复用项目已有的 DEB 打包与发布方式,完成构建、包检查、上传和发布验证。
|
|
||||||
|
|
||||||
## 什么时候使用
|
|
||||||
|
|
||||||
当你希望 Agent 处理以下任务时使用:
|
|
||||||
|
|
||||||
- “帮我构建这个项目的 DEB 包”
|
|
||||||
- “把 1.2.3 版本的 DEB 发布到包仓库”
|
|
||||||
- “看看项目里的 DEB 发布流程”
|
|
||||||
- “检查这个 DEB 是否可以发布”
|
|
||||||
|
|
||||||
只想在本机安装一个 `.deb`,或者要构建 RPM、Docker 镜像时,不需要使用这个 skill。
|
|
||||||
|
|
||||||
## 使用前准备
|
|
||||||
|
|
||||||
请准备或确认:
|
|
||||||
|
|
||||||
- 要构建或发布的版本号。
|
|
||||||
- 项目已经配置好构建脚本、Make 目标或 `debian/` 目录;上传脚本由 skill 自带,
|
|
||||||
不需要放进项目。
|
|
||||||
- 发布所需的令牌已经放入项目约定的环境变量或密钥系统。
|
|
||||||
- 如果要真实发布,明确告诉 Agent 目标仓库以及是否允许覆盖同版本。
|
|
||||||
|
|
||||||
不要把令牌直接粘贴到对话、命令参数或项目文件中。
|
|
||||||
|
|
||||||
## 使用示例
|
|
||||||
|
|
||||||
仅分析,不产生或上传包:
|
|
||||||
|
|
||||||
```text
|
|
||||||
看看这个项目的 DEB 是怎么构建和发布的。
|
|
||||||
```
|
|
||||||
|
|
||||||
只构建和检查,不上传:
|
|
||||||
|
|
||||||
```text
|
|
||||||
使用 deb-publisher 构建 1.2.3 的 DEB,检查包元数据和内容,不要上传。
|
|
||||||
```
|
|
||||||
|
|
||||||
构建并发布:
|
|
||||||
|
|
||||||
```text
|
|
||||||
使用 deb-publisher 构建并发布 1.2.3 的 amd64 DEB 到项目已配置的测试仓库。
|
|
||||||
```
|
|
||||||
|
|
||||||
发布已有产物:
|
|
||||||
|
|
||||||
```text
|
|
||||||
使用 deb-publisher 检查并发布 ./dist/example_1.2.3_amd64.deb。
|
|
||||||
```
|
|
||||||
|
|
||||||
## Agent 会做什么
|
|
||||||
|
|
||||||
Agent 会优先发现和复用项目已有入口,然后:
|
|
||||||
|
|
||||||
1. 确认版本、架构、产物路径、目标仓库和授权范围。
|
|
||||||
2. 构建 DEB,或定位你指定的已有产物。
|
|
||||||
3. 检查包的元数据、内容和 SHA-256。
|
|
||||||
4. 在你明确要求发布时,通过 skill 自带的 `scripts/upload_deb.sh` 上传。
|
|
||||||
5. 检查服务端响应,并在仓库支持时确认该版本已经可见。
|
|
||||||
|
|
||||||
## 如何判断完成
|
|
||||||
|
|
||||||
结果中应包含包名、版本、架构、产物路径、SHA-256,以及构建、上传和仓库可见性的
|
|
||||||
独立状态。异步索引尚未完成时,Agent 应明确说明“上传已接受,索引待更新”。
|
|
||||||
|
|
||||||
## 上传协议
|
|
||||||
|
|
||||||
自带脚本适用于接收 multipart 字段 `package`、`token` 和 `repository_name` 的
|
|
||||||
HTTP DEB 仓库,默认路径是 `/api/v2/upload/package`。使用以下环境变量:
|
|
||||||
|
|
||||||
- `DEB_SERVER_URL`:仓库服务地址。
|
|
||||||
- `DEB_TOKEN`:认证令牌。
|
|
||||||
- `DEB_REPOSITORY`:目标仓库名。
|
|
||||||
- `DEB_UPLOAD_PATH`:可选的上传路径覆盖。
|
|
||||||
|
|
||||||
令牌只从环境变量读取,不作为命令行参数传递。若你的仓库使用其他 API 协议,应先告诉
|
|
||||||
Agent,不要直接套用该脚本。
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
---
|
|
||||||
name: deb-publisher
|
|
||||||
description: >-
|
|
||||||
构建并发布 Debian DEB 包:发现项目已有的 Makefile 和打包入口,使用 skill 自带的
|
|
||||||
通用上传脚本提交包,校验包元数据与内容,并验证发布结果。触发词:构建 deb、
|
|
||||||
发布 deb、上传 deb、提交 deb、推送 apt 仓库、打 Debian 包。仅分析打包逻辑时也可使用,
|
|
||||||
但不会在未获授权时执行上传。
|
|
||||||
---
|
|
||||||
|
|
||||||
# DEB Publisher
|
|
||||||
|
|
||||||
复用项目已有发布约定,安全地完成“发现入口 → 构建 → 检查 → 上传 → 验证”。
|
|
||||||
|
|
||||||
## 何时使用
|
|
||||||
|
|
||||||
- 用户要求构建、发布、上传或提交 `.deb` 包。
|
|
||||||
- 用户要求梳理或接通项目现有的 DEB 发布流程。
|
|
||||||
- 用户要求把已经生成的 `.deb` 推送到 APT/DEB 包仓库。
|
|
||||||
|
|
||||||
## 不适用
|
|
||||||
|
|
||||||
- 只需要安装或卸载本地 DEB 包。
|
|
||||||
- 目标是 RPM、APK、容器镜像或语言包管理器。
|
|
||||||
- 用户只要求设计全新的 Debian 打包体系;此时应先完成方案设计。
|
|
||||||
|
|
||||||
## 工作流
|
|
||||||
|
|
||||||
### 1. 发现项目约定
|
|
||||||
|
|
||||||
从项目根目录查找,不预设文件位置:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg -n -i --hidden --glob '!.git' \
|
|
||||||
'build-deb|upload-deb|publish-deb|dpkg-deb|debuild|curl.*deb|\.deb\b|aptly|reprepro'
|
|
||||||
```
|
|
||||||
|
|
||||||
重点检查:
|
|
||||||
|
|
||||||
- `Makefile`、CI 配置、`debian/`、构建脚本和发布文档。
|
|
||||||
- 版本号、包名、架构、产物目录和仓库名如何传入。
|
|
||||||
- 发布端点、认证方式以及发布是否由构建目标自动触发。
|
|
||||||
- 项目根目录或发布文档是否提供 `.env` 配置;只确认变量名,不打印其值。
|
|
||||||
- 当前工作树和目标版本是否匹配。
|
|
||||||
|
|
||||||
优先复用已有构建入口。上传默认使用本 skill 的 `scripts/upload_deb.sh`,不要把它
|
|
||||||
复制到项目中;仅当目标仓库协议不兼容时才复用或修改项目专属上传逻辑。
|
|
||||||
|
|
||||||
### 2. 确认发布边界
|
|
||||||
|
|
||||||
上传是外部写操作。仅当用户明确要求发布、上传或提交时执行;若用户只要求查看、
|
|
||||||
诊断或构建,则停在相应阶段。
|
|
||||||
|
|
||||||
执行上传前确认:
|
|
||||||
|
|
||||||
- 目标服务和仓库来自项目配置或用户输入,不猜测生产端点。
|
|
||||||
- 认证令牌已通过环境变量、密钥系统或项目 `.env` 提供。
|
|
||||||
- 目标版本、架构和产物路径能够从构建配置推导。
|
|
||||||
- 相同版本是否允许覆盖;无法确认且可能覆盖时,先询问用户。
|
|
||||||
|
|
||||||
绝不把令牌写入命令输出、文件、提交或最终回复。不要用 `set -x` 执行含凭据的脚本。
|
|
||||||
|
|
||||||
如果项目根目录存在 `.env`,且当前 shell 尚未提供所需变量,上传前必须显式加载
|
|
||||||
该文件;上传脚本不会自动读取 `.env`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
set -a
|
|
||||||
. "$PROJECT_ROOT/.env"
|
|
||||||
set +a
|
|
||||||
```
|
|
||||||
|
|
||||||
加载后确认 `DEB_SERVER_URL`、`DEB_REPOSITORY`、`DEB_TOKEN` 已非空;如配置了自定义
|
|
||||||
上传路径,也确认 `DEB_UPLOAD_PATH`。不得输出 `.env` 内容、令牌或完整环境变量值。
|
|
||||||
当前 shell 中已显式设置的值优先于 `.env`,若需覆盖 `.env`,加载后重新导出显式值。
|
|
||||||
|
|
||||||
### 3. 构建包
|
|
||||||
|
|
||||||
使用项目声明的构建目标,并显式传入版本。例如项目提供 Make 目标时:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make build-deb VERSION="$RELEASE_VERSION"
|
|
||||||
```
|
|
||||||
|
|
||||||
如果构建目标会自动上传,而当前仅获构建授权,应改用其纯构建子目标。执行前检查
|
|
||||||
所需工具和环境,例如 Docker、`dpkg-deb`、编译器、SSH 访问或前端工具链。
|
|
||||||
|
|
||||||
不得擅自清理宽泛目录。若脚本包含 `rm -rf`,先解析并确认目标是明确、受限的构建目录。
|
|
||||||
|
|
||||||
### 4. 上传前检查
|
|
||||||
|
|
||||||
定位唯一目标产物;若匹配多个包,不凭文件时间猜测:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
find <artifact-dir> -maxdepth 2 -type f -name '*.deb' -print
|
|
||||||
dpkg-deb --info <package.deb>
|
|
||||||
dpkg-deb --contents <package.deb>
|
|
||||||
```
|
|
||||||
|
|
||||||
至少验证:
|
|
||||||
|
|
||||||
- 文件存在、非空且 `dpkg-deb --info` 成功。
|
|
||||||
- `Package`、`Version`、`Architecture` 与本次发布一致。
|
|
||||||
- 包内容包含预期的主程序或关键文件。
|
|
||||||
- maintainer scripts 存在时权限正确,且没有明显的宿主机破坏性操作。
|
|
||||||
|
|
||||||
建议记录 SHA-256:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sha256sum <package.deb>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 发布
|
|
||||||
|
|
||||||
解析当前 `SKILL.md` 所在目录,以绝对路径调用随 skill 分发的脚本:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEB_SERVER_URL="$DEB_SERVER_URL" \
|
|
||||||
DEB_TOKEN="$DEB_TOKEN" \
|
|
||||||
DEB_REPOSITORY="$DEB_REPOSITORY" \
|
|
||||||
<skill-dir>/scripts/upload_deb.sh <exact-package-path.deb>
|
|
||||||
```
|
|
||||||
|
|
||||||
执行上述命令前,若配置来自项目 `.env`,先按第 2 步加载 `.env`;不要把 `.env`
|
|
||||||
复制到 skill 或项目之外的临时位置,也不要把 token 作为命令行参数。
|
|
||||||
|
|
||||||
不要把脚本复制进当前项目,也不要将 token 作为命令行参数。脚本默认请求
|
|
||||||
`/api/v2/upload/package`,以 multipart 字段 `package`、`token`、
|
|
||||||
`repository_name` 上传,接受 `200` 和 `201` 为成功。端点路径不同时可设置
|
|
||||||
`DEB_UPLOAD_PATH`。
|
|
||||||
|
|
||||||
调用前确认目标服务使用上述协议;不兼容时不要强行调用。传入刚刚校验过的确切路径,
|
|
||||||
不要使用宽泛 glob。项目已有 `make upload-deb` 时,检查它是否只是包装了同一协议:
|
|
||||||
如果是,直接使用 skill 脚本;若 CI 或其他人仍依赖 Make 目标,可将目标改为调用已安装
|
|
||||||
skill 的脚本,但不要提交脚本副本。
|
|
||||||
|
|
||||||
脚本支持多个确切文件路径,会汇总每个文件的结果,并在任一失败时返回非零。
|
|
||||||
|
|
||||||
### 6. 验证与汇报
|
|
||||||
|
|
||||||
发布成功不能只依据“curl 已执行”。综合检查:
|
|
||||||
|
|
||||||
- 上传命令退出码为零。
|
|
||||||
- HTTP 状态和响应体明确表示成功。
|
|
||||||
- 若仓库提供只读查询、索引或下载地址,再确认该包和版本已可见。
|
|
||||||
- 若索引更新是异步的,报告“上传已接受,索引尚待更新”,不要声称已完全可用。
|
|
||||||
|
|
||||||
最终回复给出:
|
|
||||||
|
|
||||||
- 包名、版本、架构。
|
|
||||||
- 产物路径和 SHA-256。
|
|
||||||
- 目标服务/仓库的非敏感标识。
|
|
||||||
- 构建、上传及仓库可见性各自的验证结果。
|
|
||||||
- 任何未完成项或回滚/覆盖风险。
|
|
||||||
|
|
||||||
## 修改已有发布逻辑时
|
|
||||||
|
|
||||||
- 保持项目现有变量名和调用入口,避免无关重构。
|
|
||||||
- 修复行为缺陷时增加最小静态检查或可离线运行的测试。
|
|
||||||
- 可用 `bash -n` 检查脚本语法;项目有 ShellCheck 时一并运行。
|
|
||||||
- skill 自带上传脚本是 SSOT;通用上传行为的修改应落在
|
|
||||||
`skills/deb-publisher/scripts/upload_deb.sh`,不要同步复制到业务项目。
|
|
||||||
- 不通过真实生产上传来测试脚本,除非用户明确授权并给出测试版本或测试仓库。
|
|
||||||
|
|
||||||
## 完成标准
|
|
||||||
|
|
||||||
- 仅分析:入口、调用链、配置来源和风险已被准确说明。
|
|
||||||
- 仅构建:DEB 已生成,元数据、内容和校验和通过检查,未发生上传。
|
|
||||||
- 发布:构建检查通过,服务端接受上传,且仓库可见性已验证或被准确标记为待更新。
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# deployer
|
||||||
|
|
||||||
|
把「一堆 VPS / NAS 上的 Docker 服务」变成一个 Git 仓库就能管的事:仓库里只放服务配置(数据),
|
||||||
|
部署、同步、升级的方法和脚本全部由这个 skill 自带,换台电脑、换个项目都能直接用。
|
||||||
|
|
||||||
|
支持两种用法:
|
||||||
|
|
||||||
|
- **独立配置中心**:一个专门的 Git 仓库管所有机器的所有服务(如 app00)
|
||||||
|
- **项目内环境**:在普通项目里放 `.skiff/deployer/{prod,test,dev}/`,
|
||||||
|
把这个项目的生产/测试/开发环境也用同一套流程部署
|
||||||
|
|
||||||
|
## 什么时候使用
|
||||||
|
|
||||||
|
- 想用一套固定流程把本地改好的 Docker Compose 配置发到某台服务器
|
||||||
|
- 要升级某个服务的镜像版本、重启服务、看远程容器状态和日志
|
||||||
|
- 有编译好的 .deb 包要装到某台机器上(scp 上传安装,或从 URL 直接拉)
|
||||||
|
- 新加一个服务、把服务从一台机器挪到另一台、或下线旧服务
|
||||||
|
- 想给当前项目加 prod/test/dev 三套远程环境并随时部署其中一套
|
||||||
|
- 需要一张「哪台机器跑哪些服务」的清单
|
||||||
|
|
||||||
|
## 使用前准备
|
||||||
|
|
||||||
|
- 本机装有 Python 3、`rsync`、`ssh`
|
||||||
|
- 目标机器装好 Docker + Docker Compose v2
|
||||||
|
- `~/.ssh/config` 里为每台机器配好 Host 别名,且能免密(或 agent)登录
|
||||||
|
- 知道每个服务的运行时数据放在哪(这些目录不能被同步覆盖)
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 独立配置中心
|
||||||
|
帮我把 vyyo1/naiveproxy 的配置改完部署上去
|
||||||
|
升级 vora3/gpt-load 的镜像版本
|
||||||
|
列一下现在所有服务和各自在哪台机器上
|
||||||
|
新增一个服务 uptime 到 vora3,先帮我建好目录结构
|
||||||
|
vhom1 上那个 naiveproxy 为什么 sync 失败?
|
||||||
|
|
||||||
|
# deb 包安装
|
||||||
|
把 ./gpt-load_1.2.0_amd64.deb 装到 web1 上
|
||||||
|
把这个目录里的三个 .deb 都推到 deploy@nas 再安装
|
||||||
|
web1 能出网,直接让它从 https://... 把包拉下来装
|
||||||
|
|
||||||
|
# 项目内环境
|
||||||
|
给这个项目建好 .skiff/deployer,prod 和 test 分别放到两台机器上
|
||||||
|
把 test 环境重新部署一下
|
||||||
|
prod 的 compose 加个 redis,改完发上去
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent 会做什么
|
||||||
|
|
||||||
|
1. 读服务/环境目录(及共享的父目录)的 `_config.yaml`,确定目标机器和远程路径;
|
||||||
|
项目内布局从 `.skiff/deployer/` 自动发现,无需额外配置
|
||||||
|
2. 用 skill 自带脚本把本地目录同步到远程(rsync,自动排除 `data/`、`_data/`)
|
||||||
|
3. 在远程执行对应的 `docker compose` 操作(启动 / 重建 / 升级 / 重启)
|
||||||
|
4. deb 包安装走独立脚本:scp 上传到暂存目录后远程 apt 安装,失败自动修依赖
|
||||||
|
5. 同步后查看容器状态和日志确认生效
|
||||||
|
6. 只针对你指定的那一个服务操作,不会批量动整台机器
|
||||||
|
|
||||||
|
项目内布局下,远程目录名自动带上项目前缀(如 `my-project-prod`),
|
||||||
|
避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。
|
||||||
|
|
||||||
|
重要边界:同步使用 `--delete`,远程多余的文件会被删除;数据库、证书等运行时数据
|
||||||
|
必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、清理远程文件的操作会先向你确认。
|
||||||
|
|
||||||
|
## 如何判断完成
|
||||||
|
|
||||||
|
- 脚本输出显示同步完成、远程命令执行成功
|
||||||
|
- `ps` 显示容器 Up、`logs` 无报错;升级后镜像 tag 与配置一致
|
||||||
|
- deb 安装后 `ssh <node> dpkg -l` 能看到目标包,服务能正常启动
|
||||||
|
- 域名/端口类服务能 curl 通
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
---
|
||||||
|
name: deployer
|
||||||
|
description: >-
|
||||||
|
管理多 VPS / NAS 的 Docker Compose 配置中心:仓库只存服务数据(compose.yaml、静态配置),
|
||||||
|
部署方法与脚本由本 skill 提供。当用户要求部署、同步、升级、重启远程 Docker 服务,
|
||||||
|
新增/迁移/下线服务,梳理节点与服务清单,向节点分发安装 deb 包(scp 上传 + dpkg/apt 安装,
|
||||||
|
或从 URL 远程拉取安装),或提到 make sync/deploy/upgrade/TGT、_config.yaml、rsync 同步、
|
||||||
|
tar over SSH、Synology NAS 部署失败时使用。
|
||||||
|
---
|
||||||
|
|
||||||
|
# deployer:多机 Compose 配置中心
|
||||||
|
|
||||||
|
仓库 = 数据(各机器的 `compose.yaml` 与静态配置);方法 = 本 skill 的脚本与规范。
|
||||||
|
本地改配置 → skill 脚本同步到对应 SSH 节点 → 远程 `docker compose` 应用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
- 部署 / 同步 / 升级 / 重启某个远程 Docker Compose 服务
|
||||||
|
- 向节点安装 deb 包:scp 上传本地 .deb 后 dpkg/apt 安装,或远程从 URL 直接拉取安装
|
||||||
|
- 新增、迁移、下线一个服务;梳理「哪台机器跑什么」
|
||||||
|
- sync 失败排查、证书丢失、改了配置不生效等运维问题
|
||||||
|
- 提到 `make deploy TGT=...`、`TGT=`、`_config.yaml`、rsync/tar 同步
|
||||||
|
|
||||||
|
## 不适用
|
||||||
|
|
||||||
|
- 单机 docker 日常使用(无多机同步诉求)
|
||||||
|
- K8s / Nomad 等编排系统
|
||||||
|
- CI/CD 流水线构建发布(本流程是 push 式运维,不是流水线)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心模型(先读懂再动手)
|
||||||
|
|
||||||
|
- **仓库只放数据**:`compose.yaml`、Caddyfile、Traefik 动态配置等静态配置进 Git;
|
||||||
|
运行时数据(证书、数据库、上传文件)永不进 Git,也永不参与同步范围。
|
||||||
|
- **每个可部署服务目录必须有 `compose.yaml`**,且能解析出目标节点 `node`
|
||||||
|
(来自该目录、部署根或祖先目录的 `_config.yaml`,或父目录名恰为 SSH Host 别名)。
|
||||||
|
- **`node` 即 SSH Host 别名**(`~/.ssh/config`),支持 `user@host` 形式。
|
||||||
|
- `unused/` 下不参与自动发现与部署。
|
||||||
|
|
||||||
|
### 两种布局
|
||||||
|
|
||||||
|
**A. 独立配置中心仓库**(如 app00):仓库根即部署根,
|
||||||
|
`DEPLOYER_ROOT=/path/to/repo` 指定后按仓库内相对路径操作:
|
||||||
|
|
||||||
|
```
|
||||||
|
repo/
|
||||||
|
├── _config.yaml # 可选,全局默认
|
||||||
|
├── vyyo1/_config.yaml # node: vyyo1(主机目录)
|
||||||
|
│ └── naiveproxy/ # 服务目录:compose.yaml + 可选 _config.yaml
|
||||||
|
└── unused/
|
||||||
|
```
|
||||||
|
|
||||||
|
远程目录名 = 目录末级名:`vyyo1/naiveproxy` → `/opt/app/naiveproxy`。
|
||||||
|
|
||||||
|
**B. 项目内环境布局**:项目根放 `.skiff/deployer/{prod,test,dev}/`,
|
||||||
|
每个环境一个目录。从项目内任意位置运行脚本即自动发现(也可用 `DEPLOYER_ROOT`
|
||||||
|
显式指定),无需环境变量:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-project/
|
||||||
|
├── src/ ... # 项目本体
|
||||||
|
└── .skiff/deployer/
|
||||||
|
├── _config.yaml # 三个环境共享默认(node/base_path 等)
|
||||||
|
├── prod/
|
||||||
|
│ ├── compose.yaml # 生产 compose 与配置
|
||||||
|
│ └── _config.yaml # 环境级覆盖
|
||||||
|
├── test/compose.yaml
|
||||||
|
└── dev/compose.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
项目模式下远程目录名自动加项目前缀 `{git仓库名}-{env}`
|
||||||
|
(如 `my-project-prod`),防止同主机多项目的同名环境互相覆盖;
|
||||||
|
`_config.yaml` 写 `name:` 可显式指定。
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
### 0. 定位部署根
|
||||||
|
|
||||||
|
skill 目录下的 `scripts/deploy/` 是通用部署工具链(lib/sync/remote/list),
|
||||||
|
不依赖具体项目路径。部署根按以下顺序解析:
|
||||||
|
|
||||||
|
1. 环境变量 `DEPLOYER_ROOT` 显式指定(独立配置中心仓库用这个)
|
||||||
|
2. 从当前目录向上找 `.skiff/deployer/`(项目内环境布局自动发现)
|
||||||
|
3. skill 安装位置兜底(仅用于查看,没有可部署服务)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# <skill-dir> = 本 SKILL.md 所在目录,先解析出来记下
|
||||||
|
# 布局 A:显式指定仓库根
|
||||||
|
export DEPLOYER_ROOT=/path/to/your/compose-repo
|
||||||
|
python3 <skill-dir>/scripts/deploy/list.py
|
||||||
|
|
||||||
|
# 布局 B:在项目内直接跑即可(cwd 在项目里)
|
||||||
|
python3 <skill-dir>/scripts/deploy/list.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. 摸底:列出服务与节点
|
||||||
|
|
||||||
|
上一步的 `list.py` 输出全部服务与节点分布;新增环境/服务后重跑确认被发现。
|
||||||
|
项目布局下 `prod/test/dev` 各显示为 `{项目名}-{env}`。
|
||||||
|
|
||||||
|
### 2. 解析单个服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看 node、远程路径、排除规则(sync.py 干跑会打印这些信息)
|
||||||
|
python3 <skill-dir>/scripts/deploy/sync.py <service-path>
|
||||||
|
```
|
||||||
|
|
||||||
|
或直接读服务目录及祖先的 `_config.yaml`。
|
||||||
|
|
||||||
|
### 3. 命令选择(语义严格区分)
|
||||||
|
|
||||||
|
| 意图 | 命令 |
|
||||||
|
|------|------|
|
||||||
|
| 只同步文件,不动容器 | `sync.py <svc>` |
|
||||||
|
| 应用 compose/配置变更 | `sync.py <svc> && remote.py <svc> up` |
|
||||||
|
| 改配置后强制重建 | `remote.py <svc> recreate`(配合前置 sync) |
|
||||||
|
| 镜像 tag 变更升级 | `sync.py <svc> && remote.py <svc> upgrade` |
|
||||||
|
| 仅重启,不同步文件 | `remote.py <svc> restart` |
|
||||||
|
| 排查 | `remote.py <svc> ps` / `remote.py <svc> logs` |
|
||||||
|
|
||||||
|
### 4. 项目侧 Makefile(可选薄封装)
|
||||||
|
|
||||||
|
若项目有 Makefile 封装,命令形如 `make deploy TGT=<服务路径>`。
|
||||||
|
没有 Makefile 时直接调 python 脚本即可,不要新建封装层。
|
||||||
|
|
||||||
|
### 5. 新增服务 / 环境 checklist
|
||||||
|
|
||||||
|
独立仓库布局:
|
||||||
|
|
||||||
|
1. 在合适分类目录创建服务文件夹,写 `compose.yaml`
|
||||||
|
2. 在服务目录或祖先目录放 `_config.yaml`(至少能解析出 `node`)
|
||||||
|
3. 有运行时目录 → 加进 `sync_exclude`
|
||||||
|
4. 远程首次建目录:`ssh <node> "mkdir -p <base_path>/<name>"`
|
||||||
|
5. 首次部署:sync + up
|
||||||
|
6. 验证:ps + logs,必要时 curl/ssh 检查端口
|
||||||
|
|
||||||
|
项目环境布局:
|
||||||
|
|
||||||
|
1. 项目根建 `.skiff/deployer/{env}/`(env 通常为 prod/test/dev)
|
||||||
|
2. 每个环境写 `compose.yaml`;三个环境共享的 node/base_path 放
|
||||||
|
`.skiff/deployer/_config.yaml`
|
||||||
|
3. 环境有差异(不同主机、不同排除项)→ 在该环境的 `_config.yaml` 覆盖
|
||||||
|
4. 同名冲突或需要固定远程目录名 → `_config.yaml` 写 `name:`
|
||||||
|
5. 首次部署前确认目标主机的远程目录不存在旧内容(rsync `--delete` 会清掉)
|
||||||
|
|
||||||
|
### 6. 下线服务
|
||||||
|
|
||||||
|
独立仓库布局:配置移入 `unused/`(自动脱离发现体系),远程按需手动清理:
|
||||||
|
`ssh <node> "cd <base_path>/<name> && docker compose down"`,数据卷按需保留或删除。
|
||||||
|
项目环境布局:删除对应 `.skiff/deployer/{env}/` 目录即可脱离发现体系,远程清理同上。
|
||||||
|
|
||||||
|
### 7. 向节点安装 deb 包
|
||||||
|
|
||||||
|
`deb.py` 把 deb 包发到节点并安装。目标两种写法:仓库内目录
|
||||||
|
(复用 `_config.yaml` 继承链解析 node/port/identity_file,如 `hosts/web1`),
|
||||||
|
或裸 SSH 别名 / `user@host`(须在 `~/.ssh/config` 中,可加 `--port`/`--identity`)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本地 .deb → scp 上传 → 远程 apt 安装(失败自动 apt -f 修依赖),成功后删暂存包
|
||||||
|
python3 <skill-dir>/scripts/deploy/deb.py <target> push ./foo_1.0_amd64.deb --yes
|
||||||
|
|
||||||
|
# 仅上传到远程暂存目录(默认 {base_path}/.debs;裸主机为 /tmp/deployer-debs)
|
||||||
|
python3 <skill-dir>/scripts/deploy/deb.py <target> scp ./foo_1.0_amd64.deb
|
||||||
|
|
||||||
|
# 安装该节点暂存目录里已上传的全部 .deb(配合 scp 分步操作)
|
||||||
|
python3 <skill-dir>/scripts/deploy/deb.py <target> dpkg --yes
|
||||||
|
|
||||||
|
# 远程直接从 URL 下载安装(机器能出网时免上传)
|
||||||
|
python3 <skill-dir>/scripts/deploy/deb.py <target> apt https://example.com/foo_1.0_amd64.deb --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
- 非 root 用户走 `sudo -n`(需配好免密 sudo);`--yes` 传 `-y` 免交互,
|
||||||
|
无终端交互能力,没配 sudo 免密/密钥时会直接失败。
|
||||||
|
- 升级同版本号前想先看包信息:`ssh <node> "dpkg -I <暂存路径>"`;
|
||||||
|
装完验证:`ssh <node> "dpkg -l | grep <pkg>"`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- **禁止节点级批量操作**:所有 sync/up/recreate/upgrade/restart 必须按单服务执行。
|
||||||
|
批量升级风险过高,逐个来。
|
||||||
|
- **rsync 带 `--delete`**:远程多余文件会被删除。运行时数据必须放在
|
||||||
|
默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径
|
||||||
|
(如 `/data01/docker/<svc>/`),否则会被清掉。
|
||||||
|
- **镜像固定 tag**,不用 `:latest` 漂移;成对升级的服务(如 proxy 客户端/服务端)要同步升。
|
||||||
|
- **密钥**:优先放远程 `.env` 或环境变量,不要提交新密钥进 Git。
|
||||||
|
- **Git 安全**:不 `--force` 推送、不硬 reset,除非用户明确要求。
|
||||||
|
- **NAS / Synology 特例**:部分 NAS 的 SSH 用户禁用 rsync 协议(Permission denied)。
|
||||||
|
表现是 sync 报错但 ssh 正常。处理顺序:
|
||||||
|
1. 该节点 `_config.yaml` 写真实 `base_path`(如 `/volume1/docker`,避开符号链接路径)
|
||||||
|
2. 仍失败则手动 tar over SSH 推送:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar czf - -C <服务目录> . --exclude='data' --exclude='_data' \
|
||||||
|
| ssh <node> "mkdir -p <base_path>/<name> && cd <base_path>/<name> && tar xzf -"
|
||||||
|
ssh <node> "cd <base_path>/<name> && /usr/local/bin/docker compose up -d"
|
||||||
|
```
|
||||||
|
|
||||||
|
tar 不会删除远程多余文件;需清理旧文件时手动 SSH 删除。
|
||||||
|
3. Synology 上 docker 路径可能是 `/usr/local/bin/docker`
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `list.py` 输出全部服务与节点分布,数量与预期一致
|
||||||
|
- 每次 sync/deploy 后 `remote.py <svc> ps` 容器 Up、`logs` 无报错
|
||||||
|
- 升级后额外确认镜像 tag 与 compose.yaml 一致
|
||||||
|
- 改 Traefik/Caddy 路由后 curl 对应域名验证生效
|
||||||
|
|
||||||
|
## scripts/
|
||||||
|
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `scripts/deploy/lib.py` | 解析服务目录、合并继承 `_config.yaml`、SSH/rsync/scp 参数构造 |
|
||||||
|
| `scripts/deploy/sync.py` | rsync -avz --delete 同步;无 rsync 时 tar over SSH 兜底 |
|
||||||
|
| `scripts/deploy/remote.py` | SSH 远程 docker compose:up/recreate/restart/upgrade/ps/logs |
|
||||||
|
| `scripts/deploy/deb.py` | deb 包分发安装:push(scp+apt)/scp/dpkg/apt(URL) |
|
||||||
|
| `scripts/deploy/list.py` | 扫描全部可部署服务 |
|
||||||
|
|
||||||
|
## references/
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `references/config-reference.md` | `_config.yaml` 字段完整说明与继承合并规则 |
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# `_config.yaml` 配置参考
|
||||||
|
|
||||||
|
`_config.yaml` 供 skill 部署脚本解析,决定同步目标与排除规则。可放在**服务目录、部署根或其任意祖先目录**;子目录中的字段覆盖父目录(继承合并)。
|
||||||
|
|
||||||
|
## 放置位置(两种布局)
|
||||||
|
|
||||||
|
| 布局 | 部署根 | `_config.yaml` 典型位置 |
|
||||||
|
|------|--------|------------------------|
|
||||||
|
| 独立配置中心仓库(`DEPLOYER_ROOT` 指向) | 仓库根 | 主机目录 `vyyo1/_config.yaml`、服务目录 |
|
||||||
|
| 项目内环境 `.skiff/deployer/{env}/` | `.skiff/deployer/` | 根级共享默认、各环境目录覆盖 |
|
||||||
|
|
||||||
|
项目布局示例:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .skiff/deployer/_config.yaml — 三个环境共享
|
||||||
|
node: my-vps
|
||||||
|
base_path: /srv/apps
|
||||||
|
|
||||||
|
# .skiff/deployer/prod/_config.yaml — 仅生产环境差异
|
||||||
|
node: prod-vps # 覆盖父级
|
||||||
|
name: my-project-api # 可选,覆盖默认的 {项目名}-{env}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 字段一览
|
||||||
|
|
||||||
|
| 字段 | 必填 | 可继承 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `node` | 是 | 是 | SSH 目标主机别名 |
|
||||||
|
| `port` | 否 | 是 | SSH 端口,默认 22 |
|
||||||
|
| `identity_file` | 否 | 是 | SSH 私钥路径 |
|
||||||
|
| `base_path` | 否 | 是 | 远程部署根目录,默认 `/opt/app` |
|
||||||
|
| `sync_exclude` | 否 | 是 | rsync 排除目录列表 |
|
||||||
|
| `name` | 否 | 是 | 远程目录名覆盖(见下) |
|
||||||
|
|
||||||
|
## name(可选)
|
||||||
|
|
||||||
|
远程目录名默认取服务/环境文件夹最后一级名称。**项目内环境布局**默认改为
|
||||||
|
`{git仓库名}-{env}`(如 `my-project-prod`),防止同主机多项目的同名环境互相覆盖。
|
||||||
|
写 `name:` 显式指定最终远程目录名(不含 base_path 部分)。
|
||||||
|
|
||||||
|
## node(必填)
|
||||||
|
|
||||||
|
对应 `~/.ssh/config` 中的 `Host` 别名,支持带用户形式:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
node: deploy@prod
|
||||||
|
```
|
||||||
|
|
||||||
|
独立仓库布局默认远程目录名 = 服务文件夹最后一级名称:`vyyo1/naiveproxy` → `{base_path}/naiveproxy`;
|
||||||
|
项目环境布局见上文 name 一节。
|
||||||
|
兜底规则:合并后仍无 `node` 时,若**直接父目录名**是 SSH Host 别名则作为 node。
|
||||||
|
|
||||||
|
|
||||||
|
## port(可选)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
node: edge
|
||||||
|
port: 2222
|
||||||
|
```
|
||||||
|
|
||||||
|
sync 通过 `rsync -e "ssh -p PORT"` 连接,remote 使用 `ssh -p PORT`。
|
||||||
|
与 `~/.ssh/config` 的 `Port` 同时存在时以 `_config.yaml` 为准(命令行覆盖 config)。
|
||||||
|
|
||||||
|
## identity_file(可选)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
node: edge
|
||||||
|
identity_file: ~/.ssh/deploy_ed25519
|
||||||
|
```
|
||||||
|
|
||||||
|
须为绝对路径或 `~` 开头;私钥不进 Git。带 passphrase 的密钥需事先加入 ssh-agent,脚本不支持交互输入。
|
||||||
|
|
||||||
|
## base_path(可选)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
node: nas
|
||||||
|
base_path: /volume1/docker # 默认路径是符号链接的设备写真实路径
|
||||||
|
```
|
||||||
|
|
||||||
|
实际同步目标 `{base_path}/{服务名}`。
|
||||||
|
|
||||||
|
## sync_exclude(可选)
|
||||||
|
|
||||||
|
rsync 排除的目录名列表(仅目录名)。默认已排除 `data`、`_data`。
|
||||||
|
子级重新定义时**整体替换**父级列表(非追加):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sync_exclude:
|
||||||
|
- data
|
||||||
|
- _data
|
||||||
|
- logs
|
||||||
|
```
|
||||||
|
|
||||||
|
运行时数据放这些目录或 compose 挂载的远程绝对路径(如 `/data01/docker/<svc>/`),避免被 `--delete` 清掉。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 继承合并规则
|
||||||
|
|
||||||
|
1. 收集**部署根自身**的 `_config.yaml`(全局默认),再收集部署根到服务目录路径上的所有 `_config.yaml`
|
||||||
|
2. 按祖先 → 子顺序合并;同名字段后者覆盖前者;`sync_exclude` 整体替换
|
||||||
|
3. 无任何配置且父目录名为 SSH Host 别名 → 兜底为 node
|
||||||
|
|
||||||
|
## 配置组织建议
|
||||||
|
|
||||||
|
| 场景 | 建议 |
|
||||||
|
|------|------|
|
||||||
|
| 同一 SSH 主机多个服务 | 主机目录写一份 `node`/`base_path`,子服务免重复 |
|
||||||
|
| 各服务目标不同 | 服务目录单独写 `_config.yaml` |
|
||||||
|
| 个别覆盖 | 子目录只写差异字段 |
|
||||||
|
| 项目三环境同主机 | `.skiff/deployer/_config.yaml` 写共享 node,各环境只放差异 |
|
||||||
|
| 项目环境分属不同主机 | 各环境 `_config.yaml` 分别写 `node` |
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# hosts/nas/_config.yaml — 该节点下所有服务共享
|
||||||
|
node: nas
|
||||||
|
base_path: /volume1/docker
|
||||||
|
|
||||||
|
# hosts/web1/_config.yaml
|
||||||
|
node: web1
|
||||||
|
port: 2222
|
||||||
|
identity_file: ~/.ssh/web1_ed25519
|
||||||
|
|
||||||
|
# hosts/web2/special/_config.yaml — 覆盖父级的 node
|
||||||
|
node: deploy@web2-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
## 可部署服务判定
|
||||||
|
|
||||||
|
同时满足才被 `list.py` 发现:
|
||||||
|
|
||||||
|
- 目录内有 `compose.yaml`
|
||||||
|
- 能通过继承或兜底解析出 `node`
|
||||||
|
- 独立仓库布局:路径中不含 `unused/` 段
|
||||||
|
- 项目环境布局:位于部署根 `.skiff/deployer/` 内(其外的项目文件不扫描)
|
||||||
Executable
+201
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
远程分发并安装 deb 包。
|
||||||
|
|
||||||
|
目标 <target> 两种写法:
|
||||||
|
1. 仓库内目录(含其祖先的 _config.yaml,解析 node/port/identity_file),
|
||||||
|
例如 hosts/web1 或 hosts/web1/myapp —— 与 sync.py/remote.py 的目标语义一致。
|
||||||
|
2. 裸 SSH 别名或 user@host(须在 ~/.ssh/config 中),可配合 --port/--identity。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python deb.py <target> apt <deb-URL> # 远程 wget 下载 + apt 安装
|
||||||
|
python deb.py <target> push <本地.deb> [...] # scp 上传 + dpkg 安装(失败自动 apt -f 修依赖)
|
||||||
|
python deb.py <target> scp <本地.deb> [...] # 仅上传到远程暂存目录
|
||||||
|
python deb.py <target> dpkg # 安装该节点暂存目录里已有的包
|
||||||
|
|
||||||
|
选项:
|
||||||
|
--yes apt/dpkg 免交互(-y / --assume-yes)
|
||||||
|
--staging-dir 远程暂存目录;默认目录目标用 {base_path}/.debs,裸主机用 /tmp/deployer-debs
|
||||||
|
|
||||||
|
示例:
|
||||||
|
python deb.py hosts/web1/apt-proxy push ./gpt-load_1.0_amd64.deb
|
||||||
|
python deb.py deploy@web1 apt https://example.com/pkg.deb --yes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from lib import (
|
||||||
|
project_root,
|
||||||
|
run_remote,
|
||||||
|
scp_base_args,
|
||||||
|
service_info,
|
||||||
|
ssh_config_hosts,
|
||||||
|
)
|
||||||
|
|
||||||
|
BARE_HOST_STAGING = "/tmp/deployer-debs"
|
||||||
|
|
||||||
|
|
||||||
|
def apt_prefix() -> str:
|
||||||
|
"""root 直接跑 apt/dpkg,否则加 sudo -n(免密 sudo)。"""
|
||||||
|
return 'APT="apt-get"; [ "$(id -u)" = 0 ] || APT="sudo -n apt-get"'
|
||||||
|
|
||||||
|
|
||||||
|
class Target:
|
||||||
|
"""安装目标:仓库内服务目录继承配置,或 ~/.ssh/config 中的裸主机别名。"""
|
||||||
|
|
||||||
|
def __init__(self, spec: str, port: int | None, identity: str | None, staging: str) -> None:
|
||||||
|
self.spec = spec
|
||||||
|
abs_dir = (project_root() / spec).resolve()
|
||||||
|
if abs_dir.is_dir():
|
||||||
|
self.info = service_info(spec)
|
||||||
|
# 暂存目录只跟节点走(base_path/.debs),同一节点共享一个暂存区,
|
||||||
|
# scp 与 dpkg 分步操作时才不会因目录不同而对不上
|
||||||
|
default_staging = f"{self.info['base_path']}/.debs"
|
||||||
|
else:
|
||||||
|
host = spec.split("@", 1)[-1]
|
||||||
|
if host not in ssh_config_hosts():
|
||||||
|
print(
|
||||||
|
f"错误: {spec!r} 既不是仓库内的服务/主机目录,"
|
||||||
|
f"也不是 ~/.ssh/config 中的 Host 别名"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
self.info = {
|
||||||
|
"node": spec,
|
||||||
|
"port": port,
|
||||||
|
"identity_file": identity,
|
||||||
|
}
|
||||||
|
default_staging = BARE_HOST_STAGING
|
||||||
|
self.staging_dir = staging or default_staging
|
||||||
|
|
||||||
|
@property
|
||||||
|
def node(self) -> str:
|
||||||
|
return self.info["node"]
|
||||||
|
|
||||||
|
def ssh(self, script: str) -> int:
|
||||||
|
return run_remote(self.info, script)
|
||||||
|
|
||||||
|
def upload(self, local_paths: list[str]) -> list[str]:
|
||||||
|
"""scp 上传文件到暂存目录,返回远程侧文件名列表。"""
|
||||||
|
names = []
|
||||||
|
for p in local_paths:
|
||||||
|
name = Path(p).name
|
||||||
|
if not name.endswith(".deb"):
|
||||||
|
print(f"错误: 不是 .deb 文件: {p}")
|
||||||
|
sys.exit(1)
|
||||||
|
names.append(name)
|
||||||
|
|
||||||
|
remote = f"{self.node}:{shlex.quote(self.staging_dir)}/"
|
||||||
|
cmd = [*scp_base_args(self.info), *local_paths, remote]
|
||||||
|
print(f"正在上传到 {self.node}:{self.staging_dir}")
|
||||||
|
print(f"$ {' '.join(cmd)}")
|
||||||
|
print("-" * 60)
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"错误: scp 上传失败,退出码: {e.returncode}")
|
||||||
|
sys.exit(e.returncode or 1)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("错误: scp 命令未找到")
|
||||||
|
sys.exit(1)
|
||||||
|
return names
|
||||||
|
|
||||||
|
def install_staged(self, names: list[str], *, assume_yes: bool) -> int:
|
||||||
|
"""在远程对暂存目录里的指定包执行 apt 安装(失败自动 -f 修依赖)。"""
|
||||||
|
quoted = " ".join(shlex.quote(f"{self.staging_dir}/{n}") for n in names)
|
||||||
|
yes = " -y" if assume_yes else ""
|
||||||
|
script = "\n".join(
|
||||||
|
[
|
||||||
|
"set -e",
|
||||||
|
f"mkdir -p {shlex.quote(self.staging_dir)}",
|
||||||
|
apt_prefix(),
|
||||||
|
f"$APT install{yes} {quoted} || $APT install{yes} -f",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return self.ssh(script)
|
||||||
|
|
||||||
|
|
||||||
|
def _quote(value: str) -> str:
|
||||||
|
return "'" + value.replace("'", "'\\''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def action_apt(target: Target, deb: str, *, assume_yes: bool) -> int:
|
||||||
|
"""远程从 URL 安装:wget 到暂存目录后 apt-get install。"""
|
||||||
|
if not deb.startswith(("http://", "https://", "ftp://")):
|
||||||
|
print(f"提示: {deb!r} 不是 URL,请改用 push 上传本地包")
|
||||||
|
return 2
|
||||||
|
filename = deb.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
staged = f"{target.staging_dir}/{filename}"
|
||||||
|
yes = " -y" if assume_yes else ""
|
||||||
|
script = "\n".join(
|
||||||
|
[
|
||||||
|
"set -e",
|
||||||
|
f"mkdir -p {shlex.quote(target.staging_dir)}",
|
||||||
|
apt_prefix(),
|
||||||
|
f"wget -qO {_quote(staged)}.part {_quote(deb)}",
|
||||||
|
f"mv {_quote(staged)}.part {_quote(staged)}",
|
||||||
|
f"$APT install{yes} {_quote(staged)}",
|
||||||
|
f"rm -f {_quote(staged)}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return target.ssh(script)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="向远程节点分发并安装 deb 包")
|
||||||
|
parser.add_argument("target", help="仓库内目录(hosts/web1[/svc])或 SSH 别名(user@host)")
|
||||||
|
parser.add_argument("action", choices=["apt", "push", "scp", "dpkg"])
|
||||||
|
parser.add_argument("packages", nargs="*", help="apt 的 .deb URL;push/scp 的本地 .deb 文件")
|
||||||
|
parser.add_argument("--yes", action="store_true", help="apt/dpkg 免交互(-y / --assume-yes)")
|
||||||
|
parser.add_argument("--staging-dir", default="", help="远程暂存目录,默认 {base_path}/.debs")
|
||||||
|
parser.add_argument("--port", type=int, help="SSH 端口(仅裸主机目标)")
|
||||||
|
parser.add_argument("--identity", help="SSH 私钥路径(仅裸主机目标)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
os.chdir(project_root())
|
||||||
|
target = Target(args.target, args.port, args.identity, args.staging_dir)
|
||||||
|
|
||||||
|
if args.action == "apt":
|
||||||
|
if len(args.packages) != 1:
|
||||||
|
parser.error("apt 需要恰好一个 .deb URL")
|
||||||
|
return action_apt(target, args.packages[0], assume_yes=args.yes)
|
||||||
|
|
||||||
|
if args.action in ("push", "scp"):
|
||||||
|
if not args.packages:
|
||||||
|
parser.error(f"{args.action} 至少需要一个本地 .deb 文件")
|
||||||
|
missing = [p for p in args.packages if not Path(p).is_file()]
|
||||||
|
if missing:
|
||||||
|
print(f"错误: 本地文件不存在: {', '.join(missing)}")
|
||||||
|
return 1
|
||||||
|
names = target.upload(args.packages)
|
||||||
|
if args.action == "scp":
|
||||||
|
print(f"已上传: {', '.join(names)} -> {target.node}:{target.staging_dir}")
|
||||||
|
print("后续安装: python deb.py {} dpkg".format(args.target))
|
||||||
|
return 0
|
||||||
|
rc = target.install_staged(names, assume_yes=args.yes)
|
||||||
|
if rc == 0:
|
||||||
|
staged = " ".join(shlex.quote(f"{target.staging_dir}/{n}") for n in names)
|
||||||
|
target.ssh(f"rm -f {staged}")
|
||||||
|
return rc
|
||||||
|
|
||||||
|
# dpkg:安装暂存目录中已上传的全部包
|
||||||
|
yes = " -y" if args.yes else ""
|
||||||
|
glob = shlex.quote(f"{target.staging_dir}") + "/*.deb"
|
||||||
|
script = "\n".join(
|
||||||
|
[
|
||||||
|
apt_prefix(),
|
||||||
|
f"ls -1 {glob} >/dev/null 2>&1 || {{ echo '错误: {target.node}:{target.staging_dir} 没有暂存的 .deb'; exit 1; }}",
|
||||||
|
f"$APT install{yes} {glob} || $APT install{yes} -f",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return target.ssh(script)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+363
@@ -0,0 +1,363 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""deployer 部署工具公共模块:解析部署根、服务目录与 _config.yaml。
|
||||||
|
|
||||||
|
支持两种布局:
|
||||||
|
1. 独立配置中心仓库(DEPLOYER_ROOT 指向,或 skill 安装位置)
|
||||||
|
2. 项目内环境目录 .skiff/deployer/{prod,test,dev}/(从 CWD 自动发现)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_BASE_PATH = "/opt/app"
|
||||||
|
DEFAULT_SYNC_EXCLUDES = ("data", "_data")
|
||||||
|
_SKILL_DIR = Path(__file__).resolve().parent.parent # scripts/
|
||||||
|
PROJECT_ROOT: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_project_root() -> Path:
|
||||||
|
"""部署根:DEPLOYER_ROOT > 从 CWD 向上找 .skiff/deployer > skill 安装位置。"""
|
||||||
|
env = os.environ.get("DEPLOYER_ROOT", "").strip()
|
||||||
|
if env:
|
||||||
|
p = Path(env).expanduser().resolve()
|
||||||
|
if not p.is_dir():
|
||||||
|
print(f"错误: DEPLOYER_ROOT 不是目录: {p}")
|
||||||
|
sys.exit(1)
|
||||||
|
return p
|
||||||
|
cur = Path.cwd()
|
||||||
|
while True:
|
||||||
|
cand = cur / ".skiff" / "deployer"
|
||||||
|
if cand.is_dir():
|
||||||
|
return cand
|
||||||
|
if cur == cur.parent:
|
||||||
|
break
|
||||||
|
cur = cur.parent
|
||||||
|
return _SKILL_DIR.parent
|
||||||
|
|
||||||
|
|
||||||
|
def project_root() -> Path:
|
||||||
|
global PROJECT_ROOT
|
||||||
|
if PROJECT_ROOT is None:
|
||||||
|
PROJECT_ROOT = _find_project_root()
|
||||||
|
return PROJECT_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def in_project_layout(root: Path | None = None) -> bool:
|
||||||
|
"""部署根是否为某项目内的 .skiff/deployer/。"""
|
||||||
|
root = root or project_root()
|
||||||
|
return root.name == "deployer" and root.parent.name == ".skiff"
|
||||||
|
|
||||||
|
|
||||||
|
def project_display_name(root: Path | None = None) -> str:
|
||||||
|
"""项目名:git 仓库名优先,否则 .skiff 的父目录名。"""
|
||||||
|
root = root or project_root()
|
||||||
|
anchor = root.parent.parent if in_project_layout(root) else root
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["git", "-C", str(anchor), "rev-parse", "--show-toplevel"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if proc.returncode == 0:
|
||||||
|
return Path(proc.stdout.strip()).name
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return anchor.name
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: os.PathLike | str, *, required: bool = True) -> dict:
|
||||||
|
"""解析精简版 _config.yaml(仅支持本项目使用的字段)。"""
|
||||||
|
config: dict = {}
|
||||||
|
current_list_key: str | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path, encoding="utf-8") as f:
|
||||||
|
for raw_line in f:
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith("- ") and current_list_key:
|
||||||
|
config.setdefault(current_list_key, []).append(line[2:].strip())
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_list_key = None
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
current_list_key = key
|
||||||
|
config.setdefault(key, [])
|
||||||
|
continue
|
||||||
|
|
||||||
|
config[key] = value
|
||||||
|
except FileNotFoundError:
|
||||||
|
if required:
|
||||||
|
print(f"错误: 配置文件不存在: {config_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def config_paths_for_service(service_dir: str) -> list[Path]:
|
||||||
|
"""收集部署根自身及服务目录各层 _config.yaml(祖先在前,服务目录在后)。
|
||||||
|
|
||||||
|
部署根的 _config.yaml(如 .skiff/deployer/_config.yaml)作为全局默认,
|
||||||
|
对所有环境/服务生效。
|
||||||
|
"""
|
||||||
|
root = project_root()
|
||||||
|
rel = Path(service_dir)
|
||||||
|
paths: list[Path] = []
|
||||||
|
root_config = root / "_config.yaml"
|
||||||
|
if root_config.is_file():
|
||||||
|
paths.append(root_config)
|
||||||
|
for depth in range(1, len(rel.parts) + 1):
|
||||||
|
config_path = root / Path(*rel.parts[:depth]) / "_config.yaml"
|
||||||
|
if config_path.is_file() and config_path != root_config:
|
||||||
|
paths.append(config_path)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def merge_service_config(service_dir: str) -> tuple[dict, list[Path]]:
|
||||||
|
"""合并服务目录及其祖先的 _config.yaml,子级覆盖父级。"""
|
||||||
|
merged: dict = {}
|
||||||
|
sources = config_paths_for_service(service_dir)
|
||||||
|
for config_path in sources:
|
||||||
|
merged.update(load_config(config_path, required=True))
|
||||||
|
return merged, sources
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_config_hosts() -> set[str]:
|
||||||
|
"""读取 ~/.ssh/config 中的 Host 别名(不含通配符)。"""
|
||||||
|
global _SSH_HOSTS
|
||||||
|
if _SSH_HOSTS is not None:
|
||||||
|
return _SSH_HOSTS
|
||||||
|
|
||||||
|
hosts: set[str] = set()
|
||||||
|
config_path = Path.home() / ".ssh" / "config"
|
||||||
|
if config_path.is_file():
|
||||||
|
for raw_line in config_path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if not line.lower().startswith("host "):
|
||||||
|
continue
|
||||||
|
for host in line.split()[1:]:
|
||||||
|
if "*" in host or "?" in host or "!" in host:
|
||||||
|
continue
|
||||||
|
hosts.add(host)
|
||||||
|
|
||||||
|
_SSH_HOSTS = hosts
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
|
||||||
|
_SSH_HOSTS: set[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_port(raw: str, *, strict: bool = True) -> int | None:
|
||||||
|
"""解析 SSH 端口,无效时 strict 模式下退出。"""
|
||||||
|
try:
|
||||||
|
port = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
if strict:
|
||||||
|
print(f"错误: port 必须是整数: {raw!r}")
|
||||||
|
sys.exit(1)
|
||||||
|
return None
|
||||||
|
if not 1 <= port <= 65535:
|
||||||
|
if strict:
|
||||||
|
print(f"错误: port 超出有效范围 1-65535: {port}")
|
||||||
|
sys.exit(1)
|
||||||
|
return None
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_identity_file(raw: str, *, strict: bool = True) -> str | None:
|
||||||
|
"""解析 SSH 私钥路径(expanduser,须为绝对路径且文件存在)。"""
|
||||||
|
path = os.path.expanduser(raw)
|
||||||
|
if not os.path.isabs(path):
|
||||||
|
if strict:
|
||||||
|
print(f"错误: identity_file 必须是绝对路径或 ~ 开头: {raw!r}")
|
||||||
|
sys.exit(1)
|
||||||
|
return None
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
if strict:
|
||||||
|
print(f"错误: identity_file 不存在: {path}")
|
||||||
|
sys.exit(1)
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_base_args(info: dict) -> list[str]:
|
||||||
|
"""ssh 可执行文件及 -p / -i 等选项(不含 host 与 remote command)。"""
|
||||||
|
args = ["ssh"]
|
||||||
|
port = info.get("port")
|
||||||
|
if port is not None:
|
||||||
|
args.extend(["-p", str(port)])
|
||||||
|
identity_file = info.get("identity_file")
|
||||||
|
if identity_file:
|
||||||
|
args.extend(["-i", identity_file])
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_cmd(info: dict, remote_command: str) -> list[str]:
|
||||||
|
"""构建 ssh 命令行(含可选 -p / -i)。"""
|
||||||
|
return [*ssh_base_args(info), info["node"], remote_command]
|
||||||
|
|
||||||
|
|
||||||
|
def rsync_ssh_args(info: dict) -> list[str]:
|
||||||
|
"""rsync 需自定义 ssh 时通过 -e 传入 port / identity_file。"""
|
||||||
|
base = ssh_base_args(info)
|
||||||
|
if len(base) == 1:
|
||||||
|
return []
|
||||||
|
return ["-e", shlex.join(base)]
|
||||||
|
|
||||||
|
|
||||||
|
def scp_base_args(info: dict) -> list[str]:
|
||||||
|
"""scp 可执行文件及 -P / -i 选项(注意 scp 的端口是大写 P)。"""
|
||||||
|
args = ["scp"]
|
||||||
|
port = info.get("port")
|
||||||
|
if port is not None:
|
||||||
|
args.extend(["-P", str(port)])
|
||||||
|
identity_file = info.get("identity_file")
|
||||||
|
if identity_file:
|
||||||
|
args.extend(["-i", identity_file])
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def run_remote(info: dict, remote_command: str, *, remote_cwd: str | None = None) -> int:
|
||||||
|
"""在远程节点执行 shell 命令并返回退出码;remote_cwd 提供时先切换目录。"""
|
||||||
|
shell = f"cd {remote_cwd} && {remote_command}" if remote_cwd else remote_command
|
||||||
|
endpoint = info["node"]
|
||||||
|
if info.get("port") is not None:
|
||||||
|
endpoint += f":{info['port']}"
|
||||||
|
location = f"{endpoint} {remote_cwd}" if remote_cwd else endpoint
|
||||||
|
print(f"远程执行: {location}")
|
||||||
|
print(f"$ {remote_command}")
|
||||||
|
print("-" * 60)
|
||||||
|
try:
|
||||||
|
return subprocess.run(ssh_cmd(info, shell), check=False).returncode
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("错误: ssh 命令未找到")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def node_from_parent_dir(service_dir: str) -> str | None:
|
||||||
|
"""父目录名若是 SSH Host 别名,则作为 node。"""
|
||||||
|
parent = Path(service_dir.rstrip("/")).parent.name
|
||||||
|
if not parent or parent == ".":
|
||||||
|
return None
|
||||||
|
if parent in ssh_config_hosts():
|
||||||
|
return parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def service_info(service_dir: str, *, strict: bool = True) -> dict | None:
|
||||||
|
"""解析单个服务目录,返回 node、远程路径等信息。"""
|
||||||
|
root = project_root()
|
||||||
|
abs_dir = (root / service_dir).resolve()
|
||||||
|
if not abs_dir.is_dir():
|
||||||
|
if strict:
|
||||||
|
print(f"错误: 服务目录不存在: {service_dir}")
|
||||||
|
sys.exit(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
env_name = abs_dir.name
|
||||||
|
rel_dir = str(abs_dir.relative_to(root))
|
||||||
|
config, sources = merge_service_config(rel_dir)
|
||||||
|
|
||||||
|
# 远程目录名:_config.yaml 的 name 显式覆盖;项目布局默认 {项目名}-{env}
|
||||||
|
# 防止同主机上多个项目的 prod/test 相互覆盖
|
||||||
|
if "name" in config:
|
||||||
|
name = str(config["name"])
|
||||||
|
elif in_project_layout(root):
|
||||||
|
name = f"{project_display_name(root)}-{env_name}"
|
||||||
|
if strict:
|
||||||
|
print(f"提示: 项目模式,远程名自动加前缀: {name!r}(_config.yaml 写 name: 可覆盖)")
|
||||||
|
else:
|
||||||
|
name = env_name
|
||||||
|
|
||||||
|
node = config.get("node")
|
||||||
|
if not node:
|
||||||
|
node = node_from_parent_dir(rel_dir)
|
||||||
|
if node and strict and not config:
|
||||||
|
print(f"提示: 未找到 _config.yaml,使用父目录 SSH 主机 {node!r}")
|
||||||
|
|
||||||
|
if not node:
|
||||||
|
if strict:
|
||||||
|
print(f"错误: 无法解析 node: {service_dir}")
|
||||||
|
print(" 请在服务目录或其祖先目录添加 _config.yaml,或确保父目录是 SSH 主机别名")
|
||||||
|
sys.exit(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
base_path = config.get("base_path", DEFAULT_BASE_PATH)
|
||||||
|
excludes = config.get("sync_exclude") or list(DEFAULT_SYNC_EXCLUDES)
|
||||||
|
remote_dir = f"{base_path}/{name}"
|
||||||
|
port = parse_port(config["port"], strict=strict) if "port" in config else None
|
||||||
|
identity_file = (
|
||||||
|
resolve_identity_file(config["identity_file"], strict=strict)
|
||||||
|
if "identity_file" in config
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"service_dir": str(abs_dir),
|
||||||
|
"name": name,
|
||||||
|
"node": node,
|
||||||
|
"port": port,
|
||||||
|
"identity_file": identity_file,
|
||||||
|
"base_path": base_path,
|
||||||
|
"remote_dir": remote_dir,
|
||||||
|
"sync_exclude": excludes,
|
||||||
|
"config_sources": [str(p.relative_to(root)) for p in sources],
|
||||||
|
}
|
||||||
|
local_config = abs_dir / "_config.yaml"
|
||||||
|
if strict and sources and not local_config.is_file():
|
||||||
|
rel_sources = info["config_sources"]
|
||||||
|
if len(rel_sources) == 1:
|
||||||
|
print(f"提示: 使用继承配置 {rel_sources[0]!r}")
|
||||||
|
else:
|
||||||
|
print(f"提示: 使用继承配置 {' -> '.join(rel_sources)!r}")
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def is_deployable_dir(path: Path, root: Path) -> bool:
|
||||||
|
if "unused" in path.parts or "__pycache__" in path.parts:
|
||||||
|
return False
|
||||||
|
if ".skiff" in path.parts and root.name != "deployer":
|
||||||
|
return False
|
||||||
|
if not (path / "compose.yaml").is_file():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
path.relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def discover_services(node: str | None = None) -> list[dict]:
|
||||||
|
"""扫描仓库内所有带 compose.yaml 且能解析 node 的服务。"""
|
||||||
|
root = project_root()
|
||||||
|
services: list[dict] = []
|
||||||
|
|
||||||
|
for compose_path in sorted(root.glob("**/compose.yaml")):
|
||||||
|
service_dir = compose_path.parent
|
||||||
|
if not is_deployable_dir(service_dir, root):
|
||||||
|
continue
|
||||||
|
rel = str(service_dir.relative_to(root))
|
||||||
|
info = service_info(rel, strict=False)
|
||||||
|
if info is None:
|
||||||
|
continue
|
||||||
|
if node is None or info["node"] == node:
|
||||||
|
services.append(info)
|
||||||
|
|
||||||
|
return services
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""列出仓库内所有可部署服务及其目标节点。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from lib import discover_services, project_root
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
root = project_root()
|
||||||
|
os.chdir(root)
|
||||||
|
services = discover_services()
|
||||||
|
if not services:
|
||||||
|
print("未找到任何可部署服务(需 compose.yaml 且能解析 node)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
by_node: dict[str, list[str]] = defaultdict(list)
|
||||||
|
for info in services:
|
||||||
|
rel = os.path.relpath(info["service_dir"], root)
|
||||||
|
by_node[info["node"]].append(rel)
|
||||||
|
|
||||||
|
print(f"共 {len(services)} 个服务,分布在 {len(by_node)} 个节点:\n")
|
||||||
|
for node in sorted(by_node):
|
||||||
|
print(f"[{node}]")
|
||||||
|
for service in by_node[node]:
|
||||||
|
print(f" - {service}")
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
在远程节点上执行 docker compose 操作。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python remote.py <服务目录> <命令>
|
||||||
|
|
||||||
|
命令:
|
||||||
|
up 启动/更新容器(不拉镜像)
|
||||||
|
recreate 强制重建容器(up -d --force-recreate,改配置后用)
|
||||||
|
restart 重启 compose 内所有服务
|
||||||
|
upgrade 拉取镜像并重建容器
|
||||||
|
ps 查看容器状态
|
||||||
|
logs 查看最近日志(非 follow)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from lib import project_root, run_remote, service_info
|
||||||
|
|
||||||
|
REMOTE_COMMANDS = {
|
||||||
|
"up": "docker compose up -d",
|
||||||
|
"recreate": "docker compose up -d --force-recreate",
|
||||||
|
"restart": "docker compose restart",
|
||||||
|
"upgrade": "docker compose pull && docker compose up -d",
|
||||||
|
"ps": "docker compose ps",
|
||||||
|
"logs": "docker compose logs --tail=100",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_for_service(service_dir: str, action: str) -> int:
|
||||||
|
info = service_info(service_dir)
|
||||||
|
compose_command = REMOTE_COMMANDS[action]
|
||||||
|
endpoint = info["node"]
|
||||||
|
if info.get("port") is not None:
|
||||||
|
endpoint = f"{endpoint}:{info['port']}"
|
||||||
|
print(f"服务: {service_dir} -> {endpoint}:{info['remote_dir']}")
|
||||||
|
return run_remote(info, compose_command, remote_cwd=info["remote_dir"])
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="在远程节点执行 docker compose 操作")
|
||||||
|
parser.add_argument("target", help="服务目录,例如 hosts/web1/myapp")
|
||||||
|
parser.add_argument(
|
||||||
|
"action",
|
||||||
|
choices=sorted(REMOTE_COMMANDS),
|
||||||
|
help="远程操作",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
os.chdir(project_root())
|
||||||
|
return run_for_service(args.target, args.action)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+145
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
目录同步脚本
|
||||||
|
将指定目录同步到远程机器
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python sync.py <目录路径>
|
||||||
|
例如: python sync.py hosts/web1/myapp
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from lib import DEFAULT_BASE_PATH, project_root, rsync_ssh_args, service_info, ssh_cmd
|
||||||
|
|
||||||
|
|
||||||
|
def sync_directory(info: dict) -> int:
|
||||||
|
if shutil.which("rsync"):
|
||||||
|
return _sync_rsync(info)
|
||||||
|
print("本机没有 rsync,改用 tar over SSH(不会删除远程多余文件)")
|
||||||
|
return _sync_tar(info)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_rsync(info: dict) -> int:
|
||||||
|
source_dir = info["service_dir"]
|
||||||
|
node = info["node"]
|
||||||
|
remote_path = f"{node}:{info['remote_dir']}"
|
||||||
|
|
||||||
|
rsync_cmd = [
|
||||||
|
"rsync",
|
||||||
|
"-avz",
|
||||||
|
"--delete",
|
||||||
|
*rsync_ssh_args(info),
|
||||||
|
]
|
||||||
|
for item in info["sync_exclude"]:
|
||||||
|
rsync_cmd.append(f"--exclude={item}/")
|
||||||
|
|
||||||
|
rsync_cmd.extend([f"{source_dir}/", remote_path])
|
||||||
|
|
||||||
|
print(f"正在同步 {source_dir} 到 {remote_path}")
|
||||||
|
if info["sync_exclude"]:
|
||||||
|
print(f"排除目录: {', '.join(info['sync_exclude'])}")
|
||||||
|
print(f"执行命令: {' '.join(rsync_cmd)}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(rsync_cmd, check=True)
|
||||||
|
print("-" * 60)
|
||||||
|
print("同步完成!")
|
||||||
|
return 0
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"错误: rsync 执行失败,退出码: {e.returncode}")
|
||||||
|
return 1
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("错误: rsync 命令未找到,请确保已安装 rsync")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_tar(info: dict) -> int:
|
||||||
|
source_dir = info["service_dir"]
|
||||||
|
remote_dir = info["remote_dir"]
|
||||||
|
tar_cmd = ["tar", "czf", "-", "-C", source_dir]
|
||||||
|
for item in info["sync_exclude"]:
|
||||||
|
tar_cmd.append(f"--exclude={item}")
|
||||||
|
tar_cmd.append(".")
|
||||||
|
|
||||||
|
remote_shell = (
|
||||||
|
f"mkdir -p {shlex.quote(remote_dir)} && "
|
||||||
|
f"tar xzf - -C {shlex.quote(remote_dir)}"
|
||||||
|
)
|
||||||
|
ssh = ssh_cmd(info, remote_shell)
|
||||||
|
print(f"正在同步 {source_dir} 到 {info['node']}:{remote_dir}")
|
||||||
|
if info["sync_exclude"]:
|
||||||
|
print(f"排除目录: {', '.join(info['sync_exclude'])}")
|
||||||
|
print(f"执行命令: tar | {' '.join(ssh)}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
tar = subprocess.Popen(tar_cmd, stdout=subprocess.PIPE)
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(ssh, stdin=tar.stdout, check=False)
|
||||||
|
finally:
|
||||||
|
if tar.stdout:
|
||||||
|
tar.stdout.close()
|
||||||
|
tar.wait()
|
||||||
|
if tar.returncode:
|
||||||
|
print(f"错误: tar 打包失败,退出码: {tar.returncode}")
|
||||||
|
return tar.returncode
|
||||||
|
if completed.returncode:
|
||||||
|
print(f"错误: 远程 tar 解包失败,退出码: {completed.returncode}")
|
||||||
|
return completed.returncode
|
||||||
|
print("-" * 60)
|
||||||
|
print("同步完成!")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="将指定目录同步到远程机器",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
示例:
|
||||||
|
python sync.py hosts/web1/myapp
|
||||||
|
python sync.py infra/traefik --base-path /opt/app
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"directory",
|
||||||
|
help="要同步的目录路径(相对项目根,例如: hosts/web1/myapp)",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--base-path",
|
||||||
|
default=DEFAULT_BASE_PATH,
|
||||||
|
help=f"远程基础路径(默认: {DEFAULT_BASE_PATH})",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
info = service_info(args.directory)
|
||||||
|
if args.base_path != DEFAULT_BASE_PATH:
|
||||||
|
name = info["name"]
|
||||||
|
info["base_path"] = args.base_path
|
||||||
|
info["remote_dir"] = f"{args.base_path}/{name}"
|
||||||
|
|
||||||
|
print(f"目标节点: {info['node']}")
|
||||||
|
if info.get("port") is not None:
|
||||||
|
print(f"SSH 端口: {info['port']}")
|
||||||
|
if info.get("identity_file"):
|
||||||
|
print(f"SSH 密钥: {info['identity_file']}")
|
||||||
|
print(f"源目录: {args.directory}")
|
||||||
|
print(f"远程路径: {info['remote_dir']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
return sync_directory(info)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -14,8 +14,7 @@ ORC 是显式调用的薄路由器:只把开发、版本发布、DEB 和 Docke
|
|||||||
## 使用前准备
|
## 使用前准备
|
||||||
|
|
||||||
- Orca 正在运行并启用了 orchestration。
|
- Orca 正在运行并启用了 orchestration。
|
||||||
- 安装本次需要的 `$ack`、`$manage-release`、`$deb-publisher` 或
|
- 安装本次需要的 `$ack`、`$manage-release` 或 `$builder`(DEB 与 Docker 共用)。
|
||||||
`$publish-docker-image`。
|
|
||||||
- ORC 直接使用 skill 内共享的 `config.yaml`,不需要在每个项目初始化配置。修改这份
|
- ORC 直接使用 skill 内共享的 `config.yaml`,不需要在每个项目初始化配置。修改这份
|
||||||
配置会影响所有项目;旧的项目级 `docs/orc/config.yaml` 不再参与解析。
|
配置会影响所有项目;旧的项目级 `docs/orc/config.yaml` 不再参与解析。
|
||||||
- `/usr/bin/python3`;ORC v2 配置必须保持为 JSON-compatible YAML。
|
- `/usr/bin/python3`;ORC v2 配置必须保持为 JSON-compatible YAML。
|
||||||
|
|||||||
+4
-4
@@ -73,8 +73,8 @@ JSON-compatible YAML,并由隔离的 Python 标准库解析。它固定包含
|
|||||||
`cursor-agent`。身份不明确时停止,不从用户任务文本、默认值或已安装 executable 猜测。
|
`cursor-agent`。身份不明确时停止,不从用户任务文本、默认值或已安装 executable 猜测。
|
||||||
确认宿主 CLI 可用;在 Cursor 中还要用 `cursor-agent --list-models`
|
确认宿主 CLI 可用;在 Cursor 中还要用 `cursor-agent --list-models`
|
||||||
核对精确模型 ID 对当前账号可见。
|
核对精确模型 ID 对当前账号可见。
|
||||||
4. 确认本次所需下游 Skill 已安装:`ack`、`manage-release`、`deb-publisher`、
|
4. 确认本次所需下游 Skill 已安装:`ack`、`manage-release`、`builder`。只检查实际
|
||||||
`publish-docker-image`。只检查实际会用到的项。
|
会用到的项;`deb` 与 `docker` 阶段都由 `$builder` 承载。
|
||||||
5. 解析 profile 时把项目根和目标 worktree 一并交给 resolver;只有 resolver 验证目标是
|
5. 解析 profile 时把项目根和目标 worktree 一并交给 resolver;只有 resolver 验证目标是
|
||||||
当前 Git 仓库已注册的 worktree 且身份稳定后才可创建终端。不得只做文本比较或跳过
|
当前 Git 仓库已注册的 worktree 且身份稳定后才可创建终端。不得只做文本比较或跳过
|
||||||
机器校验。
|
机器校验。
|
||||||
@@ -132,8 +132,8 @@ JSON-compatible YAML,并由隔离的 Python 标准库解析。它固定包含
|
|||||||
- 功能、缺陷、重构与验证闭环交给 `$ack`。
|
- 功能、缺陷、重构与验证闭环交给 `$ack`。
|
||||||
- 发布版本、release 分支/PR/MR、合并、tag 与 Forge Release 交给
|
- 发布版本、release 分支/PR/MR、合并、tag 与 Forge Release 交给
|
||||||
`$manage-release`。
|
`$manage-release`。
|
||||||
- DEB 构建或上传交给 `$deb-publisher`。
|
- DEB 构建或上传交给 `$builder`(deb 阶段);Docker/OCI 镜像构建或上传也交给
|
||||||
- Docker/OCI 镜像构建或上传交给 `$publish-docker-image`。
|
`$builder`(docker 阶段)。
|
||||||
- 普通非发布 PR/MR 不伪装成版本发布;只有 ACK 已验证交付或明确的 release 流程才
|
- 普通非发布 PR/MR 不伪装成版本发布;只有 ACK 已验证交付或明确的 release 流程才
|
||||||
进入对应下游能力。
|
进入对应下游能力。
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ ORC 是薄路由器,只负责意图映射、依赖、静态档位和结构化
|
|||||||
|------|------------|------|--------|
|
|------|------------|------|--------|
|
||||||
| `code` | `$ack` | 功能、缺陷、重构、测试、三角色验证闭环,以及用户明确要求的普通非发布 PR/MR | 版本发布、单独上传产物 |
|
| `code` | `$ack` | 功能、缺陷、重构、测试、三角色验证闭环,以及用户明确要求的普通非发布 PR/MR | 版本发布、单独上传产物 |
|
||||||
| `release` | `$manage-release` | 版本号、release worktree/分支、release PR/MR、合并、tag、Forge Release、恢复发布 | 普通非发布 PR/MR、构建或上传 DEB/Docker |
|
| `release` | `$manage-release` | 版本号、release worktree/分支、release PR/MR、合并、tag、Forge Release、恢复发布 | 普通非发布 PR/MR、构建或上传 DEB/Docker |
|
||||||
| `deb` | `$deb-publisher` | DEB 构建、校验、上传与仓库可见性 | 源码 tag、Docker 镜像 |
|
| `deb` | `$builder` | DEB 构建、契约校验、上传与仓库可见性 | 源码 tag、Docker 镜像 |
|
||||||
| `docker` | `$publish-docker-image` | Docker/OCI 构建、push、digest 与平台验证 | 源码版本生命周期、DEB |
|
| `docker` | `$builder` | Docker/OCI 构建、push、digest 与平台验证 | 源码版本生命周期、DEB |
|
||||||
|
|
||||||
没有匹配项时不要临时扩写某个 Skill 的职责,也不要让 ORC 自己模仿领域流程。报告缺少
|
没有匹配项时不要临时扩写某个 Skill 的职责,也不要让 ORC 自己模仿领域流程。报告缺少
|
||||||
的能力,由用户决定直接执行、安装新 Skill 或另行设计。
|
的能力,由用户决定直接执行、安装新 Skill 或另行设计。
|
||||||
@@ -40,14 +40,14 @@ decision gate。不得为了填满 worker prompt 而分析代码、推断版本
|
|||||||
```text
|
```text
|
||||||
code ($ack)
|
code ($ack)
|
||||||
-> release ($manage-release)
|
-> release ($manage-release)
|
||||||
-> deb ($deb-publisher)
|
-> deb ($builder)
|
||||||
-> docker ($publish-docker-image)
|
-> docker ($builder)
|
||||||
```
|
```
|
||||||
|
|
||||||
只从当前 commit 构建产物:
|
只从当前 commit 构建产物:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
deb ($deb-publisher) || docker ($publish-docker-image)
|
deb ($builder) || docker ($builder)
|
||||||
```
|
```
|
||||||
|
|
||||||
仅发布源码版本:
|
仅发布源码版本:
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
---
|
|
||||||
name: publish-docker-image
|
|
||||||
description: >-
|
|
||||||
构建当前项目的 Docker 镜像,并将其上传到用户指定的镜像仓库。仅当用户显式指定
|
|
||||||
$publish-docker-image 或明确说“使用 publish-docker-image skill”时使用;
|
|
||||||
不要因普通编码、编辑 Dockerfile、本地构建、测试或一般 Docker 问题而自动触发。
|
|
||||||
---
|
|
||||||
|
|
||||||
# 发布 Docker 镜像
|
|
||||||
|
|
||||||
安全、可复现地构建当前提交对应的 Docker 镜像,并按用户指定的目标上传。
|
|
||||||
|
|
||||||
## 执行流程
|
|
||||||
|
|
||||||
1. 读取项目的 `AGENTS.md`、Dockerfile、构建脚本和相关发布文档。
|
|
||||||
2. 收集目标 registry、repository、tag、platform、构建上下文和 Dockerfile。优先使用用户已明确提供的值;缺少会改变发布结果的值时,停止并询问。
|
|
||||||
3. 检查 Git 工作区与当前提交。若存在未提交修改,明确说明镜像将包含哪些修改。
|
|
||||||
4. 按 [registry.md](references/registry.md) 检查仓库规则和认证状态。
|
|
||||||
5. 在执行外部写操作前,向用户展示完整镜像引用、platform、Dockerfile、构建上下文和源 commit。只有用户已明确要求上传到该目标时才继续。
|
|
||||||
6. 使用 [publish.sh](scripts/publish.sh) 构建并上传。不要自行拼接包含凭据的命令。
|
|
||||||
7. 检查命令退出状态,并尽可能获取远端 digest。
|
|
||||||
8. 汇报完整镜像引用、digest、platform、源 commit,以及是否包含未提交修改。
|
|
||||||
|
|
||||||
## 命令
|
|
||||||
|
|
||||||
默认构建并上传:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/publish.sh \
|
|
||||||
--registry REGISTRY \
|
|
||||||
--repository NAMESPACE/IMAGE \
|
|
||||||
--tag TAG \
|
|
||||||
--platform PLATFORM
|
|
||||||
```
|
|
||||||
|
|
||||||
先验证而不构建或上传:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/publish.sh \
|
|
||||||
--registry REGISTRY \
|
|
||||||
--repository NAMESPACE/IMAGE \
|
|
||||||
--tag TAG \
|
|
||||||
--platform PLATFORM \
|
|
||||||
--dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
仅当用户明确要求本地构建时使用 `--load`。多平台镜像不能使用 `--load`。
|
|
||||||
|
|
||||||
## 安全边界
|
|
||||||
|
|
||||||
- 不把密码、访问令牌或 Docker 配置写入 skill、项目文件、命令参数或输出。
|
|
||||||
- 不主动执行 `docker login`;认证缺失时让用户通过交互式登录或其凭据管理器完成。
|
|
||||||
- 不覆盖已存在的 release tag,除非用户明确授权。无法可靠检查远端 tag 时说明这一限制。
|
|
||||||
- 不把 `latest` 作为隐含默认 tag。
|
|
||||||
- 不上传用户未指定的附加 tag。
|
|
||||||
- 不擅自修改 Dockerfile、发布配置、仓库权限或镜像保留策略。
|
|
||||||
- 若仓库、tag、platform 或目标环境存在歧义,在上传前询问用户。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
- 确认 `docker buildx build` 成功且启用了 `--push`。
|
|
||||||
- 优先用 `docker buildx imagetools inspect FULL_IMAGE_REF` 验证远端引用及平台。
|
|
||||||
- 记录远端 digest;若仓库不允许检查,明确报告只验证了 push 命令成功。
|
|
||||||
- 将发布所用的 Git commit 与工作区状态一并报告。
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
sed -n '2,22p' "$0"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build and publish a Docker image with buildx.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# publish.sh --registry HOST --repository PATH --tag TAG --platform PLATFORMS [options]
|
|
||||||
#
|
|
||||||
# Options:
|
|
||||||
# --registry HOST Registry host, without a URL scheme
|
|
||||||
# --repository PATH Repository path, such as team/service
|
|
||||||
# --tag TAG Image tag
|
|
||||||
# --platform LIST Comma-separated platforms
|
|
||||||
# --file PATH Dockerfile path (default: Dockerfile)
|
|
||||||
# --context PATH Build context (default: .)
|
|
||||||
# --builder NAME Existing buildx builder
|
|
||||||
# --load Load a single-platform image instead of pushing
|
|
||||||
# --dry-run Print the resolved build without executing it
|
|
||||||
# --help Show this help
|
|
||||||
|
|
||||||
registry=
|
|
||||||
repository=
|
|
||||||
tag=
|
|
||||||
platform=
|
|
||||||
dockerfile=Dockerfile
|
|
||||||
build_context=.
|
|
||||||
builder=
|
|
||||||
mode=push
|
|
||||||
dry_run=false
|
|
||||||
|
|
||||||
while (($#)); do
|
|
||||||
case "$1" in
|
|
||||||
--registry) registry=${2-}; shift 2 ;;
|
|
||||||
--repository) repository=${2-}; shift 2 ;;
|
|
||||||
--tag) tag=${2-}; shift 2 ;;
|
|
||||||
--platform) platform=${2-}; shift 2 ;;
|
|
||||||
--file) dockerfile=${2-}; shift 2 ;;
|
|
||||||
--context) build_context=${2-}; shift 2 ;;
|
|
||||||
--builder) builder=${2-}; shift 2 ;;
|
|
||||||
--load) mode=load; shift ;;
|
|
||||||
--dry-run) dry_run=true; shift ;;
|
|
||||||
--help|-h) usage; exit 0 ;;
|
|
||||||
*) printf 'Unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
for required_name in registry repository tag platform; do
|
|
||||||
if [[ -z ${!required_name} ]]; then
|
|
||||||
printf 'Missing required option: --%s\n' "$required_name" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ $registry == *://* || $registry == */* ]]; then
|
|
||||||
printf '%s\n' 'Registry must be a host without a URL scheme or path.' >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if [[ $repository == /* || $repository == */ || $repository != */* ]]; then
|
|
||||||
printf '%s\n' 'Repository must look like namespace/image.' >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if [[ $tag == *:* || $tag == */* ]]; then
|
|
||||||
printf '%s\n' 'Tag must not contain ":" or "/".' >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if [[ $mode == load && $platform == *,* ]]; then
|
|
||||||
printf '%s\n' '--load supports only one platform.' >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if [[ ! -f $dockerfile ]]; then
|
|
||||||
printf 'Dockerfile not found: %s\n' "$dockerfile" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if [[ ! -d $build_context ]]; then
|
|
||||||
printf 'Build context not found: %s\n' "$build_context" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if [[ $dry_run == false ]] && ! command -v docker >/dev/null 2>&1; then
|
|
||||||
printf '%s\n' 'docker is not installed or not available in PATH.' >&2
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
|
|
||||||
image_ref="${registry}/${repository}:${tag}"
|
|
||||||
build_cmd=(docker buildx build --file "$dockerfile" --platform "$platform" --tag "$image_ref")
|
|
||||||
if [[ -n $builder ]]; then
|
|
||||||
build_cmd+=(--builder "$builder")
|
|
||||||
fi
|
|
||||||
if [[ $mode == push ]]; then
|
|
||||||
build_cmd+=(--push)
|
|
||||||
else
|
|
||||||
build_cmd+=(--load)
|
|
||||||
fi
|
|
||||||
build_cmd+=("$build_context")
|
|
||||||
|
|
||||||
printf 'Image: %s\n' "$image_ref"
|
|
||||||
printf 'Platform: %s\n' "$platform"
|
|
||||||
printf 'Dockerfile: %s\n' "$dockerfile"
|
|
||||||
printf 'Context: %s\n' "$build_context"
|
|
||||||
printf 'Mode: %s\n' "$mode"
|
|
||||||
|
|
||||||
if [[ $dry_run == true ]]; then
|
|
||||||
printf 'Command:'
|
|
||||||
printf ' %q' "${build_cmd[@]}"
|
|
||||||
printf '\n'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
"${build_cmd[@]}"
|
|
||||||
|
|
||||||
if [[ $mode == push ]]; then
|
|
||||||
docker buildx imagetools inspect "$image_ref"
|
|
||||||
fi
|
|
||||||
@@ -44,11 +44,11 @@ class OrcSkillTests(unittest.TestCase):
|
|||||||
self.assertIn("当前 shell", skill)
|
self.assertIn("当前 shell", skill)
|
||||||
self.assertNotIn("用户未指定档位时采用以下判断", skill)
|
self.assertNotIn("用户未指定档位时采用以下判断", skill)
|
||||||
self.assertNotIn("若该档位不足以安全完成", skill)
|
self.assertNotIn("若该档位不足以安全完成", skill)
|
||||||
for child in ("$ack", "$manage-release", "$deb-publisher", "$publish-docker-image"):
|
for child in ("$ack", "$manage-release", "$builder"):
|
||||||
self.assertIn(child, routing)
|
self.assertIn(child, routing)
|
||||||
|
|
||||||
def test_children_do_not_reference_orc(self) -> None:
|
def test_children_do_not_reference_orc(self) -> None:
|
||||||
for child in ("ack", "manage-release", "deb-publisher", "publish-docker-image"):
|
for child in ("ack", "manage-release", "builder"):
|
||||||
for path in (REPO_ROOT / "skills" / child).rglob("*"):
|
for path in (REPO_ROOT / "skills" / child).rglob("*"):
|
||||||
if not path.is_file() or "__pycache__" in path.parts:
|
if not path.is_file() or "__pycache__" in path.parts:
|
||||||
continue
|
continue
|
||||||
|
|||||||
Reference in New Issue
Block a user