fix(release): mirror TCR images asynchronously

This commit is contained in:
tianma-if committed 2026-08-25 19:23:14 +08:00
1 parent bc6fdfdcd4
commit 95e0f3eb97
6 files changed
+198 -106

No files matched your search

+12 -74
View File
@@ -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<<EOF"
echo "${RELEASE_TAG}"
echo "${version}"
echo "latest"
echo "EOF"
echo "version=${version}"
echo "audit_tag=${RELEASE_TAG}"
} >> "${GITHUB_OUTPUT}"
@@ -159,10 +153,6 @@ jobs:
echo "${GHCR_IMAGE_NAME}:main"
echo "${GHCR_IMAGE_NAME}:sha-${short_sha}"
echo "EOF"
echo "mirror_tags<<EOF"
echo "main"
echo "sha-${short_sha}"
echo "EOF"
echo "version=main"
echo "audit_tag=main"
} >> "${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)'
+144
View File
@@ -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
+1 -1
View File
@@ -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
+12 -7
View File
@@ -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.
+9 -6
View File
@@ -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 废纸篓备份恢复上一版应用。
+20 -18
View File
@@ -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}"');
});
});