ci(r2): 上传校验通过后只保留最新版本

R2 桶里越堆越多的是历次版本的安装包,而下载入口只认最新一份:
updates/latest.json 只指向本次 tag,旧版本另有 GitHub Release 与
CNB 镜像存档。所以在上传并逐对象校验成功之后,清掉其余
releases/<tag>/ 前缀。

护栏:
- 排在验证步骤之后且不带 always(),上传失败时一个对象都不删;
- 只处理 releases/ 前缀,更新清单所在的前缀永不触碰;
- 本次 tag 不在桶里就拒绝清理,宁可留下旧版本也不删最后一份可用包;
- 非稳定 tag(预发布)跳过清理,不会带走上一个稳定版本的安装包。

顺带补上契约测试与行为测试(抽出的脚本配假 aws 跑真实清理逻辑),
并在 .github/RELEASE.md 记录这条保留策略。
This commit is contained in:
胡飞
2026-10-06 23:21:37 +08:00
parent 05f2c21894
commit 74d0e05951
3 changed files with 202 additions and 0 deletions
+2
View File
@@ -31,6 +31,7 @@ The normal release sequence is:
6. The workflow checks the application version and tagged changelog entry before starting expensive builds.
7. macOS ARM64, macOS x86_64, Linux x86_64, Linux ARM64, and Windows x86_64 build in parallel in one matrix.
8. After all requested platforms finish, the workflow extracts the tagged entry, uses it as the GitHub Release body, and writes the same Markdown to the R2 `latest.json` `release_notes` field.
9. Once every uploaded object has been verified, R2 keeps only the tag that was just uploaded: the other `releases/<tag>/` prefixes are pruned, so the bucket carries one version's installers instead of the whole history. Nothing becomes unreachable — `updates/latest.json` only ever points at the tag being uploaded, and older installers stay on their GitHub Release and on the CNB mirror.
The build workflow checks out the requested tag, while the workflow itself runs from `main`. This keeps Cargo input caches and sccache data reusable across tags and repair runs.
@@ -117,5 +118,6 @@ For a failed matrix job in the same workflow run, prefer **Re-run failed jobs**.
- A new release tag must contain a valid bilingual `CHANGELOG.md` entry before builds begin.
- GitHub Release notes and R2 updater `release_notes` are extracted from the same tagged changelog entry.
- Publishing uses `--clobber` only for newly built platform files and `sha256sums.txt`.
- R2 keeps only the newest release. The prune step runs after the upload has been verified object by object and never carries `always()`, so a failed upload leaves the existing objects alone; it refuses to delete anything when its own tag is missing from R2, and a non-stable tag (for example `v0.20.0-rc.1`) skips pruning so it cannot take the last stable installers with it.
- Legacy pre-changelog repairs preserve their existing GitHub Release body.
- All five primary platform builds belong to one matrix, so they start in parallel and a failed job can be rerun without rebuilding successful matrix jobs.
+71
View File
@@ -398,3 +398,74 @@ jobs:
done
upload_object "artifacts/updates/latest.json" "updates/latest.json" "application/json; charset=utf-8" "no-store, max-age=0"
# 桶里只留这次上传的版本:旧安装包在 R2 上只占空间,下载入口都不依赖它们
# —— updates/latest.json 只指向本次 tag,旧版本另有 GitHub Release 与 CNB
# 镜像作为存档。这一步必须待在上传与逐对象校验之后,且不能加 always():
# 上传没成功就一个对象都不许删,否则桶里可能新旧两份都不完整。
- name: Prune older R2 releases
if: ${{ steps.r2_config.outputs.skip != 'true' && steps.release.outputs.skip != 'true' }}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_REGION: auto
AWS_EC2_METADATA_DISABLED: true
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
endpoint="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"
# 所有删除都限定在这个前缀下:更新清单(latest.json 在另一条前缀里)
# 永远不碰。
prefix="releases"
# 预发布(v0.20.0-rc.1 之类)不清理:它不该把上一个稳定版本的安装包
# 带走,而它的对象本来也只是临时放一下。
if ! [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::warning::Skipping R2 prune for non-stable release tag '${RELEASE_TAG}'."
exit 0
fi
# 列出 releases/ 下的版本前缀(delimiter 让每页只回顶层目录名)。
list_release_prefixes() {
aws s3api list-objects-v2 \
--bucket "${CLOUDFLARE_R2_BUCKET}" \
--prefix "${prefix}/" \
--delimiter "/" \
--endpoint-url "${endpoint}" \
--query "CommonPrefixes[].Prefix" \
--output text \
| sed -n "s|^${prefix}/\\([^/]*\\)/\$|\\1|p" \
| sort -u
}
tags="$(list_release_prefixes)"
# 本次 tag 不在桶里就什么都不删:宁可留下旧版本,也不能在「新版本其实
# 没上传成功」时把最后一份可用安装包清掉。
if ! grep -Fxq "${RELEASE_TAG}" <<< "${tags}"; then
echo "::error::Refusing to prune: ${prefix}/${RELEASE_TAG}/ is not present in R2"
exit 1
fi
# 注意这里的 for 依赖分词逐行遍历。
for tag in ${tags}; do
if [ "${tag}" = "${RELEASE_TAG}" ]; then
continue
fi
echo "Deleting ${prefix}/${tag}/"
aws s3 rm "s3://${CLOUDFLARE_R2_BUCKET}/${prefix}/${tag}/" \
--recursive \
--endpoint-url "${endpoint}"
done
for tag in $(list_release_prefixes); do
if [ "${tag}" != "${RELEASE_TAG}" ]; then
echo "::error::R2 still holds ${prefix}/${tag}/ after pruning"
exit 1
fi
done
echo "R2 now keeps only ${prefix}/${RELEASE_TAG}/"
+129
View File
@@ -1082,6 +1082,135 @@ test("R2 uploads are single-dispatch, revalidated, and verified after overwrite"
assert.doesNotMatch(upload, /max-age=31536000, immutable/);
});
test("R2 keeps only the release it just uploaded", () => {
const upload = read(".github/workflows/upload-r2.yml");
// 清理必须排在上传与逐对象校验之后,而且不能总是执行:上传失败时一个对象
// 都不能删,否则桶里可能新旧两份都不完整。
assert.ok(
upload.indexOf("Prune older R2 releases") >
upload.indexOf("Upload update archives and manifest to R2"),
);
const pruneStep = workflowStep(upload, "Prune older R2 releases");
assert.doesNotMatch(pruneStep, /always\(\)/);
assert.doesNotMatch(pruneStep, /continue-on-error/);
assert.match(
pruneStep,
/if: \$\{\{ steps\.r2_config\.outputs\.skip != 'true' && steps\.release\.outputs\.skip != 'true' \}\}/,
);
// 只动 releases/<tag>/:updates/latest.json 是更新入口,任何时候都不能删。
assert.match(pruneStep, /prefix="releases"/);
assert.doesNotMatch(pruneStep, /updates\//);
// 只有稳定版本号才清理,预发布不能把上一个稳定版本的安装包带走。
assert.match(pruneStep, /=\~ \^v\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\$ \]\]/);
assert.match(pruneStep, /Skipping R2 prune for non-stable release tag/);
const pruneRun = pruneStep
.match(/run: \|\n([\s\S]*)/)?.[1]
.split("\n")
.map((line) => line.replace(/^ {10}/, ""))
.join("\n");
assert.ok(pruneRun, "prune step must have a run script");
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "navop-r2-prune-"));
const statePath = path.join(fixtureDir, "bucket");
const deletedPath = path.join(fixtureDir, "deleted");
const fakeAws = path.join(fixtureDir, "aws");
// 假 aws 只实现这一步用到的两条命令:列出版本前缀、递归删掉一个前缀。删除
// 会真的从 fixture 里拿掉版本,所以脚本最后的复查也一起被验证到。
fs.writeFileSync(
fakeAws,
`#!/usr/bin/env bash
set -euo pipefail
case "\${1:-} \${2:-}" in
"s3api list-objects-v2")
grep -v '^$' "\${FAKE_R2_STATE}" | while read -r tag; do
printf 'releases/%s/\\n' "\$tag"
done
;;
"s3 rm")
target=""
for argument in "\$@"; do
case "\$argument" in s3://*) target="\$argument" ;; esac
done
key="\${target#s3://*/}"
key="\${key%/}"
echo "\$key" >> "\${FAKE_R2_DELETED}"
grep -Fxv "\${key#releases/}" "\${FAKE_R2_STATE}" > "\${FAKE_R2_STATE}.tmp"
mv "\${FAKE_R2_STATE}.tmp" "\${FAKE_R2_STATE}"
;;
*)
echo "unexpected aws invocation: \$*" >&2
exit 42
;;
esac
`,
{ mode: 0o755 },
);
const runPrune = (tags, releaseTag = "v0.19.5") => {
fs.writeFileSync(statePath, `${tags.join("\n")}\n`);
fs.writeFileSync(deletedPath, "");
const result = spawnSync("bash", ["-c", pruneRun], {
encoding: "utf8",
env: {
...process.env,
PATH: `${fixtureDir}:${process.env.PATH}`,
FAKE_R2_STATE: statePath,
FAKE_R2_DELETED: deletedPath,
CLOUDFLARE_ACCOUNT_ID: "test-account",
CLOUDFLARE_R2_BUCKET: "test-bucket",
RELEASE_TAG: releaseTag,
},
});
return {
result,
remaining: fs
.readFileSync(statePath, "utf8")
.trim()
.split("\n")
.filter(Boolean),
deleted: fs
.readFileSync(deletedPath, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.sort(),
};
};
try {
const pruned = runPrune(["v0.19.5", "v0.19.4", "v0.19.3"]);
assert.equal(pruned.result.status, 0, pruned.result.stderr);
assert.deepEqual(pruned.remaining, ["v0.19.5"]);
assert.deepEqual(pruned.deleted, [
"releases/v0.19.3",
"releases/v0.19.4",
]);
assert.match(pruned.result.stdout, /R2 now keeps only releases\/v0\.19\.5\//);
// 本次 tag 不在桶里(上传其实没成功)时,一个旧版本都不能删。
const missing = runPrune(["v0.19.4", "v0.19.3"]);
assert.notEqual(missing.result.status, 0);
assert.match(missing.result.stdout, /Refusing to prune/);
assert.deepEqual(missing.deleted, []);
assert.deepEqual(missing.remaining, ["v0.19.4", "v0.19.3"]);
// 预发布不清理,既有稳定版本原样保留。
const prerelease = runPrune(["v0.19.5", "v0.19.4"], "v0.20.0-rc.1");
assert.equal(prerelease.result.status, 0, prerelease.result.stderr);
assert.deepEqual(prerelease.deleted, []);
assert.deepEqual(prerelease.remaining, ["v0.19.5", "v0.19.4"]);
assert.match(
prerelease.result.stdout,
/Skipping R2 prune for non-stable release tag/,
);
} finally {
fs.rmSync(fixtureDir, { recursive: true, force: true });
}
});
test("CNB release synchronization replaces moved tags before syncing assets", () => {
const sync = read(".github/workflows/sync-cnb-release-assets.yml");