From c1febaa4ff0bd7f11b39a367dff06b11d0d8c700 Mon Sep 17 00:00:00 2001 From: zuiyinggg Date: Tue, 19 May 2026 17:26:09 +0800 Subject: [PATCH] [verified] ci: add self-built image verification workflow --- .github/workflows/build-sub2api-image.yml | 224 +++++++++++++++++- .gitignore | 6 +- Makefile | 8 +- docs/SELF_BUILT_IMAGE_RUNBOOK.md | 180 +++++++++++++++ scripts/verify-sub2api-deploy.sh | 203 ++++++++++++++++ scripts/verify-sub2api-image.sh | 270 ++++++++++++++++++++++ 6 files changed, 880 insertions(+), 11 deletions(-) create mode 100644 docs/SELF_BUILT_IMAGE_RUNBOOK.md create mode 100755 scripts/verify-sub2api-deploy.sh create mode 100755 scripts/verify-sub2api-image.sh diff --git a/.github/workflows/build-sub2api-image.yml b/.github/workflows/build-sub2api-image.yml index 3eb614a76e3..ff1f4bfa317 100644 --- a/.github/workflows/build-sub2api-image.yml +++ b/.github/workflows/build-sub2api-image.yml @@ -13,6 +13,21 @@ on: required: true default: "main" type: string + upstream_repo: + description: "Upstream repository used for hotfix ancestry and missing commit checks." + required: true + default: "Wei-Shaw/sub2api" + type: string + upstream_ref: + description: "Upstream branch, tag, or commit SHA used for missing commit checks." + required: true + default: "main" + type: string + required_fixes: + description: "Required upstream hotfix commit SHAs (multiline string, one SHA per line or whitespace-separated)." + required: false + default: "" + type: string image_name: description: "GHCR image name. Use 'sub2api' for ghcr.io//sub2api, or owner/name." required: true @@ -73,6 +88,15 @@ jobs: printf '%s' "$raw" } + is_owner_repo() { + [[ "$1" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] + } + + if ! is_owner_repo "$SOURCE_REPO"; then + echo "::error::source_repo must be OWNER/REPO, got: $SOURCE_REPO" + exit 1 + fi + owner="${GITHUB_REPOSITORY_OWNER,,}" image_input="${IMAGE_NAME_INPUT,,}" if [[ "$image_input" == ghcr.io/* ]]; then @@ -123,6 +147,87 @@ jobs: echo "Tags:" cat "$tags_file" + - name: Verify required fixes and upstream status + id: upstream + shell: bash + env: + SOURCE_COMMIT: ${{ steps.meta.outputs.sha }} + REQUIRED_FIXES_INPUT: ${{ inputs.required_fixes }} + UPSTREAM_REPO: ${{ inputs.upstream_repo }} + UPSTREAM_REF: ${{ inputs.upstream_ref }} + run: | + set -euo pipefail + + is_owner_repo() { + [[ "$1" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] + } + + if ! is_owner_repo "$UPSTREAM_REPO"; then + echo "::error::upstream_repo must be OWNER/REPO, got: $UPSTREAM_REPO" + exit 1 + fi + + if [ -z "$UPSTREAM_REF" ]; then + echo "::error::upstream_ref must not be empty" + exit 1 + fi + + mapfile -t required_fixes < <( + printf '%s\n' "$REQUIRED_FIXES_INPUT" | + tr ',;[:space:]' '\n' | + sed '/^$/d' + ) + + normalized_fixes_file="$(mktemp)" + failed=0 + + if [ "${#required_fixes[@]}" -eq 0 ]; then + echo "No required fixes declared." + else + for fix in "${required_fixes[@]}"; do + if [[ ! "$fix" =~ ^[0-9a-fA-F]{7,40}$ ]]; then + echo "::error::required fix is not a commit SHA: $fix" + failed=1 + continue + fi + + if ! fix_commit="$(git rev-parse --verify "$fix^{commit}" 2>/dev/null)"; then + echo "::error::required fix is not present in the source checkout: $fix" + failed=1 + continue + fi + + if git merge-base --is-ancestor "$fix_commit" "$SOURCE_COMMIT"; then + printf '%s\n' "$fix_commit" >> "$normalized_fixes_file" + echo "Required fix is an ancestor: $fix_commit" + else + echo "::error::required fix is not an ancestor of source commit: $fix_commit" + failed=1 + fi + done + fi + + if [ "$failed" -ne 0 ]; then + exit 1 + fi + + git fetch --quiet --no-tags "https://github.com/${UPSTREAM_REPO}.git" "$UPSTREAM_REF" + upstream_commit="$(git rev-parse --verify 'FETCH_HEAD^{commit}')" + missing_total="$(git rev-list --count "${SOURCE_COMMIT}..${upstream_commit}")" + missing_nonmerge="$(git rev-list --count --no-merges "${SOURCE_COMMIT}..${upstream_commit}")" + + { + echo "upstream_commit=$upstream_commit" + echo "missing_upstream_total=$missing_total" + echo "missing_upstream_nonmerge=$missing_nonmerge" + echo "required_fixes<> "$GITHUB_OUTPUT" + + echo "Upstream commit: $upstream_commit" + echo "Missing upstream commits: total=$missing_total nonmerge=$missing_nonmerge" + - name: Pin Dockerfile pnpm version for reproducible builds shell: bash run: | @@ -154,7 +259,7 @@ jobs: labels: | org.opencontainers.image.title=Sub2API org.opencontainers.image.description=Custom Sub2API image built from ${{ inputs.source_repo }}@${{ steps.meta.outputs.sha }} - org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.source=https://github.com/${{ inputs.source_repo }} org.opencontainers.image.revision=${{ steps.meta.outputs.sha }} org.opencontainers.image.created=${{ steps.meta.outputs.date }} build-args: | @@ -163,26 +268,127 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - - name: Write build summary + - name: Write build manifest and summary shell: bash + env: + SOURCE_REPO: ${{ inputs.source_repo }} + SOURCE_REF_INPUT: ${{ inputs.source_ref }} + SOURCE_COMMIT: ${{ steps.meta.outputs.sha }} + UPSTREAM_REPO: ${{ inputs.upstream_repo }} + UPSTREAM_REF: ${{ inputs.upstream_ref }} + UPSTREAM_COMMIT: ${{ steps.upstream.outputs.upstream_commit }} + MISSING_UPSTREAM_TOTAL: ${{ steps.upstream.outputs.missing_upstream_total }} + MISSING_UPSTREAM_NONMERGE: ${{ steps.upstream.outputs.missing_upstream_nonmerge }} + IMAGE: ${{ steps.meta.outputs.image }} + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build.outputs.digest }} + REQUIRED_FIXES: ${{ steps.upstream.outputs.required_fixes }} + BUILD_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | + set -euo pipefail + + yaml_quote() { + local value="$1" + value="${value//$'\r'/}" + value="${value//$'\n'/ }" + value="${value//\'/\'\'}" + printf "'%s'" "$value" + } + + write_array() { + local name="$1" + local data="$2" + local printed=0 + + while IFS= read -r item; do + [ -n "$item" ] || continue + if [ "$printed" -eq 0 ]; then + printf '%s:\n' "$name" + printed=1 + fi + printf ' - ' + yaml_quote "$item" + printf '\n' + done <<< "$data" + + if [ "$printed" -eq 0 ]; then + printf '%s: []\n' "$name" + fi + } + + { + printf 'source_repo: ' + yaml_quote "$SOURCE_REPO" + printf '\n' + printf 'source_ref_input: ' + yaml_quote "$SOURCE_REF_INPUT" + printf '\n' + printf 'source_commit: ' + yaml_quote "$SOURCE_COMMIT" + printf '\n' + printf 'upstream_repo: ' + yaml_quote "$UPSTREAM_REPO" + printf '\n' + printf 'upstream_ref: ' + yaml_quote "$UPSTREAM_REF" + printf '\n' + printf 'upstream_commit: ' + yaml_quote "$UPSTREAM_COMMIT" + printf '\n' + printf 'missing_upstream_total: %s\n' "$MISSING_UPSTREAM_TOTAL" + printf 'missing_upstream_nonmerge: %s\n' "$MISSING_UPSTREAM_NONMERGE" + printf 'image: ' + yaml_quote "$IMAGE" + printf '\n' + write_array "tags" "$TAGS" + printf 'digest: ' + yaml_quote "$DIGEST" + printf '\n' + write_array "required_fixes" "$REQUIRED_FIXES" + printf 'build_run_url: ' + yaml_quote "$BUILD_RUN_URL" + printf '\n' + } > build-manifest.yaml + { echo "## Sub2API image build" echo - echo "- Source repo: \`${{ inputs.source_repo }}\`" - echo "- Source ref input: \`${{ inputs.source_ref }}\`" - echo "- Resolved commit: \`${{ steps.meta.outputs.sha }}\`" - echo "- Image: \`${{ steps.meta.outputs.image }}\`" - echo "- Digest: \`${{ steps.build.outputs.digest }}\`" + echo "- Source repo: \`$SOURCE_REPO\`" + echo "- Source ref input: \`$SOURCE_REF_INPUT\`" + echo "- Resolved commit: \`$SOURCE_COMMIT\`" + echo "- Upstream repo: \`$UPSTREAM_REPO\`" + echo "- Upstream ref: \`$UPSTREAM_REF\`" + echo "- Upstream commit: \`$UPSTREAM_COMMIT\`" + echo "- Missing upstream commits: total=\`$MISSING_UPSTREAM_TOTAL\`, non-merge=\`$MISSING_UPSTREAM_NONMERGE\`" + echo "- Image: \`$IMAGE\`" + echo "- Digest: \`$DIGEST\`" + echo "- Build manifest artifact: \`build-manifest.yaml\`" echo echo "### Tags" while IFS= read -r tag; do echo "- \`$tag\`" - done <<< "${{ steps.meta.outputs.tags }}" + done <<< "$TAGS" + echo + echo "### Required fixes" + if [ -z "$REQUIRED_FIXES" ]; then + echo "- None" + else + while IFS= read -r fix; do + [ -n "$fix" ] || continue + echo "- \`$fix\`" + done <<< "$REQUIRED_FIXES" + fi echo echo "### Production Compose reference" echo echo '```yaml' - echo "image: ${{ steps.meta.outputs.image }}@${{ steps.build.outputs.digest }}" + echo "image: $IMAGE@$DIGEST" echo '```' } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload build manifest + uses: actions/upload-artifact@v4 + with: + name: sub2api-build-manifest + path: build-manifest.yaml + if-no-files-found: error diff --git a/.gitignore b/.gitignore index cf251f0715a..27fe6a6ce96 100644 --- a/.gitignore +++ b/.gitignore @@ -118,7 +118,10 @@ backend/.installed tests CLAUDE.md .claude -scripts +scripts/* +!scripts/ +!scripts/verify-sub2api-image.sh +!scripts/verify-sub2api-deploy.sh .code-review-state #openspec/ code-reviews/ @@ -131,6 +134,7 @@ docs/* !docs/PAYMENT.md !docs/PAYMENT_CN.md !docs/ADMIN_PAYMENT_INTEGRATION_API.md +!docs/SELF_BUILT_IMAGE_RUNBOOK.md .serena/ .codex/ frontend/coverage/ diff --git a/Makefile b/Makefile index d00d0c4f5ef..01d39578608 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-backend build-frontend build-datamanagementd test test-backend test-frontend test-frontend-critical test-datamanagementd secret-scan +.PHONY: build build-backend build-frontend build-datamanagementd test test-backend test-frontend test-frontend-critical test-datamanagementd secret-scan verify-sub2api-image-help verify-sub2api-deploy-help FRONTEND_CRITICAL_VITEST := \ src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \ @@ -42,3 +42,9 @@ test-datamanagementd: secret-scan: @python3 tools/secret_scan.py + +verify-sub2api-image-help: + @./scripts/verify-sub2api-image.sh --help + +verify-sub2api-deploy-help: + @./scripts/verify-sub2api-deploy.sh --help diff --git a/docs/SELF_BUILT_IMAGE_RUNBOOK.md b/docs/SELF_BUILT_IMAGE_RUNBOOK.md new file mode 100644 index 00000000000..797416277c4 --- /dev/null +++ b/docs/SELF_BUILT_IMAGE_RUNBOOK.md @@ -0,0 +1,180 @@ +# Self-built GHCR Image Runbook + +## 目标 + +使用自建 GHCR 镜像承接 Wei-Shaw/sub2api upstream main 的热修复, 同时保留 BFanSYe/sub2api 的自定义首页与功能. + +生产环境必须使用 digest 固定镜像, 禁止使用 `latest` 或仅使用可变 tag 部署. + +## 分支与来源策略 + +- 以 `BFanSYe/sub2api` 作为生产构建来源. +- 以 `Wei-Shaw/sub2api@main` 作为 upstream 热修复来源. +- 将 upstream main 的必要修复合入 BFanSYe 分支后再构建镜像. +- 对生产构建优先使用精确 commit SHA 作为 `source_ref`. +- 仅在人工确认的情况下使用分支名触发构建. +- 不在镜像内写入生产域名, API key, token, 数据库地址, 管理员密码或 `.env` 内容. +- 将生产配置留给 Compose, secret manager, 环境变量或部署平台注入. + +## Upstream Sync 流程 + +1. 拉取 upstream main 最新提交. +2. 阅读 upstream 变更, 标记必须吸收的 hotfix commit. +3. 将必要 commit 合入或同步到 BFanSYe 构建分支. +4. 保留 BFanSYe 自定义首页, 支付, 管理端或其他 fork 特性. +5. 执行本地测试或 CI 测试. +6. 触发 `.github/workflows/build-sub2api-image.yml`. +7. 在 `required_fixes` 中填写必须包含的 upstream hotfix SHA, 一行一个. +8. 检查 workflow summary 与 `build-manifest.yaml`. +9. 只使用 `image@sha256:...` 更新生产 Compose. + +## 风险分级 + +### R0: 文档与脚本变更 + +- 只影响 runbook, verify script, Makefile help target 或 CI 元数据. +- 不改变应用运行时行为. +- 验证要求: `bash -n`, YAML 语法检查, 只读脚本 dry run 或 help 输出. + +### R1: 镜像重建但 source commit 不变 + +- 只改变构建环境, OCI label, manifest, tag 或缓存. +- 验证要求: 检查 digest, revision label, manifest, production Compose digest pin. + +### R2: 吸收 upstream hotfix + +- source commit 改变, 但目标是修复 upstream 已确认问题. +- 验证要求: `required_fixes` 全部为 source commit 祖先, upstream missing count 已记录, 应用测试通过. + +### R3: Fork 功能与 upstream 冲突 + +- 涉及首页, 管理端, 支付, 鉴权, 配置模型或 API 行为冲突. +- 验证要求: 除 required fixes 外, 必须人工确认 BFanSYe 自定义功能仍存在, public homepage marker 可通过部署验证脚本检查. + +### R4: 生产配置或 secret 触达风险 + +- 任何可能把生产 secret, 域名, token 或 `.env` 写入镜像的变更. +- 处理要求: 停止构建, 移除硬编码, 重新审查 Dockerfile, workflow, build args 与日志. + +## Required Fixes 清单 + +- 将必须吸收的 upstream hotfix commit SHA 写入 workflow 的 `required_fixes`. +- 每行只写一个 SHA. +- 不写分支名, tag, PR 编号或自然语言说明. +- 如果是 merge/rebase upstream, 写 upstream 原始 SHA; 如果是 cherry-pick, 原始 upstream SHA 不再是祖先, 需要写 fork 中实际落地的 cherry-pick SHA, 并在发布记录中注明来源 upstream SHA. +- workflow 必须在构建前验证每个 SHA 都是 source commit 的祖先. +- 本地或生产前验证使用: + +```bash +./scripts/verify-sub2api-image.sh \ + --image ghcr.io/OWNER/sub2api@sha256:DIGEST \ + --source-repo BFanSYe/sub2api \ + --expected-revision SOURCE_COMMIT \ + --required-fix UPSTREAM_HOTFIX_SHA +``` + +## CI 构建步骤 + +1. 打开 `Build Sub2API image` workflow. +2. 设置 `source_repo` 为 `BFanSYe/sub2api`. +3. 设置 `source_ref` 为待发布 commit SHA. +4. 设置 `upstream_repo` 为 `Wei-Shaw/sub2api`. +5. 设置 `upstream_ref` 为 `main`. +6. 在 `required_fixes` 中填写必须包含的 upstream hotfix SHA. +7. 设置 `image_name` 为生产使用的 GHCR image name. +8. 保持 `tag_latest=false`, 除非明确需要维护非生产 alias. +9. 触发 workflow. +10. 检查 summary 中的 source commit, upstream commit, missing upstream counts, digest. +11. 下载并归档 `build-manifest.yaml`. + +## 镜像验证步骤 + +1. 使用 digest 引用验证远端镜像: + +```bash +./scripts/verify-sub2api-image.sh \ + --image ghcr.io/OWNER/sub2api@sha256:DIGEST \ + --source-repo BFanSYe/sub2api \ + --expected-revision SOURCE_COMMIT \ + --upstream-repo Wei-Shaw/sub2api \ + --upstream-ref main \ + --required-fix UPSTREAM_HOTFIX_SHA +``` + +2. 确认输出包含: + - image inspect 成功. + - `org.opencontainers.image.revision` 等于 source commit. + - source commit 存在. + - required fixes 全部为 source commit 的祖先. + - missing upstream total 与 non-merge counts 已打印. + +## 生产部署步骤 + +1. 只把 Compose image 改成 digest pinned reference: + +```yaml +image: ghcr.io/OWNER/sub2api@sha256:DIGEST +``` + +2. 禁止使用以下形式部署生产: + +```yaml +image: ghcr.io/OWNER/sub2api:latest +image: ghcr.io/OWNER/sub2api:main +image: ghcr.io/OWNER/sub2api:SHORT_SHA +``` + +3. 不把生产 secret, 生产域名或 `.env` 文件复制进镜像. +4. 部署后执行只读验证: + +```bash +./scripts/verify-sub2api-deploy.sh \ + --service sub2api \ + --expected-image ghcr.io/OWNER/sub2api@sha256:DIGEST \ + --expected-revision SOURCE_COMMIT \ + --public-url https://PUBLIC_HOST/ \ + --homepage-marker "MARKER_TEXT" +``` + +5. 确认容器状态为 running. +6. 如配置了 healthcheck, 确认状态为 healthy. +7. 确认 `Config.Image` 与 digest pinned reference 完全一致. +8. 确认本地镜像 revision label 等于 source commit. +9. 确认 public URL HEAD 请求成功. +10. 如提供 homepage marker, 确认页面正文包含 marker. + +## 回滚步骤 + +1. 从上一版发布记录或 Compose 历史中取回旧 digest. +2. 将 Compose image 改回旧 digest pinned reference. +3. 执行正常部署命令. +4. 执行 `verify-sub2api-deploy.sh`. +5. 记录回滚 digest, source commit, 时间与原因. +6. 不使用 `latest` 作为临时回滚目标. + +## 禁止项 + +- 禁止生产 Compose 使用 `latest`. +- 禁止把生产 secret, token, password, private key, `.env` 内容写入镜像层. +- 禁止在验证脚本中执行 docker pull, docker compose up, restart, rm, tag, push 或 prune. +- 禁止在镜像构建日志中打印 secret. +- 禁止用未验证的 tag 替代 digest. +- 禁止跳过 required fixes 祖先检查后发布. + +## 发布记录字段 + +每次生产发布至少记录: + +- `source_repo` +- `source_ref_input` +- `source_commit` +- `upstream_repo` +- `upstream_ref` +- `upstream_commit` +- `missing_upstream_total` +- `missing_upstream_nonmerge` +- `image` +- `digest` +- `required_fixes` +- `build_run_url` +- 生产 Compose 中实际使用的 `image@sha256:...` diff --git a/scripts/verify-sub2api-deploy.sh b/scripts/verify-sub2api-deploy.sh new file mode 100755 index 00000000000..91aabc5037e --- /dev/null +++ b/scripts/verify-sub2api-deploy.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + verify-sub2api-deploy.sh \ + [--service SERVICE_OR_CONTAINER] \ + [--expected-image IMAGE_REF] \ + [--expected-revision SHA] \ + [--public-url URL] \ + [--homepage-marker TEXT] + +Options: + --service SERVICE_OR_CONTAINER + Container name/id or Docker Compose service label to inspect. + Default: sub2api + + --expected-image IMAGE_REF + Expected Config.Image value, preferably ghcr.io/owner/name@sha256:... + + --expected-revision SHA + Expected org.opencontainers.image.revision label on the local image. + + --public-url URL + Public URL to check with curl -fsSI. + + --homepage-marker TEXT + Text marker that must appear in the public URL response body. + Requires --public-url. + +Notes: + - Read-only: no restart, pull, compose up, or container mutation. + - Does not run docker compose and does not read .env. +EOF +} + +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +ok() { + printf 'OK: %s\n' "$*" +} + +info() { + printf 'INFO: %s\n' "$*" +} + +require_value() { + local option="$1" + local value="${2:-}" + if [ -z "$value" ] || [[ "$value" == --* ]]; then + die "$option requires a value" + fi +} + +resolve_container() { + local service="$1" + local direct_id + local -a ids + + if direct_id="$(docker inspect --type container --format '{{.Id}}' "$service" 2>/dev/null)"; then + printf '%s\n' "$direct_id" + return + fi + + mapfile -t ids < <(docker ps -aq --filter "label=com.docker.compose.service=$service") + case "${#ids[@]}" in + 0) + die "container not found by name/id or compose service label: $service" + ;; + 1) + printf '%s\n' "${ids[0]}" + ;; + *) + printf 'ERROR: multiple containers match compose service label %s:\n' "$service" >&2 + printf ' %s\n' "${ids[@]}" >&2 + die "pass an exact container name or id with --service" + ;; + esac +} + +SERVICE="sub2api" +EXPECTED_IMAGE="" +EXPECTED_REVISION="" +PUBLIC_URL="" +HOMEPAGE_MARKER="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --service) + require_value "$1" "${2:-}" + SERVICE="$2" + shift 2 + ;; + --expected-image) + require_value "$1" "${2:-}" + EXPECTED_IMAGE="$2" + shift 2 + ;; + --expected-revision) + require_value "$1" "${2:-}" + EXPECTED_REVISION="$2" + shift 2 + ;; + --public-url) + require_value "$1" "${2:-}" + PUBLIC_URL="$2" + shift 2 + ;; + --homepage-marker) + require_value "$1" "${2:-}" + HOMEPAGE_MARKER="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown argument: $1" + ;; + esac +done + +[ -n "$SERVICE" ] || die "--service must not be empty" +if [ -n "$HOMEPAGE_MARKER" ] && [ -z "$PUBLIC_URL" ]; then + die "--homepage-marker requires --public-url" +fi + +command -v docker >/dev/null 2>&1 || die "docker is required" +if [ -n "$PUBLIC_URL" ]; then + command -v curl >/dev/null 2>&1 || die "curl is required when --public-url is set" +fi + +container_id="$(resolve_container "$SERVICE")" +info "container id: $container_id" + +state_status="$(docker inspect --format '{{.State.Status}}' "$container_id")" +if [ "$state_status" = "running" ]; then + ok "container state is running" +else + die "container state is not running: $state_status" +fi + +health_status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id")" +case "$health_status" in + healthy) + ok "container health is healthy" + ;; + none) + ok "container has no healthcheck" + ;; + *) + die "container health is not healthy: $health_status" + ;; +esac + +config_image="$(docker inspect --format '{{.Config.Image}}' "$container_id")" +info "container Config.Image: $config_image" +if [ -n "$EXPECTED_IMAGE" ]; then + if [ "$config_image" = "$EXPECTED_IMAGE" ]; then + ok "Config.Image matches expected image" + else + die "Config.Image mismatch. expected=$EXPECTED_IMAGE actual=$config_image" + fi +fi + +if [ -n "$EXPECTED_REVISION" ]; then + image_id="$(docker inspect --format '{{.Image}}' "$container_id")" + revision_label="$( + docker image inspect \ + --format '{{if .Config.Labels}}{{index .Config.Labels "org.opencontainers.image.revision"}}{{end}}' \ + "$image_id" 2>/dev/null || true + )" + + if [ -z "$revision_label" ]; then + die "local image is missing org.opencontainers.image.revision label" + fi + + if [ "$revision_label" = "$EXPECTED_REVISION" ]; then + ok "local image revision label matches expected revision" + else + die "local image revision label mismatch. expected=$EXPECTED_REVISION actual=$revision_label" + fi +fi + +if [ -n "$PUBLIC_URL" ]; then + curl -fsSI --max-time 15 "$PUBLIC_URL" >/dev/null + ok "public URL HEAD request succeeded: $PUBLIC_URL" + + if [ -n "$HOMEPAGE_MARKER" ]; then + if curl -fsSL --max-time 20 "$PUBLIC_URL" | grep -F -- "$HOMEPAGE_MARKER" >/dev/null; then + ok "homepage marker found" + else + die "homepage marker not found in public URL response" + fi + fi +fi + +ok "deployment verification passed" diff --git a/scripts/verify-sub2api-image.sh b/scripts/verify-sub2api-image.sh new file mode 100755 index 00000000000..ba196e77005 --- /dev/null +++ b/scripts/verify-sub2api-image.sh @@ -0,0 +1,270 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + verify-sub2api-image.sh \ + --image IMAGE_REF \ + --source-repo OWNER/REPO_OR_LOCAL_PATH \ + --expected-revision SHA \ + [--upstream-repo OWNER/REPO] \ + [--upstream-ref REF] \ + [--required-fix SHA ...] + +Required: + --image IMAGE_REF + Remote image reference to inspect, preferably ghcr.io/owner/name@sha256:... + + --source-repo OWNER/REPO_OR_LOCAL_PATH + GitHub owner/repo or a local git repository path containing --expected-revision. + + --expected-revision SHA + Source commit expected in org.opencontainers.image.revision. + +Optional: + --upstream-repo OWNER/REPO + Upstream GitHub repository used for missing-commit counts. + Default: Wei-Shaw/sub2api + + --upstream-ref REF + Upstream branch, tag, or commit used for missing-commit counts. + Default: main + + --required-fix SHA + Commit that must be an ancestor of --expected-revision. Repeatable. + +Notes: + - Read-only against Docker and local source repositories. + - Uses docker buildx imagetools inspect; it never docker pulls. + - Uses a temporary git repository for remote fetches and comparisons. +EOF +} + +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +warn() { + printf 'WARN: %s\n' "$*" >&2 +} + +ok() { + printf 'OK: %s\n' "$*" +} + +info() { + printf 'INFO: %s\n' "$*" +} + +require_value() { + local option="$1" + local value="${2:-}" + if [ -z "$value" ] || [[ "$value" == --* ]]; then + die "$option requires a value" + fi +} + +is_owner_repo() { + [[ "$1" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] +} + +github_repo_url() { + local repo="$1" + is_owner_repo "$repo" || die "expected OWNER/REPO, got: $repo" + printf 'https://github.com/%s.git\n' "$repo" +} + +source_repo_url_or_path() { + local repo="$1" + if [ -e "$repo" ]; then + git -C "$repo" rev-parse --git-dir >/dev/null 2>&1 || die "local path is not a git repository: $repo" + printf '%s\n' "$repo" + return + fi + + github_repo_url "$repo" +} + +extract_revision_label() { + local json_file="$1" + python3 - "$json_file" <<'PY' +import json +import sys + +target = "org.opencontainers.image.revision" + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +found = [] + +def walk(value): + if isinstance(value, dict): + direct = value.get(target) + if isinstance(direct, (str, int)): + found.append(str(direct)) + + for key in ("Labels", "labels", "Annotations", "annotations"): + labels = value.get(key) + if isinstance(labels, dict): + label_value = labels.get(target) + if isinstance(label_value, (str, int)): + found.append(str(label_value)) + + for child in value.values(): + walk(child) + elif isinstance(value, list): + for child in value: + walk(child) + +walk(data) +print(found[0] if found else "") +PY +} + +IMAGE_REF="" +SOURCE_REPO="" +EXPECTED_REVISION="" +UPSTREAM_REPO="Wei-Shaw/sub2api" +UPSTREAM_REF="main" +declare -a REQUIRED_FIXES=() + +while [ "$#" -gt 0 ]; do + case "$1" in + --image) + require_value "$1" "${2:-}" + IMAGE_REF="$2" + shift 2 + ;; + --source-repo) + require_value "$1" "${2:-}" + SOURCE_REPO="$2" + shift 2 + ;; + --expected-revision) + require_value "$1" "${2:-}" + EXPECTED_REVISION="$2" + shift 2 + ;; + --upstream-repo) + require_value "$1" "${2:-}" + UPSTREAM_REPO="$2" + shift 2 + ;; + --upstream-ref) + require_value "$1" "${2:-}" + UPSTREAM_REF="$2" + shift 2 + ;; + --required-fix) + require_value "$1" "${2:-}" + REQUIRED_FIXES+=("$2") + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown argument: $1" + ;; + esac +done + +[ -n "$IMAGE_REF" ] || die "--image is required" +[ -n "$SOURCE_REPO" ] || die "--source-repo is required" +[ -n "$EXPECTED_REVISION" ] || die "--expected-revision is required" +[ -n "$UPSTREAM_REF" ] || die "--upstream-ref must not be empty" + +command -v docker >/dev/null 2>&1 || die "docker is required" +command -v git >/dev/null 2>&1 || die "git is required" +command -v python3 >/dev/null 2>&1 || die "python3 is required for JSON label inspection" + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +gitdir="$tmpdir/git" +git init -q "$gitdir" + +source_location="$(source_repo_url_or_path "$SOURCE_REPO")" +upstream_url="$(github_repo_url "$UPSTREAM_REPO")" + +git -C "$gitdir" remote add source "$source_location" +git -C "$gitdir" remote add upstream "$upstream_url" + +info "Fetching source revision from $SOURCE_REPO" +if ! git -C "$gitdir" fetch --quiet --no-tags source "$EXPECTED_REVISION"; then + die "source revision not found in $SOURCE_REPO: $EXPECTED_REVISION" +fi +source_commit="$(git -C "$gitdir" rev-parse --verify 'FETCH_HEAD^{commit}')" +ok "source commit exists: $source_commit" + +failed=0 + +if [ "${#REQUIRED_FIXES[@]}" -eq 0 ]; then + info "No required fixes declared" +else + for fix in "${REQUIRED_FIXES[@]}"; do + if ! git -C "$gitdir" cat-file -e "$fix^{commit}" 2>/dev/null; then + git -C "$gitdir" fetch --quiet --no-tags source "$fix" 2>/dev/null || true + fi + + if ! fix_commit="$(git -C "$gitdir" rev-parse --verify "$fix^{commit}" 2>/dev/null)"; then + printf 'FAIL: required fix is not present in source repository: %s\n' "$fix" >&2 + failed=1 + continue + fi + + if git -C "$gitdir" merge-base --is-ancestor "$fix_commit" "$source_commit"; then + ok "required fix is an ancestor: $fix_commit" + else + printf 'FAIL: required fix is not an ancestor of source commit: %s\n' "$fix_commit" >&2 + failed=1 + fi + done +fi + +info "Fetching upstream ref $UPSTREAM_REPO@$UPSTREAM_REF" +if ! git -C "$gitdir" fetch --quiet --no-tags upstream "$UPSTREAM_REF"; then + die "upstream ref not found: $UPSTREAM_REPO@$UPSTREAM_REF" +fi +upstream_commit="$(git -C "$gitdir" rev-parse --verify 'FETCH_HEAD^{commit}')" +missing_total="$(git -C "$gitdir" rev-list --count "${source_commit}..${upstream_commit}")" +missing_nonmerge="$(git -C "$gitdir" rev-list --count --no-merges "${source_commit}..${upstream_commit}")" +info "upstream commit: $upstream_commit" +info "missing upstream commits: total=$missing_total nonmerge=$missing_nonmerge" + +info "Inspecting remote image: $IMAGE_REF" +if docker buildx imagetools inspect "$IMAGE_REF" >"$tmpdir/image.inspect.txt"; then + ok "remote image inspect succeeded" +else + cat "$tmpdir/image.inspect.txt" >&2 || true + die "remote image inspect failed: $IMAGE_REF" +fi + +if docker buildx imagetools inspect "$IMAGE_REF" --format '{{json .}}' >"$tmpdir/image.inspect.json" 2>"$tmpdir/image.inspect-json.err"; then + revision_label="$(extract_revision_label "$tmpdir/image.inspect.json")" + if [ -n "$revision_label" ]; then + if [ "$revision_label" = "$source_commit" ]; then + ok "image revision label matches expected revision: $revision_label" + else + printf 'FAIL: image revision label mismatch. expected=%s actual=%s\n' "$source_commit" "$revision_label" >&2 + failed=1 + fi + else + printf 'FAIL: org.opencontainers.image.revision label was not found in imagetools JSON output\n' >&2 + failed=1 + fi +else + sed 's/^/FAIL: /' "$tmpdir/image.inspect-json.err" >&2 || true + printf 'FAIL: could not inspect OCI labels via imagetools JSON\n' >&2 + failed=1 +fi + +if [ "$failed" -ne 0 ]; then + die "verification failed" +fi + +ok "self-built image verification passed"