From 9afe061d79fe26085ee20a5c0d0c4506638dc01a Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sun, 20 Sep 2026 09:35:23 +0800 Subject: [PATCH] =?UTF-8?q?ci(deploy):=20=E9=95=9C=E5=83=8F=E5=8F=91?= =?UTF-8?q?=E5=B8=83=20-=20=E5=8E=9F=E7=94=9F=E5=A4=9A=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E5=B9=B6=E5=A4=8D=E7=94=A8=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E4=B8=8E=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/promote-image.sh | 30 ++++ .github/scripts/resolve-image.sh | 18 ++ .github/workflows/publish-images.yml | 186 ++++++++++++++------ .github/workflows/quality.yml | 36 ++-- Dockerfile | 10 +- backend/Dockerfile | 18 +- docs/content/docs/backend/system-update.mdx | 17 ++ docs/content/docs/progress/pending-test.mdx | 8 + web/test/ci-image-release.test.mjs | 109 ++++++++++++ 9 files changed, 352 insertions(+), 80 deletions(-) create mode 100644 .github/scripts/promote-image.sh create mode 100644 .github/scripts/resolve-image.sh create mode 100644 web/test/ci-image-release.test.mjs diff --git a/.github/scripts/promote-image.sh b/.github/scripts/promote-image.sh new file mode 100644 index 000000000..c5efbb9ff --- /dev/null +++ b/.github/scripts/promote-image.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${IMAGE:?}" "${GITHUB_SHA:?}" "${GITHUB_REF:?}" "${DIGEST_DIR:?}" "${EXPECTED_DIGESTS:?}" +[[ "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid commit SHA" >&2; exit 1; } +shopt -s nullglob +files=("$DIGEST_DIR"/*) +[[ ${#files[@]} -eq "$EXPECTED_DIGESTS" ]] || { echo "Incomplete image digest set" >&2; exit 1; } +sources=() +for file in "${files[@]}"; do + digest=${file##*/} + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || { echo "Invalid image digest" >&2; exit 1; } + sources+=("$IMAGE@sha256:$digest") +done + +tags=(--tag "$IMAGE:sha-$GITHUB_SHA" --tag "$IMAGE:sha-${GITHUB_SHA:0:7}") +if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + [[ "${VERSION_TAG:?}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || { echo "Invalid version image tag" >&2; exit 1; } + tags+=(--tag "$IMAGE:$VERSION_TAG") +elif [[ "$GITHUB_REF" == refs/heads/main ]]; then + current_sha=$(gh api "repos/${GITHUB_REPOSITORY:?}/git/ref/heads/main" --jq '.object.sha') + [[ "$current_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid main branch SHA" >&2; exit 1; } + if [[ "$current_sha" == "$GITHUB_SHA" ]]; then + tags+=(--tag "$IMAGE:latest") + else + echo 'A newer main commit exists; leaving latest unchanged.' + fi +fi + +docker buildx imagetools create "${tags[@]}" "${sources[@]}" diff --git a/.github/scripts/resolve-image.sh b/.github/scripts/resolve-image.sh new file mode 100644 index 000000000..c24b131f3 --- /dev/null +++ b/.github/scripts/resolve-image.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +image_ref=${1:?image reference is required} +error_file=$(mktemp) +trap 'rm -f "$error_file"' EXIT + +if digest=$(docker buildx imagetools inspect "$image_ref" --format '{{.Manifest.Digest}}' 2>"$error_file"); then + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "Invalid existing image digest" >&2; exit 1; } + printf 'found=true\ndigest=%s\n' "$digest" >> "${GITHUB_OUTPUT:?}" +elif grep -Fxq "ERROR: $image_ref: not found" "$error_file" \ + || grep -Eqi '^ERROR:.*: (manifest unknown|name unknown)(:.*)?$' "$error_file"; then + echo 'found=false' >> "${GITHUB_OUTPUT:?}" +else + # 鉴权、网络、限流错误不是缓存未命中,必须阻止发布。 + cat "$error_file" >&2 + exit 1 +fi diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index f33f42ec9..b9bb79710 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -9,28 +9,32 @@ on: workflow_dispatch: permissions: - contents: write - packages: write + contents: read + +# main 和 tag 的同一提交串行进入;后到的一轮复用已校验的 SHA 镜像。 +concurrency: + group: publish-${{ github.sha }} + cancel-in-progress: false jobs: - publish: - name: Publish ${{ matrix.image }} + checks: + name: Quality checks + uses: ./.github/workflows/quality.yml + + prepare: + name: Resolve build version runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - image: open-ai-canvas-web - dockerfile: Dockerfile - - image: open-ai-canvas-backend - dockerfile: backend/Dockerfile - + outputs: + version: ${{ steps.info.outputs.version }} + image_tag: ${{ steps.info.outputs.image_tag }} + build_time: ${{ steps.info.outputs.build_time }} + owner: ${{ steps.info.outputs.owner }} steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Resolve and validate build version - id: build-info + - uses: actions/checkout@v4 + - id: info + name: Validate release metadata + env: + REPOSITORY_OWNER: ${{ github.repository_owner }} shell: bash run: | version="$(tr -d '\r\n' < VERSION)" @@ -40,68 +44,140 @@ jobs: fi echo "version=$version" >> "$GITHUB_OUTPUT" echo "image_tag=${version#v}" >> "$GITHUB_OUTPUT" - if [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then - echo "is_semver=true" >> "$GITHUB_OUTPUT" - else - echo "is_semver=false" >> "$GITHUB_OUTPUT" - fi - echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + # 相同提交的 main/tag/重跑使用相同时间,避免人为破坏缓存。 + echo "build_time=$(git show -s --format=%cI HEAD)" >> "$GITHUB_OUTPUT" + echo "owner=${REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT" - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + build: + name: Build ${{ matrix.image }} (${{ matrix.arch }}) + needs: [checks, prepare] + permissions: + contents: read + packages: write + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + # Web 的构建阶段固定 BUILDPLATFORM,静态文件只编译一次。 + - image: open-ai-canvas-web + dockerfile: Dockerfile + arch: multi + platforms: linux/amd64,linux/arm64 + runner: ubuntu-24.04 + - image: open-ai-canvas-backend + dockerfile: backend/Dockerfile + arch: amd64 + platforms: linux/amd64 + runner: ubuntu-24.04 + - image: open-ai-canvas-backend + dockerfile: backend/Dockerfile + arch: arm64 + platforms: linux/arm64 + runner: ubuntu-24.04-arm + env: + IMAGE: ghcr.io/${{ needs.prepare.outputs.owner }}/${{ matrix.image }} + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - - name: Generate image metadata - id: metadata - uses: docker/metadata-action@v5 - with: - images: ghcr.io/${{ github.repository_owner }}/${{ matrix.image }} - tags: | - type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} - type=raw,value=${{ steps.build-info.outputs.image_tag }},enable=${{ startsWith(github.ref, 'refs/tags/v') }} - type=semver,pattern={{version}},enable=${{ steps.build-info.outputs.is_semver == 'true' }} - type=sha,prefix=sha- - - - name: Build and push image + - name: Reuse an existing commit image + id: existing + env: + IMAGE_REF: ${{ env.IMAGE }}:sha-${{ github.sha }}-${{ matrix.arch }} + run: bash .github/scripts/resolve-image.sh "$IMAGE_REF" + - name: Build and push immutable commit image + id: build + if: steps.existing.outputs.found != 'true' uses: docker/build-push-action@v6 with: context: . file: ${{ matrix.dockerfile }} - platforms: linux/amd64,linux/arm64 + platforms: ${{ matrix.platforms }} push: true build-args: | - BUILD_VERSION=${{ steps.build-info.outputs.version }} + BUILD_VERSION=${{ needs.prepare.outputs.version }} BUILD_COMMIT=${{ github.sha }} - BUILD_TIME=${{ steps.build-info.outputs.build_time }} - tags: ${{ steps.metadata.outputs.tags }} - labels: ${{ steps.metadata.outputs.labels }} - cache-from: type=gha,scope=${{ matrix.image }} - cache-to: type=gha,mode=max,scope=${{ matrix.image }} + BUILD_TIME=${{ needs.prepare.outputs.build_time }} + tags: ${{ env.IMAGE }}:sha-${{ github.sha }}-${{ matrix.arch }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ needs.prepare.outputs.version }} + cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.arch }} + - name: Record image digest + env: + DIGEST: ${{ steps.existing.outputs.digest || steps.build.outputs.digest }} + shell: bash + run: | + [[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "Invalid image digest" >&2; exit 1; } + mkdir -p "$RUNNER_TEMP/digests" + touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + - uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.image }}-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + overwrite: true + retention-days: 7 + + publish: + name: Publish ${{ matrix.image }} manifest + needs: [prepare, build] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + image: [open-ai-canvas-web, open-ai-canvas-backend] + # 不同提交可并行编译,但同一镜像的可变标签更新必须串行。 + concurrency: + group: image-tags-${{ matrix.image }}-${{ github.ref }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: digests-${{ matrix.image }}-* + path: ${{ runner.temp }}/digests + merge-multiple: true + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Promote verified digests + env: + GH_TOKEN: ${{ github.token }} + IMAGE: ghcr.io/${{ needs.prepare.outputs.owner }}/${{ matrix.image }} + VERSION_TAG: ${{ needs.prepare.outputs.image_tag }} + DIGEST_DIR: ${{ runner.temp }}/digests + EXPECTED_DIGESTS: ${{ matrix.image == 'open-ai-canvas-backend' && '2' || '1' }} + run: bash .github/scripts/promote-image.sh release: name: Publish release assets if: startsWith(github.ref, 'refs/tags/v') needs: publish runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Go uses: actions/setup-go@v5 with: go-version-file: backend/go.mod cache-dependency-path: backend/go.sum - - name: Build Host Updater working-directory: backend shell: bash @@ -112,7 +188,6 @@ jobs: done cd ../dist sha256sum open-ai-canvas-host-updater-linux-* > SHA256SUMS - - name: Prepare release notes shell: bash run: | @@ -123,7 +198,6 @@ jobs: found { print } ' CHANGELOG.md > dist/RELEASE_NOTES.md test -s dist/RELEASE_NOTES.md || printf '影策 %s\n' "$version" > dist/RELEASE_NOTES.md - - name: Create GitHub Release env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 7ab092784..e63829da1 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -2,9 +2,7 @@ name: Quality checks on: pull_request: - push: - branches: - - main + workflow_call: permissions: contents: read @@ -21,11 +19,22 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum cache: true - name: Check formatting run: test -z "$(gofmt -l .)" - name: Run Go tests - run: go test ./... + shell: bash + run: | + set -o pipefail + go test -json ./... | tee "$RUNNER_TEMP/go-tests.jsonl" + - name: Upload Go test timings + if: always() + uses: actions/upload-artifact@v4 + with: + name: go-test-timings + path: ${{ runner.temp }}/go-tests.jsonl + if-no-files-found: ignore payment-plugins: name: Payment plugin artifacts @@ -36,15 +45,22 @@ jobs: env: PAYMENT_PLUGIN_SMOKE_TEST: "1" run: sh plugin-packages/verify-payment-packages.sh --all linux amd64 + # 发布时 backend/Dockerfile 会在两种架构的原生 Runner 上执行 smoke。 + # PR 仍独立验证双架构,不能因没有发布任务而遗漏 ARM64 验收。 - uses: docker/setup-qemu-action@v3 + if: github.event_name == 'pull_request' - uses: docker/setup-buildx-action@v3 + if: github.event_name == 'pull_request' - name: Build and execute payment packages for every image platform - run: >- - docker buildx build - --platform linux/amd64,linux/arm64 - --target payment-package-smoke - --file backend/Dockerfile - . + if: github.event_name == 'pull_request' + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + target: payment-package-smoke + file: backend/Dockerfile + cache-from: type=gha,scope=payment-smoke + cache-to: type=gha,mode=max,scope=payment-smoke web: name: Web checks diff --git a/Dockerfile b/Dockerfile index fac35e7ea..62c5250fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,9 @@ # syntax=docker/dockerfile:1.7 # 构建 Vite 前端产物。 -FROM oven/bun:1.3.13 AS web-build +FROM --platform=$BUILDPLATFORM oven/bun:1.3.9 AS web-build WORKDIR /app/web -ARG VITE_TLDRAW_LICENSE_KEY -ARG BUILD_VERSION -ENV VITE_TLDRAW_LICENSE_KEY=${VITE_TLDRAW_LICENSE_KEY} -ENV CANVAS_BUILD_VERSION=${BUILD_VERSION} COPY web/package.json web/bun.lock ./ RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile --cache-dir=/root/.bun/install/cache COPY VERSION /app/VERSION @@ -15,6 +11,10 @@ COPY CHANGELOG.md /app/CHANGELOG.md COPY README.md /app/README.md COPY assets /app/assets COPY web ./ +ARG VITE_TLDRAW_LICENSE_KEY +ARG BUILD_VERSION +ENV VITE_TLDRAW_LICENSE_KEY=${VITE_TLDRAW_LICENSE_KEY} +ENV CANVAS_BUILD_VERSION=${BUILD_VERSION} # 生产镜像只构建云端工作台前端;Agent Runtime 在后端 Worker 中运行。 RUN bun --bun ./node_modules/vite/bin/vite.js build diff --git a/backend/Dockerfile b/backend/Dockerfile index 61a608441..b98a7ee32 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,18 +2,17 @@ FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS payment-package-build -ARG TARGETOS -ARG TARGETARCH ARG GOPROXY=https://proxy.golang.org,direct ENV GOPROXY=$GOPROXY RUN apk add --no-cache nodejs unzip zip WORKDIR /src -COPY backend/go.mod backend/go.sum ./backend/ -RUN --mount=type=cache,target=/go/pkg/mod cd backend && go mod download +# 插件只下载实际依赖,不为四个小程序预取整个业务后端的模块图。 COPY backend ./backend COPY plugin-packages ./plugin-packages +ARG TARGETOS +ARG TARGETARCH RUN --mount=type=cache,target=/root/.cache/go-build set -eu; \ test -n "$TARGETOS"; \ test -n "$TARGETARCH"; \ @@ -35,18 +34,19 @@ RUN PAYMENT_PLUGIN_SMOKE_TEST=1 \ FROM golang:1.25-alpine AS backend-build ARG GOPROXY=https://proxy.golang.org,direct -ARG BUILD_VERSION -ARG BUILD_COMMIT=unknown -ARG BUILD_TIME=unknown ENV GOPROXY=$GOPROXY RUN apk add --no-cache build-base WORKDIR /src/backend -COPY VERSION /src/VERSION COPY backend/go.mod backend/go.sum ./ -RUN --mount=type=cache,target=/go/pkg/mod go mod download +# 模块保留在普通镜像层中,供后续编译及跨 Runner 的 GHA 层缓存复用。 +RUN go mod download COPY backend ./ +COPY VERSION /src/VERSION +ARG BUILD_VERSION +ARG BUILD_COMMIT=unknown +ARG BUILD_TIME=unknown RUN --mount=type=cache,target=/root/.cache/go-build set -eu; \ version="${BUILD_VERSION:-$(tr -d '\r\n' -amd64/arm64` 后端镜像及 `sha-<完整提交>-multi` 前端镜像,以摘要 artifact 传递结果。两种后端架构和前端全部成功后,才从摘要合并发布 manifest,不用可变标签作为合并源。 +5. 同一 SHA 的 main/tag 发布串行运行;后到的一轮仍经过质量门禁,然后复用已有 SHA 镜像,不重复编译。只有明确的镜像不存在可以转入构建,鉴权、限流、网络和异常摘要必须报错。 +6. 发布保留完整 SHA 和短 SHA 标签;只有版本 tag 推送写入具体版本标签。`main` 只有在仍是远端最新提交时才更新 `latest`,旧提交的慢任务不能覆盖新版本。正式部署仍应固定版本标签,不依赖 `latest`。 + +Go 检查显式使用 `backend/go.sum` 作为缓存依赖键,并上传 `go-test-timings`(JSONL),可按 `Test` / `Elapsed` 定位慢测试。Docker 的后端模块下载保留在普通镜像层,编译步骤可以直接读取;支付插件只按实际 import 下载依赖,不重复预取完整后端模块图。版本参数下移到编译入口。GHA 构建缓存按镜像和架构隔离,避免并行任务共用同一 scope。`go-build` cache mount 只用于同一个 builder 内的复用,当前没有额外的跨 Runner mount 导出方案;不能把它当作持久编译缓存。冷缓存首轮和缓存上传仍可能较慢,需要比较连续两轮的实际耗时。 + +这套发布流程依赖仓库可使用 GitHub 原生 ARM64 Runner、GHCR 写权限以及构建/下载 artifact 能力。首次上线应核对实际 Runner 排队情况、双架构 manifest、main/tag 同 SHA 复用和质量失败阻断;本地测试不能代替这些云端验收。工作流变更不迁移业务数据库,也不修改服务器 Compose 或数据卷。需要回退 CI 时只回退工作流和 Dockerfile 改动;已发布镜像不会自动撤回,线上版本回退仍按本文的备份与迁移约束执行。 + ## 运维检查 ```bash diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 718a6c1f2..02aef2084 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -74,6 +74,14 @@ PR #543 本次未合入:其已发布迁移校验和改写、管理员显式图 - [x] 修复导演台 DEV 复现入口在 React 初始化前请求 `/api/public/appearance` 导致的 E2E 502:入口和 Provider 共用精确路径隔离,普通页面与生产环境仍执行外观初始化,未放宽浏览器错误断言。 - [x] 外观启动与导演台专项测试 54 项通过,包含执行真实 `main.tsx` 分支的 9 项新增回归;生产构建(含 TypeScript 检查)通过,仍有 chunk 大小告警。 - [ ] 在 GitHub 重跑 Director P0 Chrome E2E,确认六个场景不再出现外观接口 502;本轮未启动本地开发服务器或执行 Chrome E2E,不以入口回归测试替代浏览器验收。 +- [x] 修复画布连线可见性文件的 Prettier 格式;增量格式检查改用 Prettier API,移除会触发 EPIPE 的管道比较,覆盖新文件、历史格式、无父提交、全零基线和错误传播。 +- [x] 本地前端类型检查、Lint、1844 项测试通过,其中包含 20 项 CI 格式检查与镜像复用/晋级回归;两个 Dockerfile 的 BuildKit 检查无警告,Actions 工作流通过 actionlint。 +- [x] 使用原失败运行的提交区间重跑实际增量格式脚本,检查通过且未再出现 EPIPE;原生 ARM64 前端 Docker 镜像构建通过。 +- [ ] 后端本地 Docker 完整构建在 Go 依赖下载阶段被 `proxy.golang.org` 的 EOF 阻断,两次同类失败后停止原路径重试;尚不能据此确认后端镜像完整构建通过,未改用不可信依赖或吞掉错误。 +- [ ] 在 GitHub 验证质量检查失败时不构建、不更新镜像标签;确认必要检查规则匹配可复用工作流的实际名称。 +- [ ] 验证 AMD64/ARM64 原生后端构建及支付插件 smoke、前端静态资源单次编译;检查最终镜像的两个架构和实际容器运行。 +- [ ] 连续推送同一 SHA 的 main/tag,确认复用已构建摘要;模拟旧 main 晚完成、镜像不存在、GHCR 鉴权/网络失败,确认不会错误降级或回退 `latest`。 +- [ ] 比较冷/热缓存耗时,使用 `go-test-timings` 分析 `internal/app` 的慢测试。本次未删除测试、未调用真实收费模型、未推送镜像或发布线上版本;不能宣称云端耗时已经降到固定分钟数。 ## 画布素材链路与工作条改造(2026-09-19) diff --git a/web/test/ci-image-release.test.mjs b/web/test/ci-image-release.test.mjs new file mode 100644 index 000000000..9cd4a5542 --- /dev/null +++ b/web/test/ci-image-release.test.mjs @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scripts = fileURLToPath(new URL("../../.github/scripts/", import.meta.url)); +const temporaryDirectories = []; +const sha = "a".repeat(40); +const digest = "b".repeat(64); +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +function fixture(overrides = {}) { + const root = mkdtempSync(path.join(os.tmpdir(), "canvas-ci-image-")); + temporaryDirectories.push(root); + const bin = path.join(root, "bin"); + const digests = path.join(root, "digests"); + mkdirSync(bin); + mkdirSync(digests); + writeFileSync(path.join(digests, digest), ""); + writeFileSync(path.join(bin, "docker"), '#!/bin/sh\nif [ "$3" = inspect ]; then\n if [ -n "$MOCK_ERROR" ]; then printf "%s\\n" "$MOCK_ERROR" >&2; exit 1; fi\n printf "%s\\n" "$MOCK_DIGEST"\nelse\n printf "%s\\n" "$@" > "$MOCK_CALL"\nfi\n', { + mode: 0o755, + }); + writeFileSync(path.join(bin, "gh"), '#!/bin/sh\nif [ -n "$MOCK_GH_ERROR" ]; then exit 1; fi\nprintf "%s\\n" "$MOCK_MAIN"\n', { mode: 0o755 }); + const output = path.join(root, "output"); + writeFileSync(output, ""); + const env = { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + GITHUB_OUTPUT: output, + GITHUB_SHA: sha, + GITHUB_REF: "refs/heads/main", + GITHUB_REPOSITORY: "fixture/repo", + IMAGE: "ghcr.io/fixture/image", + VERSION_TAG: "1.5.5", + EXPECTED_DIGESTS: "1", + DIGEST_DIR: digests, + MOCK_DIGEST: `sha256:${digest}`, + MOCK_ERROR: "", + MOCK_GH_ERROR: "", + MOCK_MAIN: sha, + MOCK_CALL: path.join(root, "docker-call"), + ...overrides, + }; + return { + run: (script, ...args) => spawnSync("bash", [path.join(scripts, script), ...args], { env, encoding: "utf8" }), + output: () => readFileSync(output, "utf8"), + call: () => (existsSync(env.MOCK_CALL) ? readFileSync(env.MOCK_CALL, "utf8") : ""), + }; +} + +describe("commit image reuse", () => { + test("reuses the exact existing digest", () => { + const repo = fixture(); + expect(repo.run("resolve-image.sh", "ghcr.io/fixture/image:sha-test").status).toBe(0); + expect(repo.output()).toBe(`found=true\ndigest=sha256:${digest}\n`); + }); + test("only a missing manifest permits a rebuild", () => { + const repo = fixture({ MOCK_ERROR: "ERROR: ghcr.io/fixture/image:sha-test: not found" }); + expect(repo.run("resolve-image.sh", "ghcr.io/fixture/image:sha-test").status).toBe(0); + expect(repo.output()).toBe("found=false\n"); + }); + for (const error of ["ERROR: unauthorized", "ERROR: 429 Too Many Requests", "ERROR: dial tcp: i/o timeout", "ERROR: certificate file: not found: authentication failed"]) { + test(`fails closed on ${error}`, () => { + const repo = fixture({ MOCK_ERROR: error }); + expect(repo.run("resolve-image.sh", "ghcr.io/fixture/image:sha-test").status).not.toBe(0); + expect(repo.output()).toBe(""); + }); + } + test("rejects malformed successful responses", () => { + const repo = fixture({ MOCK_DIGEST: "error" }); + expect(repo.run("resolve-image.sh", "ghcr.io/fixture/image:sha-test").status).not.toBe(0); + }); +}); + +describe("digest promotion", () => { + test("current main promotes latest and SHA tags using digests", () => { + const repo = fixture(); + expect(repo.run("promote-image.sh").status).toBe(0); + expect(repo.call()).toContain("ghcr.io/fixture/image:latest"); + expect(repo.call()).toContain(`ghcr.io/fixture/image@sha256:${digest}`); + expect(repo.call()).toContain(`ghcr.io/fixture/image:sha-${sha}`); + expect(repo.call()).not.toContain("ghcr.io/fixture/image:1.5.5"); + }); + test("stale main cannot roll latest back", () => { + const repo = fixture({ MOCK_MAIN: "c".repeat(40) }); + expect(repo.run("promote-image.sh").status).toBe(0); + expect(repo.call()).not.toContain(":latest"); + }); + test("tag release promotes the exact version without touching latest", () => { + const repo = fixture({ GITHUB_REF: "refs/tags/v1.5.5" }); + expect(repo.run("promote-image.sh").status).toBe(0); + expect(repo.call()).toContain("ghcr.io/fixture/image:1.5.5"); + expect(repo.call()).not.toContain(":latest"); + }); + test("missing architecture blocks every tag update", () => { + const repo = fixture({ EXPECTED_DIGESTS: "2" }); + expect(repo.run("promote-image.sh").status).not.toBe(0); + expect(repo.call()).toBe(""); + }); + test("failure to resolve main blocks promotion", () => { + const repo = fixture({ MOCK_GH_ERROR: "1" }); + expect(repo.run("promote-image.sh").status).not.toBe(0); + expect(repo.call()).toBe(""); + }); +});