From 95e0f3eb97875638398d322c1e6cc97dbdb85b31 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 25 Aug 2026 19:23:14 +0800 Subject: [PATCH] fix(release): mirror TCR images asynchronously --- .github/workflows/docker-image.yml | 86 ++------------ .github/workflows/docker-tcr-mirror.yml | 144 ++++++++++++++++++++++++ AGENTS.md | 2 +- docs/release.md | 19 ++-- docs/release.zh-CN.md | 15 ++- scripts/docker-release.test.mjs | 38 ++++--- 6 files changed, 198 insertions(+), 106 deletions(-) create mode 100644 .github/workflows/docker-tcr-mirror.yml diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index c23794cf..c322cc83 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -54,7 +54,6 @@ permissions: env: GHCR_IMAGE_NAME: ghcr.io/tianma-if/edgeever - TCR_IMAGE_NAME: ccr.ccs.tencentyun.com/edgeever/edgeever jobs: container-test: @@ -144,11 +143,6 @@ jobs: echo "${GHCR_IMAGE_NAME}:${version}" echo "${GHCR_IMAGE_NAME}:latest" echo "EOF" - echo "mirror_tags<> "${GITHUB_OUTPUT}" @@ -159,10 +153,6 @@ jobs: echo "${GHCR_IMAGE_NAME}:main" echo "${GHCR_IMAGE_NAME}:sha-${short_sha}" echo "EOF" - echo "mirror_tags<> "${GITHUB_OUTPUT}" @@ -181,18 +171,6 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Sign in to Tencent Cloud Container Registry - uses: docker/login-action@v3 - with: - registry: ccr.ccs.tencentyun.com - username: ${{ secrets.TENCENT_TCR_USERNAME }} - password: ${{ secrets.TENCENT_TCR_PASSWORD }} - - - name: Install registry copy tool - run: | - sudo apt-get update - sudo apt-get install --yes skopeo - - name: Build and publish image uses: docker/build-push-action@v6 with: @@ -207,44 +185,12 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - - name: Mirror image to Tencent Cloud Container Registry - env: - MIRROR_TAGS: ${{ steps.image.outputs.mirror_tags }} - run: | - while IFS= read -r image_tag; do - [[ -n "${image_tag}" ]] || continue - for attempt in 1 2 3; do - if skopeo copy \ - --all \ - --preserve-digests \ - --authfile "${HOME}/.docker/config.json" \ - "docker://${GHCR_IMAGE_NAME}:${image_tag}" \ - "docker://${TCR_IMAGE_NAME}:${image_tag}"; then - break - fi - if [[ "${attempt}" -eq 3 ]]; then - echo "Failed to mirror ${image_tag} after ${attempt} attempts" >&2 - exit 1 - fi - echo "Mirror attempt ${attempt} failed for ${image_tag}; retrying" >&2 - sleep "$((attempt * 15))" - done - done <<< "${MIRROR_TAGS}" - - - name: Verify mirrored anonymous image access + - name: Verify anonymous GHCR image access run: | docker logout ghcr.io - docker logout ccr.ccs.tencentyun.com - ghcr_inspect="$(docker buildx imagetools inspect "${GHCR_IMAGE_NAME}:${{ steps.image.outputs.audit_tag }}")" - tcr_inspect="$(docker buildx imagetools inspect "${TCR_IMAGE_NAME}:${{ steps.image.outputs.audit_tag }}")" - ghcr_digest="$(sed -n 's/^Digest:[[:space:]]*//p' <<<"${ghcr_inspect}" | head -n 1)" - tcr_digest="$(sed -n 's/^Digest:[[:space:]]*//p' <<<"${tcr_inspect}" | head -n 1)" - test -n "${ghcr_digest}" - test "${ghcr_digest}" = "${tcr_digest}" - for image in "${GHCR_IMAGE_NAME}" "${TCR_IMAGE_NAME}"; do - docker buildx imagetools inspect --raw "${image}:${{ steps.image.outputs.audit_tag }}" | - jq -e '([.manifests[].platform.architecture] | index("amd64") != null) and ([.manifests[].platform.architecture] | index("arm64") != null)' - done + docker buildx imagetools inspect "${GHCR_IMAGE_NAME}:${{ steps.image.outputs.audit_tag }}" + docker buildx imagetools inspect --raw "${GHCR_IMAGE_NAME}:${{ steps.image.outputs.audit_tag }}" | + jq -e '([.manifests[].platform.architecture] | index("amd64") != null) and ([.manifests[].platform.architecture] | index("arm64") != null)' release-audit: if: github.repository == 'tianma-if/edgeever' && github.event_name == 'release' @@ -256,19 +202,11 @@ jobs: env: RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - expected_digest="" - for image in "${GHCR_IMAGE_NAME}" "${TCR_IMAGE_NAME}"; do - release_inspect="$(docker buildx imagetools inspect "${image}:${RELEASE_TAG}")" - latest_inspect="$(docker buildx imagetools inspect "${image}:latest")" - release_digest="$(sed -n 's/^Digest:[[:space:]]*//p' <<<"${release_inspect}" | head -n 1)" - latest_digest="$(sed -n 's/^Digest:[[:space:]]*//p' <<<"${latest_inspect}" | head -n 1)" - test -n "${release_digest}" - test "${release_digest}" = "${latest_digest}" - if [[ -z "${expected_digest}" ]]; then - expected_digest="${release_digest}" - else - test "${release_digest}" = "${expected_digest}" - fi - docker buildx imagetools inspect --raw "${image}:${RELEASE_TAG}" | - jq -e '([.manifests[].platform.architecture] | index("amd64") != null) and ([.manifests[].platform.architecture] | index("arm64") != null)' - done + release_inspect="$(docker buildx imagetools inspect "${GHCR_IMAGE_NAME}:${RELEASE_TAG}")" + latest_inspect="$(docker buildx imagetools inspect "${GHCR_IMAGE_NAME}:latest")" + release_digest="$(sed -n 's/^Digest:[[:space:]]*//p' <<<"${release_inspect}" | head -n 1)" + latest_digest="$(sed -n 's/^Digest:[[:space:]]*//p' <<<"${latest_inspect}" | head -n 1)" + test -n "${release_digest}" + test "${release_digest}" = "${latest_digest}" + docker buildx imagetools inspect --raw "${GHCR_IMAGE_NAME}:${RELEASE_TAG}" | + jq -e '([.manifests[].platform.architecture] | index("amd64") != null) and ([.manifests[].platform.architecture] | index("arm64") != null)' diff --git a/.github/workflows/docker-tcr-mirror.yml b/.github/workflows/docker-tcr-mirror.yml new file mode 100644 index 00000000..bebf1c4f --- /dev/null +++ b/.github/workflows/docker-tcr-mirror.yml @@ -0,0 +1,144 @@ +name: Mirror Docker image to Tencent TCR + +on: + workflow_run: + workflows: + - Build and publish Docker image + types: + - completed + release: + types: + - published + workflow_dispatch: + inputs: + source_tag: + description: "Immutable GHCR tag to mirror" + required: true + type: string + destination_tags: + description: "Comma-separated TCR tags" + required: true + type: string + +permissions: + contents: read + packages: read + +env: + GHCR_IMAGE_NAME: ghcr.io/tianma-if/edgeever + TCR_IMAGE_NAME: ccr.ccs.tencentyun.com/edgeever/edgeever + +jobs: + mirror: + if: >- + github.repository == 'tianma-if/edgeever' && + ( + github.event_name == 'release' || + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' + ) + ) + name: Mirror public image without blocking releases + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Resolve immutable source and destination tags + id: image + env: + EVENT_NAME: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + SOURCE_SHA: ${{ github.event.workflow_run.head_sha }} + INPUT_SOURCE_TAG: ${{ inputs.source_tag }} + INPUT_DESTINATION_TAGS: ${{ inputs.destination_tags }} + run: | + if [[ "${EVENT_NAME}" == "release" ]]; then + source_tag="${RELEASE_TAG}" + destination_tags="${RELEASE_TAG},${RELEASE_TAG#v},latest" + elif [[ "${EVENT_NAME}" == "workflow_run" ]]; then + source_tag="sha-${SOURCE_SHA:0:12}" + destination_tags="main,${source_tag}" + else + source_tag="${INPUT_SOURCE_TAG}" + destination_tags="${INPUT_DESTINATION_TAGS}" + fi + if [[ ! "${source_tag}" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Invalid source tag" >&2 + exit 1 + fi + IFS=',' read -ra tags <<< "${destination_tags}" + for image_tag in "${tags[@]}"; do + if [[ ! "${image_tag}" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Invalid destination tag: ${image_tag}" >&2 + exit 1 + fi + done + { + echo "source_tag=${source_tag}" + echo "destination_tags=${destination_tags}" + } >> "${GITHUB_OUTPUT}" + + - name: Sign in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Sign in to Tencent Cloud Container Registry + uses: docker/login-action@v3 + with: + registry: ccr.ccs.tencentyun.com + username: ${{ secrets.TENCENT_TCR_USERNAME }} + password: ${{ secrets.TENCENT_TCR_PASSWORD }} + + - name: Install registry copy tool + run: | + sudo apt-get update + sudo apt-get install --yes skopeo + + - name: Mirror image tags + env: + SOURCE_TAG: ${{ steps.image.outputs.source_tag }} + DESTINATION_TAGS: ${{ steps.image.outputs.destination_tags }} + run: | + IFS=',' read -ra tags <<< "${DESTINATION_TAGS}" + for image_tag in "${tags[@]}"; do + for attempt in 1 2 3; do + if skopeo copy \ + --all \ + --preserve-digests \ + --authfile "${HOME}/.docker/config.json" \ + "docker://${GHCR_IMAGE_NAME}:${SOURCE_TAG}" \ + "docker://${TCR_IMAGE_NAME}:${image_tag}"; then + break + fi + if [[ "${attempt}" -eq 3 ]]; then + echo "Failed to mirror ${image_tag} after ${attempt} attempts" >&2 + exit 1 + fi + echo "Mirror attempt ${attempt} failed for ${image_tag}; retrying" >&2 + sleep "$((attempt * 15))" + done + done + + - name: Verify mirrored anonymous image access + env: + SOURCE_TAG: ${{ steps.image.outputs.source_tag }} + DESTINATION_TAGS: ${{ steps.image.outputs.destination_tags }} + run: | + docker logout ghcr.io + docker logout ccr.ccs.tencentyun.com + source_inspect="$(docker buildx imagetools inspect "${GHCR_IMAGE_NAME}:${SOURCE_TAG}")" + source_digest="$(sed -n 's/^Digest:[[:space:]]*//p' <<<"${source_inspect}" | head -n 1)" + test -n "${source_digest}" + IFS=',' read -ra tags <<< "${DESTINATION_TAGS}" + for image_tag in "${tags[@]}"; do + mirror_inspect="$(docker buildx imagetools inspect "${TCR_IMAGE_NAME}:${image_tag}")" + mirror_digest="$(sed -n 's/^Digest:[[:space:]]*//p' <<<"${mirror_inspect}" | head -n 1)" + test "${source_digest}" = "${mirror_digest}" + docker buildx imagetools inspect --raw "${TCR_IMAGE_NAME}:${image_tag}" | + jq -e '([.manifests[].platform.architecture] | index("amd64") != null) and ([.manifests[].platform.architecture] | index("arm64") != null)' + done diff --git a/AGENTS.md b/AGENTS.md index cdcd200e..f548ec73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ 7. **Draft 内准备资产**:通过带 `release_tag` 的 `workflow_dispatch` 在 Draft 中准备并验证资产;Android 重建时须在 Draft 阶段完成 Google Play 交付、用 Play 签名 APK 替换临时资产并通过独立签名门禁;`published` 事件只审计,禁止重新构建或上传,签名不符时恢复 Draft。 8. **桌面验证职责**:桌面 Release 工作流负责测试、包结构检查、签名与公证;代理不再重复下载 Draft 或执行本地首次启动验收,除非用户明确要求。 9. **发布后更新**:正式发布后,发布流程默认不得下载、覆盖安装或启动 `/Applications/EdgeEver.app`;已安装的桌面端通过应用内自动更新机制获取新版。仅在用户明确要求时使用 `--install-desktop` 执行原有安装验收,功能体验由用户在实际使用中验证。 -10. **失败处理**:工作流或资产审计失败时保持或恢复 Draft,修复后重跑;不得公开已知损坏的 Release。 +10. **失败处理**:Release 阻塞工作流或资产审计失败时保持或恢复 Draft,修复后重跑;不得公开已知损坏的 Release。GHCR 镜像属于阻塞门禁;腾讯云 TCR 公共镜像由独立工作流在正式发布后异步同步和审计,其耗时或失败不得阻塞 GitHub Release 或将已发布版本恢复为 Draft。 11. **Release 说明结构**:使用中英文双语格式(正文禁止包含字面量 `\n`),只写用户可感知的变化、影响以及必要的升级或迁移提醒。类型检查、构建命令、签名、公证、资产复用等技术验证细节保留在 Actions 和关联 Issue 中,不写入公开 Release 正文。功能/修复关联对应 Issue 并标记 Label,发布后回链并关闭 Issue。正文结构: ```md diff --git a/docs/release.md b/docs/release.md index 2393dba5..3faebccb 100644 --- a/docs/release.md +++ b/docs/release.md @@ -53,11 +53,14 @@ previous installation check is actually needed. Release asset rather than the overall GitHub tag. This prevents a Web-only or API-only Release from prompting an unnecessary native update. - The script creates the tracking Issue and Draft Release, validates or reuses - native assets, prepares the multi-platform Docker image in both GHCR and the - public Tencent TCR mirror, publishes, and closes the Issue without installing + native assets, prepares and audits the multi-platform GHCR image, publishes, + and closes the Issue without installing the desktop application by default; installation remains available as an explicit option. Demo deployment continues independently after its Actions URL is printed. +- A separate workflow mirrors and audits the public Tencent TCR image + asynchronously after the formal Release is published. Its duration or failure + does not block the GitHub Release or return a published version to Draft. - This command does not authorize or run mobile store delivery itself. After Draft native assets are prepared, publication is blocked unless the Android APK uses the Google Play app-signing certificate. If that gate fails, the @@ -71,16 +74,18 @@ previous installation check is actually needed. The official repository must define `TENCENT_TCR_USERNAME` and `TENCENT_TCR_PASSWORD` Actions secrets. For the TCR Personal Edition registry, the username is the Tencent Cloud account ID and the password is the fixed -registry password initialized in the TCR console. Draft preparation publishes -the same tags to GHCR and TCR; both registries are checked anonymously before -the Release can be published. +registry password initialized in the TCR console. GHCR is the blocking Release +gate. TCR receives the same tags asynchronously after publication and performs +its own anonymous-access, digest, and architecture checks. ## Failure and Resume -- Validation, Draft asset, or Docker image failures leave the Release unpublished. +- Validation, Draft asset, or GHCR image failures leave the Release unpublished. +- An asynchronous TCR mirror failure leaves the formal Release intact and is + repaired or rerun in its independent Actions workflow. - Rerunning the same command resumes a matching Draft created by an interrupted run instead of creating another Issue, commit, or Release. -- A failed post-publication native or Docker audit attempts to return the Release to +- A failed post-publication native or GHCR audit attempts to return the Release to Draft and leaves the Issue open. - If an explicit application installation fails, the script restores the previous app from its macOS Trash backup when possible. diff --git a/docs/release.zh-CN.md b/docs/release.zh-CN.md index 00eb3a2c..a7243b35 100644 --- a/docs/release.zh-CN.md +++ b/docs/release.zh-CN.md @@ -45,10 +45,12 @@ bun run release -- \ Android arm64 APK。未变化的原生资产沿用原文件名、版本和校验和。 - 桌面端和 Android 更新检查使用对应 Release 资产中记录的版本,而不是整体 GitHub Tag,避免仅涉及 Web 或 API 的 Release 触发无效原生更新。 -- 脚本负责创建跟踪 Issue 和 Draft Release、验证或复用原生资产、准备多架构 - Docker 镜像并同时写入 GHCR 与腾讯云 TCR 公共镜像、正式发布、关闭 Issue, +- 脚本负责创建跟踪 Issue 和 Draft Release、验证或复用原生资产、准备并审计 + GHCR 多架构 Docker 镜像、正式发布、关闭 Issue, 默认不安装桌面端应用;安装能力作为显式选项保留。 输出 Actions 链接后,Demo 部署会独立继续执行。 +- 腾讯云 TCR 公共镜像由独立工作流在正式 Release 发布后异步同步和审计;其 + 耗时或失败不会阻塞 GitHub Release,也不会把已发布版本恢复为 Draft。 - 此命令不会自行授权或执行移动端商店交付。Draft 原生资产准备完成后,发布 命令会强制核验 Android APK 是否使用 Google Play 应用签名证书;未通过时 保持 Draft 并停止。此时先针对同一 Draft 执行 @@ -60,14 +62,15 @@ bun run release -- \ 官方仓库必须配置 `TENCENT_TCR_USERNAME` 和 `TENCENT_TCR_PASSWORD` 两个 Actions Secret。对于 TCR 个人版,用户名是腾讯云账号 ID,密码是在 TCR 控制台 -初始化的固定登录密码。Draft 准备阶段会向 GHCR 与 TCR 写入相同标签;正式发布 -前会以匿名方式检查两个仓库。 +初始化的固定登录密码。GHCR 是正式发布的阻塞门禁;TCR 在发布后异步写入相同 +标签并独立执行匿名访问、Digest 和架构检查。 ## 失败与续跑 -- 本地验证、Draft 资产或 Docker 镜像失败时,Release 保持未发布状态。 +- 本地验证、Draft 资产或 GHCR 镜像失败时,Release 保持未发布状态。 +- TCR 异步镜像失败时保留正式 Release,并在独立 Actions 工作流中修复、重跑。 - 中断后重新执行相同命令,会续跑匹配的 Draft,不会重复创建 Issue、提交或 Release。 -- 发布后的原生资产或 Docker 镜像审计失败时,脚本会尝试将 Release 恢复为 +- 发布后的原生资产或 GHCR 镜像审计失败时,脚本会尝试将 Release 恢复为 Draft,并保留 Issue。 - 显式安装时若替换应用失败,脚本会尽可能从 macOS 废纸篓备份恢复上一版应用。 diff --git a/scripts/docker-release.test.mjs b/scripts/docker-release.test.mjs index faf62252..45a40ae9 100644 --- a/scripts/docker-release.test.mjs +++ b/scripts/docker-release.test.mjs @@ -75,6 +75,7 @@ describe("Docker release contract", () => { test("gates official image publishing and release auditing", () => { const workflow = readProjectFile(".github/workflows/docker-image.yml"); + const mirrorWorkflow = readProjectFile(".github/workflows/docker-tcr-mirror.yml"); expect(workflow.match(/github\.repository == 'tianma-if\/edgeever'/g)?.length).toBeGreaterThanOrEqual(2); expect(workflow).toContain("release_tag"); expect(workflow).toContain("contents: write\n packages: write"); @@ -82,24 +83,25 @@ describe("Docker release contract", () => { expect(workflow).toContain('gh release view "${RELEASE_TAG}"'); expect(workflow).not.toContain('releases/tags/${RELEASE_TAG}'); expect(workflow).toContain("docker logout ghcr.io"); - expect(workflow).toContain("TCR_IMAGE_NAME: ccr.ccs.tencentyun.com/edgeever/edgeever"); - expect(workflow).toContain("secrets.TENCENT_TCR_USERNAME"); - expect(workflow).toContain("secrets.TENCENT_TCR_PASSWORD"); - expect(workflow).toContain("sudo apt-get install --yes skopeo"); - expect(workflow).toContain("name: Mirror image to Tencent Cloud Container Registry"); - expect(workflow).toContain('skopeo copy \\'); - expect(workflow).toContain("--all"); - expect(workflow).toContain("--preserve-digests"); - expect(workflow).toContain('--authfile "${HOME}/.docker/config.json"'); - expect(workflow).toContain('"docker://${GHCR_IMAGE_NAME}:${image_tag}"'); - expect(workflow).toContain('"docker://${TCR_IMAGE_NAME}:${image_tag}"'); - expect(workflow).toContain("for attempt in 1 2 3; do"); - expect(workflow).toContain('Failed to mirror ${image_tag} after ${attempt} attempts'); - expect(workflow).toContain('sleep "$((attempt * 15))"'); - expect(workflow).not.toContain('echo "${TCR_IMAGE_NAME}:${RELEASE_TAG}"'); - expect(workflow).toContain('docker logout ccr.ccs.tencentyun.com'); - expect(workflow).toContain('test "${ghcr_digest}" = "${tcr_digest}"'); - expect(workflow).toContain('for image in "${GHCR_IMAGE_NAME}" "${TCR_IMAGE_NAME}"'); expect(workflow).toContain("docker buildx imagetools inspect"); + expect(workflow).not.toContain("TCR_IMAGE_NAME"); + expect(workflow).not.toContain("TENCENT_TCR_USERNAME"); + + expect(mirrorWorkflow).toContain("name: Mirror Docker image to Tencent TCR"); + expect(mirrorWorkflow).toContain("workflow_run:"); + expect(mirrorWorkflow).toContain("release:"); + expect(mirrorWorkflow).toContain("github.event.workflow_run.event == 'push'"); + expect(mirrorWorkflow).toContain("TCR_IMAGE_NAME: ccr.ccs.tencentyun.com/edgeever/edgeever"); + expect(mirrorWorkflow).toContain("secrets.TENCENT_TCR_USERNAME"); + expect(mirrorWorkflow).toContain("secrets.TENCENT_TCR_PASSWORD"); + expect(mirrorWorkflow).toContain("sudo apt-get install --yes skopeo"); + expect(mirrorWorkflow).toContain('skopeo copy \\'); + expect(mirrorWorkflow).toContain("--all"); + expect(mirrorWorkflow).toContain("--preserve-digests"); + expect(mirrorWorkflow).toContain('"docker://${GHCR_IMAGE_NAME}:${SOURCE_TAG}"'); + expect(mirrorWorkflow).toContain('"docker://${TCR_IMAGE_NAME}:${image_tag}"'); + expect(mirrorWorkflow).toContain("for attempt in 1 2 3; do"); + expect(mirrorWorkflow).toContain('Failed to mirror ${image_tag} after ${attempt} attempts'); + expect(mirrorWorkflow).toContain('test "${source_digest}" = "${mirror_digest}"'); }); });