feat(builder): load publish credentials from .env.builder

Keep DEB/Docker publish keys out of the project's .env. Scripts and
check.py --ready only read .env.builder; empty values count as missing.
This commit is contained in:
2026-08-25 16:58:56 +08:00
parent 10d8800f07
commit 681aa9e237
10 changed files with 109 additions and 46 deletions
+2 -2
View File
@@ -49,8 +49,8 @@ description: >-
项目状态放在 skill 真正消费的位置(覆盖层、`makefile.builder`、compose),不要为了对齐
而新建一层没人读的 `.pouch/<skill>/``pouch init` 只用于确实有 templates/
覆盖层的 skill。需要 make 目标的 skill 用自己的文件名(如 `makefile.builder`),
不要占用用户的 `Makefile`
覆盖层的 skill。需要 make 目标或发布凭据的 skill 用自己的文件名(如 `makefile.builder`
`.env.builder`),不要占用用户的 `Makefile``.env`
---
+4 -3
View File
@@ -18,19 +18,19 @@
对新项目说「用 builder 初始化」。Agent 会探测轨道、按
`templates/makefile.builder` 写出项目根 `makefile.builder`,并列出缺的发布配置。
不改用户已有的 `Makefile`
不改用户已有的 `Makefile``.env`
1. `makefile.builder` 目标:`help/build/clean/version` + 条件 `deb/docker/push*`
变量 `ARCH/VERSION/DIST_DIR/PROJECT_NAME`
`VERSION` 通过 `include <builder>/scripts/version.mk` 从 Git 推导。
2. 运行 `python3 -I -S <builder>/scripts/check.py . --ready` 直到构建项 PASS。
缺发布键只挡住上传,不挡住构建。
3. 在项目根 `.env` 配置发布环境变量:
3. 在项目根 `.env.builder` 配置发布环境变量(不要写进 `.env`
```text
DEB_SERVER_URL=https://deb.example.com
DEB_REPOSITORY=main
DEB_TOKEN=<token> # 只放 .env 或密钥系统,不进 git
DEB_TOKEN=<token> # 只放 .env.builder 或密钥系统,不进 git
DOCKER_REGISTRY=registry.example.com
```
@@ -104,6 +104,7 @@ detached HEAD 用 `detached`CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH`
|------|------|
| `scripts/check.py` | 校验契约(`--ready` 含工具链与发布键名,`--build` 实构核对产物) |
| `templates/makefile.builder` | 初始化用的契约文件骨架,拷到项目根 |
| `templates/env.builder` | 初始化用的 `.env.builder` 骨架(注释键,不含值) |
| `scripts/version.sh` | 从 Git 祖先稳定 tag 推导规范版本 / Docker tag |
| `scripts/version.mk` | `makefile.builder` `include`,设置 `VERSION``IMAGE_TAG` |
| `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name |
+15 -12
View File
@@ -15,8 +15,8 @@ description: >-
复用项目已有发布约定,安全地完成"校验 → 构建 → 检查 → 授权 → 上传 → 验证"。
分工原则:**make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。**
项目状态是根目录 `makefile.builder` 与发布用 `.env`,不要创建 `.pouch/builder/`
不要改用户的 `Makefile` / `makefile`
项目状态是根目录 `makefile.builder` 与发布用 `.env.builder`不要创建
`.pouch/builder/`不要改用户的 `Makefile` / `makefile` / `.env`
开始时解析当前 `SKILL.md` 所在目录,记为 `<skill-dir>`。优先
`git rev-parse --show-toplevel` 解析项目根。
@@ -39,9 +39,9 @@ description: >-
## 初始化
1. 确认项目根。探测 `makefile.builder`、用户 `Makefile`/`makefile`(只当证据,
不改)、`Dockerfile``debian/`、语言清单`.env` 键名(只看键是否存在,不读、
不打印值)
1. 确认项目根。探测 `makefile.builder``.env.builder`用户 `Makefile`/`makefile`
(只当抄 build 配方的证据,不改)、`Dockerfile``debian/`、语言清单
`.env.builder` 只看键是否存在且非空,不读、不打印值。不要读取用户 `.env`
2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;都不清则问。
不要猜测 registry、token 或仓库名。
3. 没有 `makefile.builder`:把 `<skill-dir>/templates/makefile.builder` 拷到项目根。
@@ -51,7 +51,9 @@ description: >-
`push: push-deb push-docker`。不要改用户的 `Makefile` / `makefile`
4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖该文件,除非
用户明确要求按契约改。不要调用 create-makefile(其版本规则与本契约冲突)。
5. 不要创建 `.env`。缺发布键时在报告里给出可粘贴示例,并说明 `.env` 不进 Git。
5. 没有 `.env.builder`:把 `<skill-dir>/templates/env.builder` 拷到项目根为
`.env.builder`(注释键,不含值)。不要改、不要读取用户 `.env`。缺发布键时在
报告里给出可粘贴示例,并说明把 `.env.builder` 加入 `.gitignore`,不要提交。
6. 运行:
```bash
@@ -74,6 +76,7 @@ description: >-
发布键示例(只示范键名):
```text
# .env.builder
DEB_SERVER_URL=https://deb.example.com
DEB_REPOSITORY=main
DEB_TOKEN=
@@ -83,8 +86,8 @@ DOCKER_REGISTRY=registry.example.com
## 检查
只读。运行 `check.py <project-dir> --ready`,用同一报告格式,标题改为
`## builder 检查:…`。不写 `makefile.builder`、不改用户 Makefile、不创建 `.env`。
用户明确要求修复后再转入初始化。
`## builder 检查:…`。不写 `makefile.builder` / `.env.builder`,不改用户 Makefile
或 `.env`。用户明确要求修复后再转入初始化。
## 工作流
@@ -108,7 +111,7 @@ python3 -I -S <skill-dir>/scripts/check.py <project-dir> --build # 额外实构
执行上传前确认:
- 目标服务和仓库来自项目配置(`.env`)或用户输入,不猜测生产端点。
- 目标服务和仓库来自项目配置(`.env.builder`)或用户输入,不猜测生产端点。
- 认证令牌已通过环境变量或密钥系统提供;绝不写入命令输出、文件、提交或回复,
不用 `set -x` 执行含凭据的命令。
- 相同版本是否允许覆盖;无法确认且可能覆盖时,先询问。
@@ -152,7 +155,7 @@ DOCKER_REGISTRY=… \
<skill-dir>/scripts/publish_docker.sh # env 优先,flag 可覆盖
```
环境变量缺失时脚本会自动向上查找项目 `.env` 加载(shell 显式值优先)。不把 token
环境变量缺失时脚本会加载项目 `.env.builder`shell 显式值优先),不读 `.env`。不把 token
作为命令行参数;不把脚本复制进项目。upload_deb.sh 默认请求 `/api/v2/upload/package`
multipart 字段 `package`/`token`/`repository_name`,接受 200/201),协议不符时设
`DEB_UPLOAD_PATH` 或改用项目专属逻辑。publish_docker.sh 用 buildx 一步完成构建+推送,
@@ -188,8 +191,8 @@ rg -n -i --hidden --glob '!.git' \
- 上传/发布脚本是 SSOT:通用行为修改落在 `skills/builder/scripts/`,不同步复制到
业务项目。产物版本只通过 `scripts/version.sh` 推导,不要在 `makefile.builder` 内联
`git describe` 或 `sort -V`。
- 契约变更先改 `scripts/check.py`,再同步 `references/contract.md`
`templates/makefile.builder`。
- 契约变更先改 `scripts/check.py`,再同步 `references/contract.md`
`templates/makefile.builder` 与 `templates/env.builder`
- 可用 `bash -n` 检查脚本语法;有 ShellCheck 时一并运行。
- 不通过真实生产上传测试脚本,除非用户明确授权并给出测试版本/仓库。
+8 -5
View File
@@ -16,6 +16,9 @@ make -f makefile.builder <target>
不要把 builder 目标写进用户的 `Makefile``makefile``check.py` 只读
`makefile.builder`
发布凭据固定为项目根 `.env.builder`。不要把这些键写进用户的 `.env`。脚本不读取
`.env`
## 1. Make 目标
### 必备目标(所有项目)
@@ -113,9 +116,9 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk
| `IMAGE_TAG` | 否 | 默认由规范版本渲染:正式为 `X.Y.Z`;测试将 `~` 换成 `-``+g` 换成 `.g` |
| `PLATFORMS` | 否 | 默认 `linux/amd64`;多平台如 `linux/amd64,linux/arm64` |
配置来源优先级:shell 已显式设置的值 > 项目根 `.env` > 失败并询问用户。
`.env` 由 builder 脚本自动向上查找并加载(不回显任何值);当前 shell 已设置的值
优先于 `.env`
配置来源优先级:shell 已显式设置的值 > 项目根 `.env.builder` > 失败并询问用户。
`.env.builder` 由 builder 脚本加载(不回显任何值);当前 shell 已设置的值优先。
不要读取或改写用户 `.env`空值视为未配置。不要提交 `.env.builder`
### 工作区安全
@@ -137,8 +140,8 @@ clone 到 `~/.pouch`。
`python3 -I -S <builder-scripts>/check.py <project-dir> [--build]` 对本项目逐条检查
上述要求,任一 FAIL 退出码非零,可直接挂 CI。`--build` 额外实构
`make -f makefile.builder deb` 并核对产物元数据(默认只静态检查配方)。`--ready`
额外检查轨道工具链,以及 `.env` / 环境中的发布键名是否存在(不读取、不打印值;
缺键只挡住发布)。校验失败时的修复路径:按 `templates/makefile.builder` 补齐或
额外检查轨道工具链,以及 `.env.builder` / 环境中的发布键名是否存在且非空(不读取、
不打印值;缺键只挡住发布)。校验失败时的修复路径:按 `templates/makefile.builder` 补齐或
修正 `makefile.builder`,再跑 check.py,不要绕过校验器,不要改用户 Makefile,
不要用 create-makefile(版本推导与本契约冲突)。
+1 -1
View File
@@ -14,7 +14,7 @@
信息来源优先级:
1. 用户本次请求中明确给出的值。
2. 当前项目的 `.env``AGENTS.md`、发布文档。
2. 当前项目的 `.env.builder``AGENTS.md`、发布文档。不要读用户 `.env`
3. `makefile.builder`、用户 Makefile、CI 配置或现有构建脚本中一致且无歧义的配置。
4. 询问用户。
+10 -6
View File
@@ -43,6 +43,7 @@ FLOATING_TAGS = (":latest", ":stable")
DEB_SHAPE = re.compile(r"^[^_\s]+_[^_\s]+_[^_\s]+\.deb$")
VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh")
BUILDER_MAKEFILE = "makefile.builder"
BUILDER_ENV = ".env.builder"
PASS = "PASS"
FAIL = "FAIL"
@@ -279,12 +280,12 @@ def check_secrets_and_tags(report: Report, project: Path) -> None:
DEB_ENV_KEYS = ("DEB_SERVER_URL", "DEB_TOKEN", "DEB_REPOSITORY")
DOCKER_ENV_KEYS = ("DOCKER_REGISTRY",)
ENV_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=")
ENV_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
def env_file_keys(project: Path) -> set[str]:
"""Return key names defined in project `.env`. Never return or print values."""
path = project / ".env"
"""Return nonempty key names in `.env.builder`. Never return or print values."""
path = project / BUILDER_ENV
keys: set[str] = set()
if not path.is_file():
return keys
@@ -297,7 +298,10 @@ def env_file_keys(project: Path) -> set[str]:
if not stripped or stripped.startswith("#"):
continue
match = ENV_KEY_LINE.match(stripped)
if match:
if not match:
continue
value = match.group(2).strip().strip("'\"")
if value:
keys.add(match.group(1))
return keys
@@ -364,9 +368,9 @@ def check_ready_env_keys(
lines.extend(
[
"",
"blocks publish, not build. Put keys in the environment or project `.env`:",
f"blocks publish, not build. Put keys in the environment or {BUILDER_ENV}:",
*[f" {key}=" for key in missing],
"Do not commit `.env`. Never print values.",
f"Do not commit {BUILDER_ENV}. Do not put these keys in `.env`. Never print values.",
]
)
report.add(SKIP, 11, "发布环境变量键名(不读取值)", "\n".join(lines))
+8 -7
View File
@@ -6,8 +6,8 @@ usage() {
}
# Build and publish a Docker image with buildx. Configuration comes from the
# environment first (optionally loaded from the project root .env); flags
# override.
# environment first (optionally loaded from the project root .env.builder);
# flags override. Does not read `.env`.
#
# Usage:
# publish_docker.sh [--registry HOST] [--repository PATH] [--tag TAG] \
@@ -30,18 +30,19 @@ usage() {
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
# Load .env.builder without printing values; shell values win. Do not read `.env`.
if [[ -n "$project_root" && -f "$project_root/.env.builder" ]]; 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
continue # shell value already set: wins over .env.builder
fi
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
[[ -z "$value" ]] && continue
printf -v "$key" '%s' "$value"
export "$key"
done < <(grep -v '^[[:space:]]*$' "$project_root/.env")
done < <(grep -v '^[[:space:]]*$' "$project_root/.env.builder")
fi
git_repo_name=
@@ -81,7 +82,7 @@ if [[ -n "$registry" && ( "$registry" == *://* || "$registry" == */* ) ]]; then
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
echo "Set it in the environment or the project root .env.builder." >&2
usage >&2
exit 2
fi
+10 -9
View File
@@ -15,9 +15,9 @@ Options:
-p UPLOAD_PATH Override DEB_UPLOAD_PATH (default: /api/v2/upload/package)
-h Show help
Environment variables may live in the project root .env; this script walks up
from the current directory, loads it silently (existing shell values win), and
never echoes variable values. The endpoint must accept multipart fields named
Environment variables may live in the project root .env.builder; this script
loads it silently (existing shell values win), never echoes values, and does
not read `.env`. 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.
@@ -25,21 +25,22 @@ The working tree must be clean to publish; set ALLOW_UNCOMMITTED=1 to override.
EOF
}
# Locate project root (.git) upward from cwd for .env loading and git checks.
# Locate project root (.git) upward from cwd for .env.builder 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
# Load .env.builder without printing values; shell values win. Do not read `.env`.
if [[ -n "$project_root" && -f "$project_root/.env.builder" ]]; 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
continue # shell value already set: wins over .env.builder
fi
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
[[ -z "$value" ]] && continue
printf -v "$key" '%s' "$value"
export "$key"
done < <(grep -v '^[[:space:]]*$' "$project_root/.env")
done < <(grep -v '^[[:space:]]*$' "$project_root/.env.builder")
fi
server_url=${DEB_SERVER_URL:-}
@@ -61,7 +62,7 @@ shift $((OPTIND - 1))
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 "Set them in the environment or the project root .env." >&2
echo "Set them in the environment or the project root .env.builder." >&2
usage >&2
exit 2
fi
+13
View File
@@ -0,0 +1,13 @@
# Builder publish credentials. Copy to the project root as `.env.builder`.
# Do not commit this file. Do not put these keys in the project's `.env`.
# Uncomment and fill the keys for tracks this project uses.
# Shell-exported values override this file.
#
# DEB_SERVER_URL=
# DEB_TOKEN=
# DEB_REPOSITORY=
# DEB_UPLOAD_PATH=
# DOCKER_REGISTRY=
# DOCKER_REPOSITORY=
# IMAGE_TAG=
# PLATFORMS=
+38 -1
View File
@@ -95,10 +95,13 @@ class BuilderCheckTests(unittest.TestCase):
self.assertIn("check.py", skill)
self.assertIn("--ready", skill)
self.assertIn("makefile.builder", skill)
self.assertIn(".env.builder", skill)
self.assertIn("不要改用户的 `Makefile`", skill)
self.assertIn("不要调用 create-makefile", skill)
self.assertIn("不要用 create-makefile", contract)
self.assertIn("makefile.builder", contract)
self.assertIn(".env.builder", contract)
self.assertIn("不要读取或改写用户 `.env`", contract)
def test_no_makefile_without_ready_is_usage_error(self) -> None:
with tempfile.TemporaryDirectory() as temp:
@@ -136,7 +139,7 @@ class BuilderCheckTests(unittest.TestCase):
project = Path(temp)
init_repo(project)
write_contract_makefile(project)
(project / ".env").write_text(
(project / ".env.builder").write_text(
"DEB_SERVER_URL=https://secret.example.com\n"
"DEB_TOKEN=super-secret-token-value\n"
"DEB_REPOSITORY=main\n",
@@ -151,6 +154,40 @@ class BuilderCheckTests(unittest.TestCase):
self.assertNotIn("super-secret-token-value", text)
self.assertNotIn("https://secret.example.com", text)
def test_user_dotenv_is_ignored(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
write_contract_makefile(project)
(project / ".env").write_text(
"DEB_SERVER_URL=https://user.example.com\n"
"DEB_TOKEN=user-env-secret-token\n"
"DEB_REPOSITORY=main\n",
encoding="utf-8",
)
code, text = run_check(
project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"}
)
self.assertEqual(code, 0, text)
self.assertIn("DEB_SERVER_URL: MISSING", text)
self.assertNotIn("user-env-secret-token", text)
self.assertNotIn("https://user.example.com", text)
def test_empty_env_builder_values_count_as_missing(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
write_contract_makefile(project)
(project / ".env.builder").write_text(
"DEB_SERVER_URL=\nDEB_TOKEN=\nDEB_REPOSITORY=\n",
encoding="utf-8",
)
code, text = run_check(
project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"}
)
self.assertEqual(code, 0, text)
self.assertIn("DEB_TOKEN: MISSING", text)
def test_ready_fails_when_docker_track_missing_docker(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)