diff --git a/.github/actions/web-ci/action.yml b/.github/actions/web-ci/action.yml index b37f99e4..0cdcf176 100644 --- a/.github/actions/web-ci/action.yml +++ b/.github/actions/web-ci/action.yml @@ -23,7 +23,9 @@ runs: - name: Audit web dependencies shell: bash - run: pnpm --dir web audit --audit-level high + # 只审计生产依赖:devDependencies 经 Vite 构建后不进入 internal/webui/dist, + # 让它们的 CVE 阻断发布只会制造噪音。 + run: pnpm --dir web audit --prod --audit-level high - name: Lint web source shell: bash diff --git a/.github/release-assets.txt b/.github/release-assets.txt new file mode 100644 index 00000000..bc87cb62 --- /dev/null +++ b/.github/release-assets.txt @@ -0,0 +1,12 @@ +Apache-2.0.txt +LICENSE +MIT.txt +MPL-2.0.txt +SHA256SUMS +THIRD_PARTY_NOTICES.md +bom.cdx.json +gpt-load-linux-amd64 +gpt-load-linux-arm64 +gpt-load-macos-amd64 +gpt-load-macos-arm64 +gpt-load-windows-amd64.exe diff --git a/.github/scripts/release-docker-smoke.sh b/.github/scripts/release-docker-smoke.sh index a14e34ee..8c5112d1 100755 --- a/.github/scripts/release-docker-smoke.sh +++ b/.github/scripts/release-docker-smoke.sh @@ -6,6 +6,14 @@ release_version="${RELEASE_VERSION:-v2.0.0-local}" suffix="${RELEASE_SMOKE_SUFFIX:-local-$$}" source_image="${RELEASE_SMOKE_SOURCE_IMAGE:-}" trivy_image="${RELEASE_SMOKE_TRIVY_IMAGE:-aquasec/trivy:0.72.0@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f}" +skip_scan="${RELEASE_SMOKE_SKIP_SCAN:-false}" +case "${skip_scan}" in + true | false) ;; + *) + printf 'RELEASE_SMOKE_SKIP_SCAN must be true or false\n' >&2 + exit 1 + ;; +esac case "${suffix}" in *[!A-Za-z0-9_-]*) printf 'RELEASE_SMOKE_SUFFIX contains unsupported characters\n' >&2 @@ -122,15 +130,22 @@ else -t "${image}" . fi smoke_stage="scan-image" -docker run --rm \ - --volume /var/run/docker.sock:/var/run/docker.sock \ - "${trivy_image}" image \ - --scanners vuln \ - --severity CRITICAL,HIGH \ - --ignore-unfixed \ - --exit-code 1 \ - --no-progress \ - "${image}" +# 发布后阶段拉取的是发布前已扫描过的同一 commit 镜像,重复扫描没有新信息, +# 由调用方通过 RELEASE_SMOKE_SKIP_SCAN=true 显式跳过。 +scanned=true +if [[ "${skip_scan}" == "true" ]]; then + scanned=false +else + docker run --rm \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + "${trivy_image}" image \ + --scanners vuln \ + --severity CRITICAL,HIGH \ + --ignore-unfixed \ + --exit-code 1 \ + --no-progress \ + "${image}" +fi test "$(docker image inspect -f '{{.Config.User}}' "${image}")" = "10001:10001" docker image inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "${image}" | grep -Fx 'HOST=0.0.0.0' >/dev/null @@ -456,6 +471,7 @@ smoke_stage="verify-secret-free-artifacts" summary_file="${task_tmp}/summary.txt" { printf 'platform=%s\n' "${platform}" + printf 'vulnerability_scanned=%s\n' "${scanned}" printf 'configured_user=10001:10001\n' printf 'image_and_container_host=0.0.0.0\n' printf 'direct_docker_run_publish_reachable=true\n' diff --git a/.github/scripts/release-verify-assets.sh b/.github/scripts/release-verify-assets.sh new file mode 100755 index 00000000..f25e5a37 --- /dev/null +++ b/.github/scripts/release-verify-assets.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 校验一个发布资产目录是否与 .github/release-assets.txt 完全一致: +# 1. 目录内的普通文件清单必须逐字等于清单文件; +# 2. SHA256SUMS 必须恰好覆盖清单中除自身以外的全部资产; +# 3. 每一项校验和都必须通过。 +# 清单文件是发布资产的唯一事实源,新增或改名资产只需要改动它。 +# +# 用法: +# release-verify-assets.sh +# release-verify-assets.sh +# +# 校验从远端下载的资产时必须传入第二个参数。此时目录内那份 SHA256SUMS 是不可信 +# 输入,只用于身份比对,绝不执行;实际校验一律使用当前 run 生成的可信副本。 + +directory="${1:?release asset directory is required}" +trusted_checksums="${2:-}" +manifest="${RELEASE_ASSET_MANIFEST:-.github/release-assets.txt}" + +absolute_path() { + printf '%s/%s' "$(cd "$(dirname "$1")" && pwd)" "$(basename "$1")" +} + +test -d "${directory}" +test -s "${manifest}" +manifest="$(absolute_path "${manifest}")" +if [[ -n "${trusted_checksums}" ]]; then + test -s "${trusted_checksums}" + trusted_checksums="$(absolute_path "${trusted_checksums}")" +fi + +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT + +# 清单本身必须保持 C 序排序,否则后续所有 cmp 都会随 locale 漂移。 +LC_ALL=C sort "${manifest}" >"${work}/expected" +cmp "${manifest}" "${work}/expected" + +find "${directory}" -mindepth 1 -maxdepth 1 -type f -exec basename {} \; | + LC_ALL=C sort >"${work}/actual" +cmp "${work}/expected" "${work}/actual" + +grep -vFx 'SHA256SUMS' "${work}/expected" >"${work}/expected-checksummed" + +cd "${directory}" +awk '{print $2}' SHA256SUMS | LC_ALL=C sort >"${work}/actual-checksummed" +cmp "${work}/expected-checksummed" "${work}/actual-checksummed" + +# release-assets-trusted-checksum:start +if [[ -n "${trusted_checksums}" ]]; then + cmp "${trusted_checksums}" SHA256SUMS + sha256sum --check "${trusted_checksums}" +else + sha256sum --check SHA256SUMS +fi +# release-assets-trusted-checksum:end diff --git a/.github/scripts/release-verify-image-revision.sh b/.github/scripts/release-verify-image-revision.sh new file mode 100755 index 00000000..3251a13c --- /dev/null +++ b/.github/scripts/release-verify-image-revision.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 断言多架构镜像的 linux/amd64 与 linux/arm64 两个 config 标签 +# org.opencontainers.image.revision 都等于期望的 commit SHA。 +# +# registry 返回的标签值是不可信输入,只允许在 jq 比较内部使用:进入 shell 的 +# 永远只有受信的 ${expected} 或字面量 mismatch,实际读到的值仅直接写入 stderr。 +# +# 退出码约定,调用方据此区分「确实不匹配」与「无法完成检查」: +# 0 = 两个架构都匹配,stdout 输出该 revision +# 1 = 镜像可读但 revision 不匹配,stderr 输出实际读到的标签 +# 2 = 无法读取镜像清单(网络、认证或镜像缺失),stderr 输出原始错误 + +image="${1:?image is required}" +expected="${2:?expected revision is required}" + +if ! inspection="$( + docker buildx imagetools inspect "${image}" --format '{{json .}}' 2>&1 +)"; then + printf '%s\n' "${inspection}" >&2 + exit 2 +fi + +# release-image-revision-validation:start +revision=mismatch +if jq -e \ + --arg expected "${expected}" \ + '[ + .image["linux/amd64"].config.Labels[ + "org.opencontainers.image.revision" + ], + .image["linux/arm64"].config.Labels[ + "org.opencontainers.image.revision" + ] + ] | all(type == "string" and . == $expected)' \ + <<<"${inspection}" >/dev/null 2>&1; then + revision="${expected}" +fi +# release-image-revision-validation:end + +if [[ "${revision}" == "mismatch" ]]; then + printf 'image revision mismatch for %s (expected %s), actual labels: ' \ + "${image}" "${expected}" >&2 + jq -c '[ + .image["linux/amd64"].config.Labels[ + "org.opencontainers.image.revision" + ], + .image["linux/arm64"].config.Labels[ + "org.opencontainers.image.revision" + ] + ]' <<<"${inspection}" >&2 2>/dev/null || printf 'unreadable\n' >&2 + exit 1 +fi + +printf '%s' "${revision}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e5019a1..895cbec1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,10 @@ jobs: validate-tag: runs-on: ubuntu-24.04 timeout-minutes: 5 + permissions: + contents: read + # 读取同一 commit 上的 CI 结论需要 actions:read。 + actions: read outputs: version: ${{ steps.tag.outputs.version }} prerelease: ${{ steps.tag.outputs.prerelease }} @@ -23,6 +27,7 @@ jobs: image_minor: ${{ steps.tag.outputs.image_minor }} image_major: ${{ steps.tag.outputs.image_major }} beta: ${{ steps.tag.outputs.beta }} + ci_verified: ${{ steps.ci.outputs.ci_verified }} steps: - name: Checkout tag commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -76,10 +81,39 @@ jobs: # release-tag-validation:end - name: Require tag commit in v2 history + shell: bash run: | git fetch --no-tags origin v2 git merge-base --is-ancestor "${GITHUB_SHA}" origin/v2 + - name: Reuse CI verdict for this commit + id: ci + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # race 测试、CPA 桥接测试与数据库合同都是同一 commit 上的确定性验证, + # ci.yml 已经整体成功过时重跑不会得出不同结论,只会让关键路径多等约 8 分钟。 + # 查不到成功记录、查询失败或结论不是 success 时一律回落为全量重跑。 + # release-ci-verdict-reuse:start + runs=query-failed + ci_verified=false + if runs="$( + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${GITHUB_SHA}&per_page=100" \ + --jq '[ + .workflow_runs[] + | select(.status == "completed" and .conclusion == "success") + ] | length' + )" && [[ "${runs}" =~ ^[0-9]+$ ]] && ((runs > 0)); then + ci_verified=true + fi + # release-ci-verdict-reuse:end + printf 'ci_verified=%s\n' "${ci_verified}" >>"${GITHUB_OUTPUT}" + printf 'successful ci.yml runs for %s: %s -> ci_verified=%s\n' \ + "${GITHUB_SHA}" "${runs}" "${ci_verified}" + verify-and-build-web: needs: validate-tag runs-on: ubuntu-24.04 @@ -99,7 +133,8 @@ jobs: name: verified-web-dist path: internal/webui/dist if-no-files-found: error - retention-days: 14 + # 仅在本次 run 内传递给 build-binaries,不作为发布产物保留。 + retention-days: 1 static-checks: needs: validate-tag @@ -147,6 +182,8 @@ jobs: race-tests: needs: validate-tag + # 同一 commit 的 CI 已全绿时跳过;publication-preflight 接受 skipped 结果。 + if: ${{ needs.validate-tag.outputs.ci_verified != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 20 steps: @@ -166,6 +203,7 @@ jobs: race-cpa: needs: validate-tag + if: ${{ needs.validate-tag.outputs.ci_verified != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 10 steps: @@ -188,6 +226,7 @@ jobs: database-contract: name: Database contract (${{ matrix.driver }}) needs: validate-tag + if: ${{ needs.validate-tag.outputs.ci_verified != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 15 strategy: @@ -301,7 +340,8 @@ jobs: name: binary-${{ matrix.filename }} path: release/${{ matrix.filename }} if-no-files-found: error - retention-days: 14 + # 仅在本次 run 内汇总进 release-assets,不单独保留。 + retention-days: 1 package-metadata: needs: validate-tag @@ -336,7 +376,8 @@ jobs: name: release-metadata path: release if-no-files-found: error - retention-days: 14 + # 仅在本次 run 内汇总进 release-assets,不单独保留。 + retention-days: 1 package-checksums: needs: @@ -345,6 +386,11 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: + - name: Checkout tag commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Download all release binaries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -359,21 +405,16 @@ jobs: path: release - name: Generate SHA256SUMS - working-directory: release + shell: bash run: | - sha256sum \ - Apache-2.0.txt \ - LICENSE \ - MIT.txt \ - MPL-2.0.txt \ - THIRD_PARTY_NOTICES.md \ - bom.cdx.json \ - gpt-load-linux-amd64 \ - gpt-load-linux-arm64 \ - gpt-load-macos-amd64 \ - gpt-load-macos-arm64 \ - gpt-load-windows-amd64.exe >SHA256SUMS - sha256sum --check SHA256SUMS + set -euo pipefail + # 校验和覆盖 .github/release-assets.txt 中除 SHA256SUMS 自身外的全部资产。 + grep -vFx 'SHA256SUMS' .github/release-assets.txt >/tmp/checksum-targets + cd release + xargs -a /tmp/checksum-targets -d '\n' sha256sum >SHA256SUMS + + - name: Verify complete release asset set + run: .github/scripts/release-verify-assets.sh release - name: Upload complete release assets uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -384,9 +425,8 @@ jobs: retention-days: 14 native-artifact-smoke: - needs: - - build-binaries - - package-checksums + # package-checksums 已经依赖 build-binaries,无需重复声明。 + needs: package-checksums timeout-minutes: 10 strategy: fail-fast: true @@ -455,6 +495,9 @@ jobs: run: .github/scripts/release-docker-smoke.sh publication-preflight: + # 这里必须逐个列出全部 gate,即使其中几个已被别的 needs 间接依赖: + # 下面的 if 只能看到直接 needs 的 result,上游失败会让中间 job 变成 skipped, + # 漏列任何一个都会让它的 failure 无法被察觉。清理 needs 前先读这条注释。 needs: - validate-tag - verify-and-build-web @@ -466,6 +509,9 @@ jobs: - native-artifact-smoke - docker-smoke - database-contract + # race-tests、race-cpa 与 database-contract 在 CI 已验证同一 commit 时为 skipped, + # 这里只拒绝真正失败的 gate,不把 skipped 当作缺失的验证。 + if: ${{ !cancelled() && !contains(needs.*.result, 'failure') }} runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: @@ -508,32 +554,16 @@ jobs: git fetch --no-tags origin v2 git merge-base --is-ancestor "${GITHUB_SHA}" origin/v2 - cat >/tmp/expected-release-assets <<'ASSETS' - Apache-2.0.txt - LICENSE - MIT.txt - MPL-2.0.txt - SHA256SUMS - THIRD_PARTY_NOTICES.md - bom.cdx.json - gpt-load-linux-amd64 - gpt-load-linux-arm64 - gpt-load-macos-amd64 - gpt-load-macos-arm64 - gpt-load-windows-amd64.exe - ASSETS - find release -mindepth 1 -maxdepth 1 -type f -exec basename {} \; | - sort >/tmp/current-run-release-assets - cmp /tmp/expected-release-assets /tmp/current-run-release-assets - cd release - test "$(wc -l /tmp/existing-release-assets - if cmp \ - /tmp/expected-release-assets \ - /tmp/existing-release-assets; then + LC_ALL=C sort >/tmp/existing-release-assets + github_assets=mismatch + if cmp .github/release-assets.txt /tmp/existing-release-assets; then rm -rf existing-release mkdir existing-release gh release download "${GITHUB_REF_NAME}" \ --repo "${GITHUB_REPOSITORY}" \ --dir existing-release - if cmp release/SHA256SUMS existing-release/SHA256SUMS; then - if ( - cd existing-release - sha256sum --check ../release/SHA256SUMS - ); then - github_assets=match - else - github_assets=mismatch - fi - else - github_assets=mismatch + # 已发布资产必须与本次 run 的 SHA256SUMS 逐字节一致; + # 传入可信副本后,远端那份 SHA256SUMS 只参与比对,不会被执行。 + if .github/scripts/release-verify-assets.sh \ + existing-release release/SHA256SUMS; then + github_assets=match fi - else - github_assets=mismatch fi elif grep -Eqi \ 'HTTP 404|Not Found|release not found' \ @@ -640,8 +661,8 @@ jobs: inventory_image() { local image="$1" local digest= - local inspection= - local revision=mismatch + local revision= + local status=0 digest="$(.github/scripts/release-image-digest.sh "${image}")" if [[ "${digest}" == "absent" ]]; then @@ -649,40 +670,33 @@ jobs: return fi - inspection="$( - docker buildx imagetools inspect \ - "${image}" \ - --format '{{json .}}' - )" - # release-image-revision-validation:start - if jq -e \ - --arg expected "${GITHUB_SHA}" \ - '[ - .image["linux/amd64"].config.Labels[ - "org.opencontainers.image.revision" - ], - .image["linux/arm64"].config.Labels[ - "org.opencontainers.image.revision" - ] - ] | all(type == "string" and . == $expected)' \ - <<<"${inspection}" >/dev/null; then - revision="${GITHUB_SHA}" + # 退出码 1 表示确实读到了不匹配的 revision,降级为 mismatch 交给分类器; + # 其他非零表示无法完成检查,此时让 job 失败以区分基础设施问题。 + revision="$( + .github/scripts/release-verify-image-revision.sh \ + "${image}" "${GITHUB_SHA}" + )" || status=$? + if ((status != 0)); then + test "${status}" = "1" + revision=mismatch fi - # release-image-revision-validation:end printf 'present|%s|%s\n' "${digest}" "${revision}" } - IFS='|' read -r ghcr_state ghcr_digest ghcr_revision < <( + # 用命令替换而非进程替换:函数内的失败必须终止本 step, + # 而不是让 read 静默读到空状态。 + ghcr_inventory="$( inventory_image \ "ghcr.io/tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}" - ) - IFS='|' read -r \ - dockerhub_state \ - dockerhub_digest \ - dockerhub_revision < <( - inventory_image \ - "tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}" - ) + )" + dockerhub_inventory="$( + inventory_image \ + "tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}" + )" + IFS='|' read -r ghcr_state ghcr_digest ghcr_revision \ + <<<"${ghcr_inventory}" + IFS='|' read -r dockerhub_state dockerhub_digest dockerhub_revision \ + <<<"${dockerhub_inventory}" ghcr_latest_digest="$( .github/scripts/release-image-digest.sh ghcr.io/tbphp/gpt-load:latest )" @@ -820,6 +834,10 @@ jobs: labels: ${{ steps.meta.outputs.labels }} build-args: | VERSION=${{ github.ref_name }} + # 复用上一次发布的依赖层(go mod download / pnpm install); + # Go 与 Web 源码层每个 tag 必然失效,这里只回收不变的依赖安装成本。 + cache-from: type=gha,scope=release-image + cache-to: type=gha,scope=release-image,mode=max - name: Verify exact published images shell: bash @@ -827,24 +845,9 @@ jobs: set -euo pipefail verify_exact_image() { local image="$1" - local inspection= - inspection="$( - docker buildx imagetools inspect \ - "${image}" \ - --format '{{json .}}' - )" - jq -e \ - --arg expected "${GITHUB_SHA}" \ - '[ - .image["linux/amd64"].config.Labels[ - "org.opencontainers.image.revision" - ], - .image["linux/arm64"].config.Labels[ - "org.opencontainers.image.revision" - ] - ] | all(type == "string" and . == $expected)' \ - <<<"${inspection}" >/dev/null + .github/scripts/release-verify-image-revision.sh \ + "${image}" "${GITHUB_SHA}" >/dev/null .github/scripts/release-image-digest.sh "${image}" } @@ -896,15 +899,20 @@ jobs: permissions: contents: write steps: + - name: Checkout tag commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Download complete release assets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-assets path: release - - name: Verify release checksums - working-directory: release - run: sha256sum --check SHA256SUMS + # 上传步骤按 release/* 通配收集,这里的清单校验就是它的边界保证。 + - name: Verify complete release asset set + run: .github/scripts/release-verify-assets.sh release - name: Verify existing GitHub Release if: ${{ needs.publication-preflight.outputs.write_mode == 'verify' }} @@ -941,11 +949,8 @@ jobs: gh release download "${GITHUB_REF_NAME}" \ --repo "${GITHUB_REPOSITORY}" \ --dir existing-release - cmp release/SHA256SUMS existing-release/SHA256SUMS - ( - cd existing-release - sha256sum --check ../release/SHA256SUMS - ) + .github/scripts/release-verify-assets.sh \ + existing-release release/SHA256SUMS - name: Create or update GitHub Release draft if: ${{ needs.publication-preflight.outputs.write_mode == 'publish' }} @@ -958,19 +963,8 @@ jobs: make_latest: false generate_release_notes: true fail_on_unmatched_files: true - files: | - release/gpt-load-linux-amd64 - release/gpt-load-linux-arm64 - release/gpt-load-macos-amd64 - release/gpt-load-macos-arm64 - release/gpt-load-windows-amd64.exe - release/Apache-2.0.txt - release/LICENSE - release/MIT.txt - release/MPL-2.0.txt - release/THIRD_PARTY_NOTICES.md - release/bom.cdx.json - release/SHA256SUMS + # 目录内容已由上面的清单校验锁定为 .github/release-assets.txt 的全集。 + files: release/* body: | GPT-Load 2.x is a greenfield rewrite and is not data-compatible with 1.x. Do not open a 1.x database with this release; rebuild configuration and cut over manually. @@ -1018,66 +1012,10 @@ jobs: channel and credentials, AccessKeys authorize data-plane access, and model discovery imports available models from the selected channel. - post-publish-native-smoke: - needs: - - publish-github - - publish-images - timeout-minutes: 15 - permissions: - # Draft Releases are visible only to identities with push access. - contents: write - strategy: - fail-fast: true - max-parallel: 5 - matrix: - include: - - runner: ubuntu-24.04 - filename: gpt-load-linux-amd64 - - runner: ubuntu-24.04-arm - filename: gpt-load-linux-arm64 - - runner: macos-15-intel - filename: gpt-load-macos-amd64 - - runner: macos-15 - filename: gpt-load-macos-arm64 - - runner: windows-2025 - filename: gpt-load-windows-amd64.exe - runs-on: ${{ matrix.runner }} - steps: - - name: Checkout tag commit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Download draft GitHub Release assets - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - mkdir -p draft-release - gh release download "${GITHUB_REF_NAME}" \ - --repo "${GITHUB_REPOSITORY}" \ - --dir draft-release - - - name: Smoke drafted POSIX native artifact - if: runner.os != 'Windows' - shell: bash - env: - RELEASE_VERSION: ${{ github.ref_name }} - RELEASE_SMOKE_BINARY: ${{ github.workspace }}/draft-release/${{ matrix.filename }} - RELEASE_SMOKE_CHECKSUM_FILE: ${{ github.workspace }}/draft-release/SHA256SUMS - run: .github/scripts/release-native-smoke.sh - - - name: Smoke drafted Windows native artifact - if: runner.os == 'Windows' - shell: pwsh - env: - RELEASE_VERSION: ${{ github.ref_name }} - RELEASE_SMOKE_BINARY: ${{ github.workspace }}/draft-release/${{ matrix.filename }} - RELEASE_SMOKE_CHECKSUM_FILE: ${{ github.workspace }}/draft-release/SHA256SUMS - run: .github/scripts/release-native-smoke.ps1 - post-publish-image-smoke: + # GHCR 与 Docker Hub 的 exact tag 已在 publish-images 断言为同一 digest, + # 因此完整运行时 smoke 只需跑一遍;Docker Hub 侧的可拉取性与 digest 一致性 + # 由 post-publish-verify 覆盖。 needs: - validate-tag - publish-images @@ -1086,17 +1024,6 @@ jobs: permissions: contents: read packages: read - strategy: - fail-fast: false - max-parallel: 2 - matrix: - include: - - registry: ghcr - repository: ghcr.io/tbphp/gpt-load - suffix: ghcr - - registry: dockerhub - repository: tbphp/gpt-load - suffix: dockerhub steps: - name: Checkout tag commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -1104,26 +1031,20 @@ jobs: persist-credentials: false - name: Log in to GHCR for runtime smoke - if: ${{ matrix.registry == 'ghcr' }} uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Log in to Docker Hub for runtime smoke - if: ${{ matrix.registry == 'dockerhub' }} - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_READ_TOKEN || secrets.DOCKERHUB_TOKEN }} - - name: Run full runtime smoke for published exact image shell: bash env: RELEASE_VERSION: ${{ github.ref_name }} - RELEASE_SMOKE_SOURCE_IMAGE: ${{ matrix.repository }}:${{ needs.validate-tag.outputs.image_exact }} - RELEASE_SMOKE_SUFFIX: ${{ github.run_id }}-${{ matrix.suffix }} + RELEASE_SMOKE_SOURCE_IMAGE: ghcr.io/tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }} + RELEASE_SMOKE_SUFFIX: ${{ github.run_id }}-ghcr + # 同一 commit 的镜像已在 docker-smoke 扫描过,这里不重复扫描。 + RELEASE_SMOKE_SKIP_SCAN: "true" run: .github/scripts/release-docker-smoke.sh post-publish-verify: @@ -1144,6 +1065,12 @@ jobs: with: persist-credentials: false + - name: Download complete release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-assets + path: release + - name: Download and verify exact GitHub Release asset inventory env: GH_TOKEN: ${{ github.token }} @@ -1154,34 +1081,10 @@ jobs: gh release download "${GITHUB_REF_NAME}" \ --repo "${GITHUB_REPOSITORY}" \ --dir draft-release - - cat >/tmp/expected-release-assets <<'ASSETS' - Apache-2.0.txt - LICENSE - MIT.txt - MPL-2.0.txt - SHA256SUMS - THIRD_PARTY_NOTICES.md - bom.cdx.json - gpt-load-linux-amd64 - gpt-load-linux-arm64 - gpt-load-macos-amd64 - gpt-load-macos-arm64 - gpt-load-windows-amd64.exe - ASSETS - find draft-release -mindepth 1 -maxdepth 1 -type f -exec basename {} \; | - sort >/tmp/actual-release-assets - asset_count="$(wc -l /tmp/checksummed-release-assets - grep -v '^SHA256SUMS$' /tmp/expected-release-assets \ - >/tmp/expected-checksummed-release-assets - cmp /tmp/expected-checksummed-release-assets /tmp/checksummed-release-assets - sha256sum --check SHA256SUMS + # 以本次 run 的可信 SHA256SUMS 为准校验已上传的 draft 资产, + # 而不是相信 draft 自带的那一份。 + .github/scripts/release-verify-assets.sh \ + draft-release release/SHA256SUMS - name: Verify GitHub Release channel metadata env: @@ -1236,22 +1139,8 @@ jobs: dockerhub_exact_digest= for repository in ghcr.io/tbphp/gpt-load tbphp/gpt-load; do exact="${repository}:${{ needs.validate-tag.outputs.image_exact }}" - inspection="$( - docker buildx imagetools inspect \ - "${exact}" \ - --format '{{json .}}' - )" - jq -e \ - --arg expected "${GITHUB_SHA}" \ - '[ - .image["linux/amd64"].config.Labels[ - "org.opencontainers.image.revision" - ], - .image["linux/arm64"].config.Labels[ - "org.opencontainers.image.revision" - ] - ] | all(type == "string" and . == $expected)' \ - <<<"${inspection}" >/dev/null + .github/scripts/release-verify-image-revision.sh \ + "${exact}" "${GITHUB_SHA}" >/dev/null exact_digest="$( .github/scripts/release-image-digest.sh "${exact}" )" @@ -1292,13 +1181,25 @@ jobs: done test "${ghcr_exact_digest}" = "${dockerhub_exact_digest}" + - name: Verify Docker Hub exact image is pullable + shell: bash + env: + DOCKERHUB_EXACT_IMAGE: tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }} + run: | + set -euo pipefail + # 完整运行时 smoke 只在 GHCR 侧跑一遍。manifest 校验只读取 config blob, + # 不保证 layer 已完整提交,这里补上 Docker Hub 侧的 layer 可达性证据。 + docker pull --platform linux/amd64 "${DOCKERHUB_EXACT_IMAGE}" + test "$( + docker image inspect -f '{{.Config.User}}' "${DOCKERHUB_EXACT_IMAGE}" + )" = "10001:10001" + reconcile-publication: if: ${{ always() }} needs: - publication-preflight - publish-images - publish-github - - post-publish-native-smoke - post-publish-image-smoke - post-publish-verify runs-on: ubuntu-24.04 @@ -1345,7 +1246,6 @@ jobs: PREFLIGHT_RESULT: ${{ needs.publication-preflight.result }} IMAGE_WRITER_RESULT: ${{ needs.publish-images.result }} GITHUB_WRITER_RESULT: ${{ needs.publish-github.result }} - NATIVE_POST_RESULT: ${{ needs.post-publish-native-smoke.result }} IMAGE_SMOKE_RESULT: ${{ needs.post-publish-image-smoke.result }} VERIFY_POST_RESULT: ${{ needs.post-publish-verify.result }} shell: bash @@ -1363,10 +1263,11 @@ jobs: read_image_inventory() { local image="$1" local digest= - local inspection= local revisions= + local status=0 - if [[ ! -x .github/scripts/release-image-digest.sh ]]; then + if [[ ! -x .github/scripts/release-image-digest.sh ]] || + [[ ! -x .github/scripts/release-verify-image-revision.sh ]]; then printf 'unavailable' return fi @@ -1379,26 +1280,16 @@ jobs: printf 'absent' return fi - inspection="$( - docker buildx imagetools inspect \ - "${image}" \ - --format '{{json .}}' 2>/dev/null - )" - if [[ $? -ne 0 ]]; then - printf '%s/revision-unavailable' "${digest}" - return - fi + # 退出码 0/1 都带回可读的 revision 值,仅在无法读取清单时降级。 revisions="$( - jq -c '[ - .image["linux/amd64"].config.Labels[ - "org.opencontainers.image.revision" - ], - .image["linux/arm64"].config.Labels[ - "org.opencontainers.image.revision" - ] - ]' <<<"${inspection}" 2>/dev/null + .github/scripts/release-verify-image-revision.sh \ + "${image}" "${GITHUB_SHA}" 2>/dev/null )" - printf '%s/%s' "${digest}" "${revisions:-revision-unavailable}" + status=$? + if ((status > 1)) || [[ -z "${revisions}" ]]; then + revisions=revision-unavailable + fi + printf '%s/%s' "${digest}" "${revisions}" } ghcr_inventory="$( @@ -1421,7 +1312,6 @@ jobs: printf -- '- preflight: `%s`\n' "${PREFLIGHT_RESULT}" printf -- '- image writer: `%s`\n' "${IMAGE_WRITER_RESULT}" printf -- '- GitHub writer: `%s`\n' "${GITHUB_WRITER_RESULT}" - printf -- '- draft native smoke: `%s`\n' "${NATIVE_POST_RESULT}" printf -- '- published image smoke: `%s`\n' "${IMAGE_SMOKE_RESULT}" printf -- '- draft and image verification: `%s`\n' "${VERIFY_POST_RESULT}" printf -- '- GitHub exact inventory: `%s`\n' "${github_inventory}" diff --git a/internal/webui/workflow_parallelism_test.go b/internal/webui/workflow_parallelism_test.go index cf6c136b..002980db 100644 --- a/internal/webui/workflow_parallelism_test.go +++ b/internal/webui/workflow_parallelism_test.go @@ -51,13 +51,11 @@ func TestReleaseWorkflowParallelizesIndependentBuildStages(t *testing.T) { } } -func TestReleaseWorkflowRunsPublishedImageSmokesInParallel(t *testing.T) { +func TestReleaseWorkflowRunsPostPublishGatesInParallel(t *testing.T) { content := readRepositoryFile(t, ".github/workflows/release.yml") smokeJob := workflowJobBlock(t, content, "post-publish-image-smoke") for _, required := range []string{ - "max-parallel: 2", "ghcr.io/tbphp/gpt-load", - "tbphp/gpt-load", "RELEASE_SMOKE_SOURCE_IMAGE", ".github/scripts/release-docker-smoke.sh", } { @@ -65,17 +63,26 @@ func TestReleaseWorkflowRunsPublishedImageSmokesInParallel(t *testing.T) { t.Fatalf("published image smoke job does not contain %q:\n%s", required, smokeJob) } } + // 两个 registry 的 exact tag 已被断言为同一 digest,完整运行时 smoke 只跑一遍; + // Docker Hub 侧的 digest 一致性与可拉取性由 post-publish-verify 覆盖。 + if strings.Contains(smokeJob, "DOCKERHUB") { + t.Fatalf("published image smoke repeats the identical digest on a second registry:\n%s", smokeJob) + } + // 同一 commit 的镜像已在发布前的 docker-smoke 扫描过,这里不得重复扫描。 + if !strings.Contains(smokeJob, `RELEASE_SMOKE_SKIP_SCAN: "true"`) { + t.Fatalf("published image smoke repeats the pre-publication vulnerability scan:\n%s", smokeJob) + } verifyJob := workflowJobBlock(t, content, "post-publish-verify") - if strings.Contains(verifyJob, "post-publish-image-smoke") || - strings.Contains(verifyJob, "post-publish-native-smoke") { + if strings.Contains(verifyJob, "needs.post-publish-image-smoke") || + strings.Contains(verifyJob, "- post-publish-image-smoke") { t.Fatalf("independent post-publication gates are still serialized:\n%s", verifyJob) } if strings.Contains(verifyJob, "for source_and_suffix in") { t.Fatalf("post-publication verification still runs image smokes serially:\n%s", verifyJob) } reconcileJob := workflowJobBlock(t, content, "reconcile-publication") - for _, dependency := range []string{"post-publish-native-smoke", "post-publish-image-smoke", "post-publish-verify"} { + for _, dependency := range []string{"post-publish-image-smoke", "post-publish-verify"} { if !strings.Contains(reconcileJob, dependency) { t.Fatalf("publication reconciliation does not need %s:\n%s", dependency, reconcileJob) } diff --git a/internal/webui/workflow_test.go b/internal/webui/workflow_test.go index 15545d66..585e7284 100644 --- a/internal/webui/workflow_test.go +++ b/internal/webui/workflow_test.go @@ -1,11 +1,14 @@ package webui import ( + "crypto/sha256" "encoding/json" + "fmt" "os" "os/exec" "path/filepath" "regexp" + "sort" "strings" "testing" ) @@ -41,7 +44,7 @@ func TestWebCICompositeActionRunsCompleteFrontendGate(t *testing.T) { previousIndex := -1 for _, command := range []string{ "pnpm --dir web install --frozen-lockfile", - "pnpm --dir web audit --audit-level high", + "pnpm --dir web audit --prod --audit-level high", "pnpm --dir web run lint", "pnpm --dir web run format", "pnpm --dir web run build", @@ -58,6 +61,10 @@ func TestWebCICompositeActionRunsCompleteFrontendGate(t *testing.T) { if strings.Contains(content, "pnpm --dir web run type-check") { t.Fatal("web-ci action duplicates the type-check already run by the build script") } + // devDependencies 不进入 internal/webui/dist,审计它们只会让发布被无关 CVE 阻断。 + if strings.Contains(content, "pnpm --dir web audit --audit-level") { + t.Fatal("web-ci action audits devDependencies that never reach the release artifact") + } packageJSON := readRepositoryFile(t, "web/package.json") if !strings.Contains(packageJSON, `"build": "pnpm run type-check && vite build"`) { t.Fatal("web build script no longer includes the required type-check") @@ -628,7 +635,6 @@ func TestReleaseWorkflowDraftReadersRequestPushVisibility(t *testing.T) { content := readRepositoryFile(t, ".github/workflows/release.yml") for _, jobName := range []string{ "publication-preflight", - "post-publish-native-smoke", "post-publish-verify", "reconcile-publication", } { @@ -651,7 +657,7 @@ func TestReleaseWorkflowUsesOneSharedPublicationPreflight(t *testing.T) { preflight := workflowJobBlock(t, content, "publication-preflight") for _, required := range []string{ "name: release-assets", - "sha256sum --check SHA256SUMS", + ".github/scripts/release-verify-assets.sh release", "git merge-base --is-ancestor", "DOCKERHUB_READ_TOKEN: ${{ secrets.DOCKERHUB_READ_TOKEN || secrets.DOCKERHUB_TOKEN }}", "DOCKERHUB_TOKEN", @@ -688,44 +694,111 @@ func TestReleaseWorkflowUsesTrustedCurrentRunChecksumForExistingRelease(t *testi job: "publish-github", step: "Verify existing GitHub Release", }, + { + job: "post-publish-verify", + step: "Download and verify exact GitHub Release asset inventory", + }, } { step := workflowStepBlock( t, workflowJobBlock(t, content, test.job), test.step, ) - compare := strings.Index( - step, - "cmp release/SHA256SUMS existing-release/SHA256SUMS", - ) - trustedCheck := strings.Index( - step, - "sha256sum --check ../release/SHA256SUMS", - ) - if compare < 0 || trustedCheck < 0 || compare >= trustedCheck { - t.Fatalf( - "%s does not compare the checksum identity before using the trusted checksum: cmp=%d check=%d\n%s", - test.step, - compare, - trustedCheck, - step, - ) + for _, required := range []string{ + ".github/scripts/release-verify-assets.sh", + "release/SHA256SUMS", + } { + if !strings.Contains(step, required) { + t.Fatalf( + "%s does not verify downloaded assets against the current-run checksum %q:\n%s", + test.step, + required, + step, + ) + } } - if strings.Contains(step, "sha256sum --check SHA256SUMS") { - t.Fatalf("%s executes the untrusted remote checksum:\n%s", test.step, step) + if strings.Contains(step, "sha256sum --check") { + t.Fatalf("%s executes a checksum file directly:\n%s", test.step, step) } } + + // 行为验证:远端资产即使与它自带的 SHA256SUMS 完全自洽,只要与当前 run 的 + // 可信副本不一致就必须被拒绝——远端那份校验和永远不会被执行。 + repositoryRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + verifier := filepath.Join( + repositoryRoot, ".github", "scripts", "release-verify-assets.sh", + ) + var assets []string + for _, line := range strings.Split( + readRepositoryFile(t, ".github/release-assets.txt"), "\n", + ) { + if name := strings.TrimSpace(line); name != "" && name != "SHA256SUMS" { + assets = append(assets, name) + } + } + if len(assets) == 0 { + t.Fatal("release asset manifest is empty") + } + + build := func(directory string, replacements map[string]string) { + t.Helper() + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatalf("create asset directory: %v", err) + } + checksums := &strings.Builder{} + for _, name := range assets { + body := "payload-" + name + if replacement, ok := replacements[name]; ok { + body = replacement + } + path := filepath.Join(directory, name) + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write asset: %v", err) + } + fmt.Fprintf(checksums, "%x %s\n", sha256.Sum256([]byte(body)), name) + } + path := filepath.Join(directory, "SHA256SUMS") + if err := os.WriteFile(path, []byte(checksums.String()), 0o600); err != nil { + t.Fatalf("write checksums: %v", err) + } + } + + workspace := t.TempDir() + trusted := filepath.Join(workspace, "current-run") + build(trusted, nil) + forged := filepath.Join(workspace, "forged") + build(forged, map[string]string{"gpt-load-linux-amd64": "malicious"}) + + verify := func(directory string) error { + command := exec.Command( + verifier, directory, filepath.Join(trusted, "SHA256SUMS"), + ) + command.Dir = repositoryRoot + return command.Run() + } + if err := verify(trusted); err != nil { + t.Fatalf("verifier rejected assets matching the current run: %v", err) + } + if err := verify(forged); err == nil { + t.Fatal("verifier accepted self-consistent forged assets over the trusted checksum") + } } func TestReleaseWorkflowKeepsUntrustedImageRevisionInsideJQComparison(t *testing.T) { - content := readRepositoryFile(t, ".github/workflows/release.yml") + revisionVerifier := readRepositoryFile( + t, ".github/scripts/release-verify-image-revision.sh", + ) expectedSHA := "0123456789abcdef0123456789abcdef01234567" - validation := workflowMarkedScript(t, content, "release-image-revision-validation") + validation := workflowMarkedScript( + t, revisionVerifier, "release-image-revision-validation", + ) scriptPath := filepath.Join(t.TempDir(), "validate-image-revision.sh") script := "#!/usr/bin/env bash\nset -euo pipefail\n" + - "GITHUB_SHA=" + expectedSHA + "\n" + + "expected=" + expectedSHA + "\n" + "inspection=\"${RELEASE_TEST_INSPECTION}\"\n" + - "revision=mismatch\n" + validation + "printf '%s\\n' \"${revision}\"\n" if err := os.WriteFile(scriptPath, []byte(script), 0o700); err != nil { @@ -818,26 +891,32 @@ func TestReleaseWorkflowKeepsUntrustedImageRevisionInsideJQComparison(t *testing }) } + // 进入 shell 的只能是受信的 ${expected} 或字面量 mismatch; + // 实际读到的标签只允许直接写入 stderr,绝不赋给变量或送上 stdout。 + for _, assignment := range regexp.MustCompile(`\brevision=\S*`). + FindAllString(revisionVerifier, -1) { + switch assignment { + case "revision=mismatch", `revision="${expected}"`: + default: + t.Fatalf("revision verifier assigns an untrusted value: %s", assignment) + } + } + + content := readRepositoryFile(t, ".github/workflows/release.yml") inventoryStep := workflowStepBlock( t, workflowJobBlock(t, content, "publication-preflight"), "Inventory publication channels", ) functionStart := strings.Index(inventoryStep, "inventory_image() {") - functionEnd := strings.Index(inventoryStep, "\n IFS='|' read -r") + functionEnd := strings.Index(inventoryStep, "\n # 用命令替换而非进程替换") if functionStart < 0 || functionEnd <= functionStart { t.Fatalf("cannot isolate inventory_image:\n%s", inventoryStep) } inventoryFunction := inventoryStep[functionStart:functionEnd] for _, required := range []string{ - "jq -e", - `--arg expected "${GITHUB_SHA}"`, - `local revision=mismatch`, - `revision="${GITHUB_SHA}"`, - `linux/amd64`, - `linux/arm64`, - `org.opencontainers.image.revision`, - `. == $expected`, + ".github/scripts/release-verify-image-revision.sh", + "revision=mismatch", } { if !strings.Contains(inventoryFunction, required) { t.Fatalf("inventory_image does not contain %q:\n%s", required, inventoryFunction) @@ -845,11 +924,15 @@ func TestReleaseWorkflowKeepsUntrustedImageRevisionInsideJQComparison(t *testing } for _, forbidden := range []string{ "child_revision", - `revision="$(`, - `revision="${child_revision`, + "imagetools inspect", + "org.opencontainers.image.revision", } { if strings.Contains(inventoryFunction, forbidden) { - t.Fatalf("inventory_image exposes raw revision via %q:\n%s", forbidden, inventoryFunction) + t.Fatalf( + "inventory_image parses the untrusted label itself instead of delegating (%q):\n%s", + forbidden, + inventoryFunction, + ) } } @@ -871,20 +954,16 @@ func TestReleaseWorkflowKeepsUntrustedImageRevisionInsideJQComparison(t *testing workflowJobBlock(t, content, test.job), test.step, ) - for _, required := range []string{ - "jq -e", - `--arg expected "${GITHUB_SHA}"`, - `linux/amd64`, - `linux/arm64`, - `org.opencontainers.image.revision`, - `. == $expected`, - } { - if !strings.Contains(step, required) { - t.Fatalf("%s does not contain %q:\n%s", test.step, required, step) - } + if !strings.Contains(step, ".github/scripts/release-verify-image-revision.sh") { + t.Fatalf("%s does not delegate the revision comparison:\n%s", test.step, step) } - if strings.Contains(step, `revision="$(`) { - t.Fatalf("%s transfers a raw revision into shell:\n%s", test.step, step) + for _, forbidden := range []string{ + "org.opencontainers.image.revision", + `revision="$(`, + } { + if strings.Contains(step, forbidden) { + t.Fatalf("%s transfers a raw revision into shell (%q):\n%s", test.step, forbidden, step) + } } } } @@ -918,9 +997,7 @@ func TestReleaseWorkflowUpdatesAliasesOnlyAfterExactVerification(t *testing.T) { } exactStep := workflowStepBlock(t, imageJob, "Verify exact published images") for _, required := range []string{ - "linux/amd64", - "linux/arm64", - "org.opencontainers.image.revision", + ".github/scripts/release-verify-image-revision.sh", "GITHUB_SHA", "ghcr_exact_digest", "dockerhub_exact_digest", @@ -1082,7 +1159,7 @@ func TestReleaseWorkflowAlwaysReconcilesWriterAndPostPublishResults(t *testing.T "publication-preflight", "publish-images", "publish-github", - "post-publish-native-smoke", + "post-publish-image-smoke", "post-publish-verify", } { if !strings.Contains(job, dependency) { @@ -1093,7 +1170,7 @@ func TestReleaseWorkflowAlwaysReconcilesWriterAndPostPublishResults(t *testing.T "needs.publication-preflight.result", "needs.publish-images.result", "needs.publish-github.result", - "needs.post-publish-native-smoke.result", + "needs.post-publish-image-smoke.result", "needs.post-publish-verify.result", } { if !strings.Contains(job, result) { @@ -1583,26 +1660,31 @@ func TestReleaseWorkflowPostPublishVerifiesAliasesAndExactDigests(t *testing.T) } } -func TestReleaseWorkflowPostPublishDownloadsExactAssetsAndRunsFiveNativeSmokes(t *testing.T) { +func TestReleaseWorkflowPostPublishVerifiesDraftAssetsAgainstCurrentRun(t *testing.T) { content := readRepositoryFile(t, ".github/workflows/release.yml") inventoryJob := workflowJobBlock(t, content, "post-publish-verify") for _, required := range []string{ "gh release download", "draft-release", - "SHA256SUMS", - "sha256sum --check", - "asset_count", - `test "${asset_count}" = "12"`, + "name: release-assets", + ".github/scripts/release-verify-assets.sh", + "release/SHA256SUMS", } { if !strings.Contains(inventoryJob, required) { t.Fatalf("post-publish asset inventory does not contain %q:\n%s", required, inventoryJob) } } - nativeJob := workflowJobBlock(t, content, "post-publish-native-smoke") + // 已上传的原生产物不再重复跑第二遍五平台运行时 smoke:它们与发布前 + // native-artifact-smoke 验证过的字节完全相同,这一点由上面对照当前 run + // 可信 SHA256SUMS 的校验保证,重复运行不会产生新信息。 + if strings.Contains(content, "post-publish-native-smoke") { + t.Fatal("release workflow reintroduces the duplicated post-publication native smoke") + } + + // 发布前的五平台原生 smoke 仍然是必须的门禁。 + nativeJob := workflowJobBlock(t, content, "native-artifact-smoke") for _, required := range []string{ - "publish-github", - "publish-images", "ubuntu-24.04", "ubuntu-24.04-arm", "macos-15-intel", @@ -1613,12 +1695,11 @@ func TestReleaseWorkflowPostPublishDownloadsExactAssetsAndRunsFiveNativeSmokes(t "gpt-load-macos-amd64", "gpt-load-macos-arm64", "gpt-load-windows-amd64.exe", - "gh release download", ".github/scripts/release-native-smoke.sh", ".github/scripts/release-native-smoke.ps1", } { if !strings.Contains(nativeJob, required) { - t.Fatalf("post-publish native smoke does not contain %q:\n%s", required, nativeJob) + t.Fatalf("pre-publication native smoke does not contain %q:\n%s", required, nativeJob) } } } @@ -1658,7 +1739,6 @@ func TestReleaseWorkflowRunsBothPublishedImagesAndPreservesLatest(t *testing.T) for _, required := range []string{ "RELEASE_SMOKE_SOURCE_IMAGE", "ghcr.io/tbphp/gpt-load", - "tbphp/gpt-load", "needs.validate-tag.outputs.image_exact", ".github/scripts/release-docker-smoke.sh", } { @@ -1666,10 +1746,15 @@ func TestReleaseWorkflowRunsBothPublishedImagesAndPreservesLatest(t *testing.T) t.Fatalf("published image runtime smoke does not contain %q:\n%s", required, runtimeSmoke) } } + // Docker Hub 的 exact tag 与 GHCR 同 digest,只在 post-publish-verify 里以只读凭据 + // 校验 manifest 并拉取一次,确认 layer 可达。 + if !strings.Contains(postPublish, "Verify Docker Hub exact image is pullable") || + !strings.Contains(postPublish, "docker pull") { + t.Fatalf("post-publication verification never pulls the Docker Hub image:\n%s", postPublish) + } for name, block := range map[string]string{ "publication-preflight": snapshotJob, "post-publish-verify": postPublish, - "published-image-smoke": runtimeSmoke, } { for _, required := range []string{ "Log in to Docker Hub", @@ -1996,3 +2081,150 @@ func workflowMarkedScript(t *testing.T, content, marker string) string { } return strings.TrimSpace(strings.Join(lines, "\n")) + "\n" } + +func TestReleaseWorkflowReusesCIVerdictOnlyOnProvenSuccess(t *testing.T) { + content := readRepositoryFile(t, ".github/workflows/release.yml") + verdict := workflowMarkedScript(t, content, "release-ci-verdict-reuse") + scriptPath := filepath.Join(t.TempDir(), "reuse-ci-verdict.sh") + script := "#!/usr/bin/env bash\nset -euo pipefail\n" + + "GITHUB_REPOSITORY=owner/repo\n" + + "GITHUB_SHA=0123456789abcdef0123456789abcdef01234567\n" + + verdict + + "printf '%s\\n' \"${ci_verified}\"\n" + if err := os.WriteFile(scriptPath, []byte(script), 0o700); err != nil { + t.Fatalf("write ci verdict script: %v", err) + } + + binDir := t.TempDir() + fakeGH := "#!/usr/bin/env bash\n" + + "if [[ -n \"${RELEASE_TEST_GH_OUTPUT}\" ]]; then\n" + + " printf '%s\\n' \"${RELEASE_TEST_GH_OUTPUT}\"\n" + + "fi\n" + + "exit \"${RELEASE_TEST_GH_EXIT}\"\n" + if err := os.WriteFile(filepath.Join(binDir, "gh"), []byte(fakeGH), 0o700); err != nil { + t.Fatalf("write fake gh: %v", err) + } + + for _, test := range []struct { + name string + output string + exit string + want string + }{ + {name: "successful runs", output: "3", exit: "0", want: "true"}, + {name: "no successful run", output: "0", exit: "0", want: "false"}, + {name: "query failed", output: "", exit: "1", want: "false"}, + {name: "non-numeric", output: "many", exit: "0", want: "false"}, + {name: "injected count", output: "2\ninjected", exit: "0", want: "false"}, + {name: "negative", output: "-1", exit: "0", want: "false"}, + {name: "empty", output: "", exit: "0", want: "false"}, + } { + t.Run(test.name, func(t *testing.T) { + command := exec.Command("bash", scriptPath) + command.Env = []string{ + "PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"), + "RELEASE_TEST_GH_OUTPUT=" + test.output, + "RELEASE_TEST_GH_EXIT=" + test.exit, + } + output, err := command.Output() + if err != nil { + t.Fatalf("ci verdict evaluation failed: %v", err) + } + if got := strings.TrimSpace(string(output)); got != test.want { + t.Fatalf("ci_verified = %q, want %q", got, test.want) + } + }) + } +} + +func TestReleaseWorkflowSkipsOnlyCIProvenGatesAndStillFailsClosed(t *testing.T) { + content := readRepositoryFile(t, ".github/workflows/release.yml") + + // 只有同一 commit 上确定性重跑的 gate 才允许复用 CI 结论。 + reusable := []string{"race-tests", "race-cpa", "database-contract"} + for _, jobName := range reusable { + job := workflowJobBlock(t, content, jobName) + if !strings.Contains(job, "needs.validate-tag.outputs.ci_verified != 'true'") { + t.Fatalf("%s does not reuse the CI verdict:\n%s", jobName, job) + } + } + // 静态检查依赖会随时间变化的漏洞库,每次发布都必须重跑。 + staticChecks := workflowJobBlock(t, content, "static-checks") + if strings.Contains(staticChecks, "ci_verified") { + t.Fatalf("static checks must not reuse a stale vulnerability audit:\n%s", staticChecks) + } + + preflight := workflowJobBlock(t, content, "publication-preflight") + if !strings.Contains(preflight, "!contains(needs.*.result, 'failure')") || + !strings.Contains(preflight, "!cancelled()") { + t.Fatalf("publication preflight does not fail closed on gate failures:\n%s", preflight) + } + // preflight 的 if 只能看到直接 needs 的 result:上游失败会让中间 job 变成 + // skipped,因此每个 gate 都必须直接列出,漏掉任何一个都会让失败无法察觉。 + needsBlock := preflight[:strings.Index(preflight, "runs-on:")] + for _, gate := range []string{ + "validate-tag", + "verify-and-build-web", + "static-checks", + "race-tests", + "race-cpa", + "build-binaries", + "package-checksums", + "native-artifact-smoke", + "docker-smoke", + "database-contract", + } { + if !strings.Contains(needsBlock, "- "+gate) { + t.Fatalf("publication preflight does not directly need gate %q:\n%s", gate, needsBlock) + } + } +} + +func TestReleaseAssetManifestIsTheSingleSourceOfTruth(t *testing.T) { + manifest := readRepositoryFile(t, ".github/release-assets.txt") + var assets []string + for _, line := range strings.Split(manifest, "\n") { + if name := strings.TrimSpace(line); name != "" { + assets = append(assets, name) + } + } + if len(assets) != 12 { + t.Fatalf("release asset manifest lists %d assets, want 12", len(assets)) + } + sorted := append([]string(nil), assets...) + sort.Strings(sorted) + for index := range assets { + if assets[index] != sorted[index] { + t.Fatalf("release asset manifest is not sorted: %v", assets) + } + } + + content := readRepositoryFile(t, ".github/workflows/release.yml") + // 许可证与 SBOM 资产名只允许出现在生成它们的 package-metadata 里, + // 清单与校验和一律从 .github/release-assets.txt 派生。 + for _, name := range []string{ + "Apache-2.0.txt", + "MIT.txt", + "MPL-2.0.txt", + "THIRD_PARTY_NOTICES.md", + "bom.cdx.json", + } { + metadataJob := workflowJobBlock(t, content, "package-metadata") + occurrences := strings.Count(content, name) + inMetadata := strings.Count(metadataJob, name) + if occurrences != inMetadata { + t.Fatalf( + "asset %q is hardcoded outside package-metadata (%d total, %d in metadata)", + name, + occurrences, + inMetadata, + ) + } + } + // 资产数量与校验和行数不得再作为魔数散落在 workflow 中。 + for _, magic := range []string{`= "11"`, `= "12"`, "asset_count"} { + if strings.Contains(content, magic) { + t.Fatalf("release workflow still hardcodes the asset count via %q", magic) + } + } +}