feat(builder): merge deb-publisher + publish-docker-image into contract-driven builder skill
- skills/builder: SKILL.md, README.md, references/contract.md (make/publish contract v1), references/registry.md - scripts/check.py: executable contract checker (make dry-run probes, secret scan, push thin-wrapper and script path checks; --build verifies real .deb) - scripts/upload_deb.sh: migrated from deb-publisher, adds project .env auto-load and dirty-worktree publish gate - scripts/publish_docker.sh: migrated from publish-docker-image publish.sh, now env-first (DOCKER_REGISTRY/REPOSITORY/IMAGE_TAG/PLATFORMS), refuses floating latest and multi-platform --load - scripts/verify_deb.sh: metadata/content/sha256 verification with v-prefix normalization - orc: deb+docker stages both route to $builder; routing table, DAGs, README, config untouched stage names; tests updated - ack delivery.md + skiff source-model.md: reference builder - remove skills/deb-publisher and skills/publish-docker-image
This commit is contained in:
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
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
DEB_SERVER_URL=https://deb.example.com \
|
||||
DEB_TOKEN=secret \
|
||||
DEB_REPOSITORY=main \
|
||||
upload_deb.sh FILE.deb [FILE.deb ...]
|
||||
|
||||
Options:
|
||||
-s SERVER_URL Override DEB_SERVER_URL
|
||||
-n REPOSITORY Override DEB_REPOSITORY
|
||||
-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
|
||||
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
|
||||
}
|
||||
|
||||
# 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:-}
|
||||
repository=${DEB_REPOSITORY:-}
|
||||
upload_path=${DEB_UPLOAD_PATH:-/api/v2/upload/package}
|
||||
token=${DEB_TOKEN:-}
|
||||
|
||||
while getopts ":s:n:p:h" option; do
|
||||
case "$option" in
|
||||
s) server_url=$OPTARG ;;
|
||||
n) repository=$OPTARG ;;
|
||||
p) upload_path=$OPTARG ;;
|
||||
h) usage; exit 0 ;;
|
||||
:) echo "Error: -$OPTARG requires a value" >&2; usage >&2; exit 2 ;;
|
||||
\?) echo "Error: unknown option -$OPTARG" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
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
|
||||
usage >&2
|
||||
exit 2
|
||||
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
|
||||
echo "Error: upload path must start with /" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "Error: curl is required." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
server_url=${server_url%/}
|
||||
success_count=0
|
||||
fail_count=0
|
||||
response_file=
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$response_file" && -f "$response_file" ]]; then
|
||||
rm -f -- "$response_file"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
print_response() {
|
||||
local file=$1
|
||||
if command -v jq >/dev/null 2>&1 && jq -e . "$file" >/dev/null 2>&1; then
|
||||
jq . "$file"
|
||||
else
|
||||
cat -- "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
for package_file in "$@"; do
|
||||
if [[ ! -f "$package_file" ]]; then
|
||||
echo "Skip: file not found: $package_file" >&2
|
||||
fail_count=$((fail_count + 1))
|
||||
continue
|
||||
fi
|
||||
if [[ "$package_file" != *.deb ]]; then
|
||||
echo "Skip: not a .deb file: $package_file" >&2
|
||||
fail_count=$((fail_count + 1))
|
||||
continue
|
||||
fi
|
||||
if [[ ! -s "$package_file" ]]; then
|
||||
echo "Skip: empty file: $package_file" >&2
|
||||
fail_count=$((fail_count + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
response_file=$(mktemp)
|
||||
echo "Uploading $(basename -- "$package_file") to $server_url (repository $repository)..."
|
||||
|
||||
http_code=000
|
||||
if http_code=$(curl --silent --show-error \
|
||||
--output "$response_file" \
|
||||
--write-out "%{http_code}" \
|
||||
--request POST \
|
||||
"$server_url$upload_path" \
|
||||
--form "package=@${package_file};type=application/vnd.debian.binary-package" \
|
||||
--form "token=${token}" \
|
||||
--form "repository_name=${repository}"); then
|
||||
:
|
||||
else
|
||||
echo "Failed (transport error): $(basename -- "$package_file")" >&2
|
||||
print_response "$response_file" >&2
|
||||
fail_count=$((fail_count + 1))
|
||||
cleanup
|
||||
response_file=
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$http_code" == 200 || "$http_code" == 201 ]]; then
|
||||
echo "Success ($http_code): $(basename -- "$package_file")"
|
||||
print_response "$response_file"
|
||||
success_count=$((success_count + 1))
|
||||
else
|
||||
echo "Failed ($http_code): $(basename -- "$package_file")" >&2
|
||||
print_response "$response_file" >&2
|
||||
fail_count=$((fail_count + 1))
|
||||
fi
|
||||
|
||||
cleanup
|
||||
response_file=
|
||||
done
|
||||
|
||||
echo "Done. Success: $success_count, Failed: $fail_count"
|
||||
if ((fail_count > 0)); then
|
||||
exit 1
|
||||
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"
|
||||
Reference in New Issue
Block a user