feat(builder): support project-local distribution scripts

This commit is contained in:
2026-10-12 12:26:10 +08:00
parent a35cc916cb
commit 544cbcca32
8 changed files with 193 additions and 23 deletions
+75 -4
View File
@@ -442,7 +442,7 @@ 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",)
COPY_ENV_KEYS = ("COPY_DEST",)
COPY_ENV_KEYS = ("COPY_DEST", "COPY_SCRIPT")
ENV_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
@@ -469,10 +469,72 @@ def env_file_keys(project: Path) -> set[str]:
return keys
def env_file_values(project: Path) -> dict[str, str]:
"""Return nonempty `.env.builder` values for internal validation only."""
path = project / BUILDER_ENV
values: dict[str, str] = {}
if not path.is_file():
return values
try:
text = path.read_text(encoding="utf-8")
except OSError:
return values
for raw in text.splitlines():
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue
match = ENV_KEY_LINE.match(stripped)
if not match:
continue
value = match.group(2).strip().strip("'\"")
if value:
values[match.group(1)] = value
return values
def key_present(key: str, env_keys: set[str]) -> bool:
return bool(os.environ.get(key)) or key in env_keys
def configured_value(key: str, env_values: dict[str, str]) -> str:
"""Read a configured value without ever printing it."""
return os.environ.get(key, "") or env_values.get(key, "")
def resolve_copy_script(project: Path, script: str) -> tuple[Path | None, str | None]:
"""Resolve COPY_SCRIPT without allowing arbitrary project-relative paths."""
configured = Path(script).expanduser()
if configured.is_absolute():
return configured, None
try:
resolved = (project / configured).resolve()
allowed_root = (project / ".pouch" / "builder").resolve()
resolved.relative_to(allowed_root)
except ValueError:
return None, "relative COPY_SCRIPT must be under .pouch/builder/"
return resolved, None
def check_copy_script(report: Report, project: Path, env_values: dict[str, str]) -> None:
script = configured_value("COPY_SCRIPT", env_values)
if not script:
return
path, resolve_error = resolve_copy_script(project, script)
problems = []
if resolve_error:
problems.append(resolve_error)
elif path is not None and not path.is_file():
problems.append("COPY_SCRIPT does not point to a file")
elif path is not None and not os.access(path, os.X_OK):
problems.append("COPY_SCRIPT is not executable")
report.add(
FAIL if problems else PASS,
14,
"COPY_SCRIPT 可执行且路径明确",
"\n".join(problems) if problems else "configured custom copy script is executable",
)
def check_ready_toolchain(
report: Report, *, deb_project: bool, docker_project: bool, copy_project: bool = False
) -> None:
@@ -516,6 +578,7 @@ def check_ready_env_keys(
copy_project: bool = False,
) -> None:
env_keys = env_file_keys(project)
env_values = env_file_values(project)
lines = []
missing: list[str] = []
if not deb_project and not docker_project and not copy_project:
@@ -541,16 +604,24 @@ def check_ready_env_keys(
for key in COPY_ENV_KEYS:
found = key_present(key, env_keys)
lines.append(f"{key}: {'present' if found else 'MISSING'}")
if not found:
missing.append(key)
if not any(key_present(key, env_keys) for key in COPY_ENV_KEYS):
missing.append("COPY_DEST or COPY_SCRIPT")
check_copy_script(report, project, env_values)
else:
lines.append("COPY_*: skipped (no copy track)")
if missing:
if "COPY_DEST or COPY_SCRIPT" in missing:
config_hints = [
" COPY_DEST= # local/container destination, or",
" COPY_SCRIPT=.pouch/builder/publish-apk.sh # choose one",
]
else:
config_hints = [f" {key}=" for key in missing]
lines.extend(
[
"",
f"blocks publish, not build. Put keys in the environment or {BUILDER_ENV}:",
*[f" {key}=" for key in missing],
*config_hints,
f"Do not commit {BUILDER_ENV}. Do not put these keys in `.env`. Never print values.",
]
)
+72 -6
View File
@@ -6,12 +6,17 @@ usage() {
Usage:
COPY_DEST=/path/or/container:/dir \
copy_artifact.sh FILE [FILE ...]
COPY_SCRIPT=.pouch/builder/publish-apk.sh \
copy_artifact.sh FILE [FILE ...]
Copy built artifacts to COPY_DEST. Dest is a local directory, or a Docker
copy target `container:/absolute/dir` (docker cp). Does not read `.env`.
Copy built artifacts using COPY_SCRIPT when configured; otherwise copy to
COPY_DEST. COPY_DEST is a local directory or a Docker copy target
`container:/absolute/dir` (docker cp). Does not read `.env`.
Environment (project root .env.builder; shell values win):
COPY_DEST Required. Local dir or container:/dir
COPY_DEST Optional fallback. Local dir or container:/dir
COPY_SCRIPT Optional .pouch/builder/ relative path or absolute
executable. Receives FILE paths as positional args.
COPY_AS Optional. Extra basenames for the first file
(comma or space separated)
ALLOW_UNCOMMITTED=1 Publish despite a dirty working tree
@@ -58,9 +63,13 @@ while (($#)); do
esac
done
if [[ -z "$dest" || $# -eq 0 ]]; then
echo "Error: COPY_DEST and at least one file are required." >&2
echo "Set them in the environment or the project root .env.builder." >&2
if [[ -z "$dest" && -z "${COPY_SCRIPT:-}" || $# -eq 0 ]]; then
if [[ $# -eq 0 ]]; then
echo "Error: at least one file is required." >&2
else
echo "Error: set COPY_DEST or COPY_SCRIPT." >&2
fi
echo "Set the value in the environment or the project root .env.builder." >&2
usage >&2
exit 2
fi
@@ -73,6 +82,63 @@ if [[ -n "$project_root" ]] && git -C "$project_root" rev-parse HEAD >/dev/null
fi
fi
if [[ -n "${COPY_SCRIPT:-}" ]]; then
copy_script=$COPY_SCRIPT
if [[ "$copy_script" != /* ]]; then
relative_script=${copy_script#./}
if [[ "$relative_script" != .pouch/builder/* || -z "$project_root" ]]; then
echo "Error: relative COPY_SCRIPT must be under .pouch/builder/." >&2
exit 2
fi
copy_script="$project_root/$relative_script"
fi
if [[ ! -f "$copy_script" ]]; then
echo "Error: COPY_SCRIPT is not a file: $copy_script" >&2
exit 2
fi
if [[ ! -x "$copy_script" ]]; then
echo "Error: COPY_SCRIPT is not executable: $copy_script" >&2
exit 2
fi
artifact_args=()
for src in "$@"; do
if [[ ! -f "$src" ]]; then
echo "Error: not a file: $src" >&2
exit 2
fi
if [[ "$src" == /* ]]; then
artifact_args+=("$src")
else
artifact_args+=("$PWD/$src")
fi
done
if $dry_run; then
printf 'custom copy script:'
printf ' %q' "$copy_script" "${artifact_args[@]}"
printf '\n'
exit 0
fi
export BUILDER_PROJECT_ROOT=${project_root:-}
if "$copy_script" "${artifact_args[@]}"; then
if command -v sha256sum >/dev/null 2>&1; then
for src in "${artifact_args[@]}"; do
sha256sum -- "$src"
done
fi
echo "copy_artifact: custom script completed for $# file(s)"
exit 0
else
status=$?
echo "Error: COPY_SCRIPT failed with exit code $status." >&2
exit "$status"
fi
fi
if [[ -z "$dest" ]]; then
echo "Error: COPY_DEST is required when COPY_SCRIPT is not configured." >&2
exit 2
fi
is_docker_dest() {
local d=$1
[[ "$d" != /* && "$d" != ./* && "$d" != ../* && "$d" != ~* ]] \