feat(builder): Android/iOS 构建与 copy_artifact 发布
新增 APK/IPA 轨道和 copy_artifact.sh(本地目录或 container:/dir)。 push 必须委托该脚本,禁止在 makefile 里内联 docker cp。
This commit is contained in:
+129
-18
@@ -32,8 +32,8 @@ 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",
|
||||
"docker push", "docker cp", "buildx build --push", "buildx --push",
|
||||
"upload_deb.sh", "publish_docker.sh", "copy_artifact.sh",
|
||||
)
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(r"(TOKEN|PASSWORD|SECRET|API_KEY|PASSWD)[A-Z_]*\s*[:?]?=\s*['\"]?[^\s$({\"']+", re.IGNORECASE),
|
||||
@@ -41,7 +41,7 @@ SECRET_PATTERNS = (
|
||||
)
|
||||
FLOATING_TAGS = (":latest", ":stable")
|
||||
DEB_SHAPE = re.compile(r"^[^_\s]+_[^_\s]+_[^_\s]+\.deb$")
|
||||
VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh")
|
||||
VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh", "copy_artifact.sh")
|
||||
BUILDER_MAKEFILE = "makefile.builder"
|
||||
BUILDER_ENV = ".env.builder"
|
||||
|
||||
@@ -285,6 +285,45 @@ def detect_docker_project(project: Path) -> bool:
|
||||
return (project / "Dockerfile").exists() or (project / "docker-compose.yaml").exists()
|
||||
|
||||
|
||||
def detect_android_project(recipe_all: str) -> bool:
|
||||
return ".apk" in recipe_all or ".aab" in recipe_all
|
||||
|
||||
|
||||
def detect_ios_project(recipe_all: str) -> bool:
|
||||
return ".ipa" in recipe_all
|
||||
|
||||
|
||||
def detect_copy_project(recipe_all: str) -> bool:
|
||||
return "copy_artifact.sh" in recipe_all
|
||||
|
||||
|
||||
def check_mobile_recipe(
|
||||
report: Report, project: Path, *, kind: str, number: int, ext: str
|
||||
) -> None:
|
||||
target = "build"
|
||||
dry = run_make(project, "build")
|
||||
text = chr(10).join(clean_make_output(dry))
|
||||
problems = []
|
||||
if dry.returncode != 0:
|
||||
problems.append(f"`make -n {target}` 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")
|
||||
wanted = (".apk", ".aab") if kind == "android" else (ext,)
|
||||
if not any(item in text for item in wanted):
|
||||
problems.append(f"recipe does not mention {' or '.join(wanted)}")
|
||||
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")
|
||||
title = f"{kind} 目标产物形状与纯构建"
|
||||
if problems:
|
||||
report.add(FAIL, number, title, "\n".join(problems))
|
||||
else:
|
||||
report.add(PASS, number, title, f"{target} writes {ext} under dist/")
|
||||
|
||||
|
||||
def check_docker_recipe(report: Report, project: Path) -> None:
|
||||
dry = run_make(project, "docker")
|
||||
text = chr(10).join(clean_make_output(dry))
|
||||
@@ -316,24 +355,42 @@ SCRIPT_RESOLVE_SNIPPETS = tuple(
|
||||
|
||||
|
||||
def check_push_delegates(
|
||||
report: Report, project: Path, dual_artifact: bool, deb_project: bool
|
||||
report: Report,
|
||||
project: Path,
|
||||
*,
|
||||
deb_project: bool,
|
||||
docker_project: bool,
|
||||
copy_project: bool,
|
||||
) -> None:
|
||||
targets = ("push-deb", "push-docker") if dual_artifact else ("push",)
|
||||
tracks = []
|
||||
if deb_project:
|
||||
tracks.append("deb")
|
||||
if docker_project:
|
||||
tracks.append("docker")
|
||||
if copy_project:
|
||||
tracks.append("copy")
|
||||
multi = len(tracks) > 1
|
||||
targets = tuple(f"push-{t}" for t in tracks) if multi else ("push",)
|
||||
missing = []
|
||||
inline = []
|
||||
thin = []
|
||||
builder_names = ("upload_deb.sh", "publish_docker.sh", "copy_artifact.sh")
|
||||
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]
|
||||
bad_tokens = [
|
||||
token
|
||||
for token in ("curl ", "scp ", "aptly ", "reprepro ", "docker cp")
|
||||
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:
|
||||
and "$(BUILDER_SCRIPT)" not in text \
|
||||
and not any(name in text for name in builder_names):
|
||||
inline.append(f"{target}: does not call a builder script (expected $BUILDER_SKILL_DIR/... or ~/.pouch/... path)")
|
||||
else:
|
||||
thin.append(target)
|
||||
@@ -342,7 +399,7 @@ def check_push_delegates(
|
||||
problems.append("missing targets: " + ", ".join(missing))
|
||||
problems.extend(inline)
|
||||
if deb_project:
|
||||
push_deb_target = "push-deb" if dual_artifact else "push"
|
||||
push_deb_target = "push-deb" if multi else "push"
|
||||
if push_deb_target not in missing:
|
||||
arches = read_arches(project)
|
||||
if arches and len(arches) > 1:
|
||||
@@ -385,6 +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",)
|
||||
ENV_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
|
||||
|
||||
|
||||
@@ -416,7 +474,7 @@ def key_present(key: str, env_keys: set[str]) -> bool:
|
||||
|
||||
|
||||
def check_ready_toolchain(
|
||||
report: Report, *, deb_project: bool, docker_project: bool
|
||||
report: Report, *, deb_project: bool, docker_project: bool, copy_project: bool = False
|
||||
) -> None:
|
||||
lines = [f"make: {'found' if shutil.which('make') else 'MISSING'}"]
|
||||
problems = []
|
||||
@@ -436,6 +494,11 @@ def check_ready_toolchain(
|
||||
problems.append("install dpkg-dev (or equivalent) to build .deb packages")
|
||||
else:
|
||||
lines.append("dpkg-deb: skipped (no deb track)")
|
||||
if copy_project:
|
||||
docker_ok = shutil.which("docker") is not None
|
||||
lines.append(
|
||||
f"docker (for container COPY_DEST): {'found' if docker_ok else 'optional; needed only for container:/dir dest'}"
|
||||
)
|
||||
report.add(
|
||||
FAIL if problems else PASS,
|
||||
10,
|
||||
@@ -445,13 +508,18 @@ def check_ready_toolchain(
|
||||
|
||||
|
||||
def check_ready_env_keys(
|
||||
report: Report, project: Path, *, deb_project: bool, docker_project: bool
|
||||
report: Report,
|
||||
project: Path,
|
||||
*,
|
||||
deb_project: bool,
|
||||
docker_project: bool,
|
||||
copy_project: bool = False,
|
||||
) -> None:
|
||||
env_keys = env_file_keys(project)
|
||||
lines = []
|
||||
missing: list[str] = []
|
||||
if not deb_project and not docker_project:
|
||||
report.add(SKIP, 11, "发布环境变量键名(不读取值)", "no deb/docker track")
|
||||
if not deb_project and not docker_project and not copy_project:
|
||||
report.add(SKIP, 11, "发布环境变量键名(不读取值)", "no deb/docker/copy track")
|
||||
return
|
||||
if deb_project:
|
||||
for key in DEB_ENV_KEYS:
|
||||
@@ -469,6 +537,14 @@ def check_ready_env_keys(
|
||||
missing.append(key)
|
||||
else:
|
||||
lines.append("DOCKER_*: skipped (no docker track)")
|
||||
if copy_project:
|
||||
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)
|
||||
else:
|
||||
lines.append("COPY_*: skipped (no copy track)")
|
||||
if missing:
|
||||
lines.extend(
|
||||
[
|
||||
@@ -585,7 +661,17 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
# 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"):
|
||||
for target in (
|
||||
*REQUIRED_TARGETS,
|
||||
"deb",
|
||||
"docker",
|
||||
"android",
|
||||
"ios",
|
||||
"push",
|
||||
"push-deb",
|
||||
"push-docker",
|
||||
"push-copy",
|
||||
):
|
||||
result = run_make(project, target)
|
||||
if result.returncode == 0:
|
||||
recipe_all_parts.append(result.stdout)
|
||||
@@ -596,6 +682,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
built_deb: Path | None = None
|
||||
deb_project = detect_deb_project(recipe_all, project)
|
||||
docker_project = detect_docker_project(project)
|
||||
android_project = detect_android_project(recipe_all)
|
||||
ios_project = detect_ios_project(recipe_all)
|
||||
copy_project = detect_copy_project(recipe_all)
|
||||
|
||||
if present["build"]:
|
||||
check_arch_guard(report, project)
|
||||
@@ -621,16 +710,38 @@ def main(argv: list[str] | None = None) -> int:
|
||||
else:
|
||||
report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no Dockerfile)")
|
||||
|
||||
dual = deb_project and docker_project
|
||||
check_push_delegates(report, project, dual, deb_project)
|
||||
if android_project:
|
||||
check_mobile_recipe(report, project, kind="android", number=12, ext=".apk")
|
||||
else:
|
||||
report.add(SKIP, 12, "android 目标产物形状与纯构建", "(not an Android project)")
|
||||
|
||||
if ios_project:
|
||||
check_mobile_recipe(report, project, kind="ios", number=13, ext=".ipa")
|
||||
else:
|
||||
report.add(SKIP, 13, "ios 目标产物形状与纯构建", "(not an iOS project)")
|
||||
|
||||
check_push_delegates(
|
||||
report,
|
||||
project,
|
||||
deb_project=deb_project,
|
||||
docker_project=docker_project,
|
||||
copy_project=copy_project,
|
||||
)
|
||||
check_secrets_and_tags(report, project)
|
||||
check_script_paths(report)
|
||||
if args.ready:
|
||||
check_ready_toolchain(
|
||||
report, deb_project=deb_project, docker_project=docker_project
|
||||
report,
|
||||
deb_project=deb_project,
|
||||
docker_project=docker_project,
|
||||
copy_project=copy_project,
|
||||
)
|
||||
check_ready_env_keys(
|
||||
report, project, deb_project=deb_project, docker_project=docker_project
|
||||
report,
|
||||
project,
|
||||
deb_project=deb_project,
|
||||
docker_project=docker_project,
|
||||
copy_project=copy_project,
|
||||
)
|
||||
|
||||
total_fail = report.failures
|
||||
|
||||
Executable
+152
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
COPY_DEST=/path/or/container:/dir \
|
||||
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`.
|
||||
|
||||
Environment (project root .env.builder; shell values win):
|
||||
COPY_DEST Required. Local dir or container:/dir
|
||||
COPY_AS Optional. Extra basenames for the first file
|
||||
(comma or space separated)
|
||||
ALLOW_UNCOMMITTED=1 Publish despite a dirty working tree
|
||||
|
||||
Options:
|
||||
-d DEST Override COPY_DEST
|
||||
-a NAMES Override COPY_AS
|
||||
--dry-run Print the copies without executing
|
||||
-h Show help
|
||||
|
||||
The working tree must be clean to publish; set ALLOW_UNCOMMITTED=1 to override.
|
||||
EOF
|
||||
}
|
||||
|
||||
project_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
||||
|
||||
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
|
||||
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.builder")
|
||||
fi
|
||||
|
||||
dest=${COPY_DEST:-}
|
||||
extra_as=${COPY_AS:-}
|
||||
dry_run=false
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
-d) dest=$2; shift 2 ;;
|
||||
-a) extra_as=$2; shift 2 ;;
|
||||
--dry-run) dry_run=true; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
--) shift; break ;;
|
||||
-*) echo "Error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) break ;;
|
||||
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
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
is_docker_dest() {
|
||||
local d=$1
|
||||
[[ "$d" != /* && "$d" != ./* && "$d" != ../* && "$d" != ~* ]] \
|
||||
&& [[ "$d" != *://* ]] \
|
||||
&& [[ "$d" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*:/ ]]
|
||||
}
|
||||
|
||||
docker_container=
|
||||
docker_dir=
|
||||
local_dir=
|
||||
if is_docker_dest "$dest"; then
|
||||
docker_container=${dest%%:*}
|
||||
docker_dir=${dest#*:}
|
||||
if [[ -z "$docker_container" || -z "$docker_dir" || "$docker_dir" != /* ]]; then
|
||||
echo "Error: docker dest must be container:/absolute/dir" >&2
|
||||
exit 2
|
||||
fi
|
||||
docker_dir=${docker_dir%/}
|
||||
else
|
||||
local_dir=${dest%/}
|
||||
fi
|
||||
|
||||
split_as() {
|
||||
local raw=$1
|
||||
raw=${raw//,/ }
|
||||
# shellcheck disable=SC2086
|
||||
printf '%s\n' $raw
|
||||
}
|
||||
|
||||
copy_one() {
|
||||
local src=$1 name=$2
|
||||
if [[ ! -f "$src" ]]; then
|
||||
echo "Error: not a file: $src" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -n "$docker_container" ]]; then
|
||||
local target="${docker_container}:${docker_dir}/${name}"
|
||||
if $dry_run; then
|
||||
echo "docker cp $src $target"
|
||||
return
|
||||
fi
|
||||
docker cp "$src" "$target"
|
||||
docker exec "$docker_container" ls -l "${docker_dir}/${name}" >/dev/null
|
||||
echo "copied $(basename -- "$src") -> $target"
|
||||
else
|
||||
local target="${local_dir}/${name}"
|
||||
if $dry_run; then
|
||||
echo "cp $src $target"
|
||||
return
|
||||
fi
|
||||
mkdir -p "$local_dir"
|
||||
cp -a "$src" "$target"
|
||||
test -f "$target"
|
||||
echo "copied $(basename -- "$src") -> $target"
|
||||
fi
|
||||
}
|
||||
|
||||
first=true
|
||||
success=0
|
||||
for src in "$@"; do
|
||||
copy_one "$src" "$(basename -- "$src")"
|
||||
if $first; then
|
||||
first=false
|
||||
if [[ -n "$extra_as" ]]; then
|
||||
while IFS= read -r alias; do
|
||||
[[ -z "$alias" ]] && continue
|
||||
copy_one "$src" "$alias"
|
||||
done < <(split_as "$extra_as")
|
||||
fi
|
||||
fi
|
||||
if ! $dry_run && command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum -- "$src"
|
||||
fi
|
||||
success=$((success + 1))
|
||||
done
|
||||
|
||||
echo "copy_artifact: $success file(s) to $dest"
|
||||
Reference in New Issue
Block a user