mirror of
https://github.com/feigeCode/navop.git
synced 2026-11-06 08:07:31 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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}/"
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user