feat(release): 接入 R2 镜像并限制发布保留量

- 应用与驱动更新优先使用 R2,并保留 GitHub 回退
- stable/dev 各保留当前版本并实施 9GB 发布预算
- 增加指针校验、安全清理和发布回归测试
This commit is contained in:
Syngnat
2026-07-22 21:43:57 +08:00
parent e30c2d8c62
commit e11706f9a9
13 changed files with 2127 additions and 129 deletions

View File

@@ -31,6 +31,7 @@ jobs:
bash tools/generate-driver-agent-revisions.test.sh
python3 tools/generate-driver-release-manifest.test.py
python3 tools/generate-update-latest-manifest.test.py
python3 tools/r2-release-retention.test.py
python3 tools/package-driver-release-assets.test.py
python3 tools/legal-release-assets.test.py
python3 tools/windows-release-artifacts.test.py
@@ -1118,6 +1119,11 @@ jobs:
needs:
- build
- driver_agents
# Serialize only the publication stage with stable releases so both
# channels cannot pass the retained-byte preflight concurrently.
concurrency:
group: gonavi-r2-publication
cancel-in-progress: false
runs-on: ubuntu-latest
steps:
- name: Checkout code
@@ -1263,6 +1269,8 @@ jobs:
--tag dev-latest \
--channel dev \
--name "Dev Build (${DEV_VERSION})" \
--download-base-url "https://download.syngnat.top/gonavi/dev/releases/download" \
--download-tag "$DEV_VERSION" \
--output release-assets/latest-dev.json
test -s release-assets/latest-dev.json
echo "📄 latest-dev.json ready:"
@@ -1422,3 +1430,249 @@ jobs:
> 每次 push 到 `dev` 分支会自动覆盖此 release。
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Mirror, promote, and retain latest dev release in Cloudflare R2
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
DEV_VERSION: ${{ steps.version.outputs.version }}
HAS_DRIVER_ASSETS: ${{ steps.driver_assets.outputs.has_driver_assets }}
R2_BUCKET: gonavi-downloads
shell: bash
run: |
set -euo pipefail
if [[ -z "${CLOUDFLARE_ACCOUNT_ID}" || -z "${AWS_ACCESS_KEY_ID}" || -z "${AWS_SECRET_ACCESS_KEY}" ]]; then
echo "Cloudflare R2 release credentials are not configured" >&2
exit 1
fi
endpoint="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"
app_dir="release-assets"
driver_dir="driver-release-assets"
app_prefix="gonavi/dev/releases/download/${DEV_VERSION}"
driver_prefix="drivers/dev/releases/download/${DEV_VERSION}"
jq -e \
--arg version "${DEV_VERSION}" \
--arg mirror_base "https://download.syngnat.top/gonavi/dev/releases/download" \
--arg github_base "https://github.com/${GITHUB_REPOSITORY}/releases/download/dev-latest" \
'
.channel == "dev"
and .tagName == "dev-latest"
and .version == $version
and (.assets | type == "array" and length > 0)
and all(.assets[];
(.url | type == "string" and startswith($mirror_base + "/" + $version + "/"))
and (.apiUrl | type == "string" and startswith($github_base + "/"))
and (.sha256 | type == "string" and test("^[0-9a-fA-F]{64}$"))
)
' "${app_dir}/latest-dev.json" >/dev/null
driver_latest_file="${RUNNER_TEMP}/driver-dev-latest-index.json"
if [[ "${HAS_DRIVER_ASSETS}" == "true" ]]; then
[[ -s "${driver_dir}/SHA256SUMS" ]] || {
echo "DriverAgents SHA256SUMS is missing" >&2
exit 1
}
(cd "${driver_dir}" && sha256sum --check SHA256SUMS)
jq \
--arg tag "dev-latest" \
--arg mirror_tag "${DEV_VERSION}" \
'.tagName = $tag | .mirrorTagName = $mirror_tag | del(.assets["GoNavi-DriverAgents.zip"])' \
"${driver_dir}/GoNavi-DriverAgents-Index.json" \
> "${driver_latest_file}"
jq -e '.assets | type == "object" and length > 0' "${driver_latest_file}" >/dev/null
else
current_driver_state="${RUNNER_TEMP}/r2-current-dev-driver-state.json"
if python3 tools/r2-release-retention.py validate-driver-pointer \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--channel dev > "${current_driver_state}"; then
current_driver_keep_prefix="$(jq -r '.keepPrefix' "${current_driver_state}")"
python3 tools/r2-release-retention.py prune \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--root-prefix "drivers/dev/releases/download/" \
--keep-prefix "${current_driver_keep_prefix}"
else
echo "::warning::Current dev driver pointer could not be fully validated; preserving every dev driver object"
fi
fi
app_manifest_size="$(stat -c '%s' "${app_dir}/latest-dev.json")"
planned_bytes="$(jq '[.assets[].size] | add // 0' "${app_dir}/latest-dev.json")"
planned_bytes="$((planned_bytes + app_manifest_size * 2))"
budget_args=(
measure
--bucket "${R2_BUCKET}"
--endpoint-url "${endpoint}"
--prefix "gonavi/"
--prefix "drivers/"
--exclude-prefix "gonavi/dev/releases/download/"
--exclude-prefix "gonavi/dev/releases/latest/"
)
if [[ -s "${driver_latest_file}" ]]; then
driver_index_size="$(stat -c '%s' "${driver_latest_file}")"
driver_asset_bytes="$(jq '[.assets[]] | add // 0' "${driver_latest_file}")"
planned_bytes="$((planned_bytes + driver_asset_bytes + driver_index_size * 2))"
budget_args+=(
--exclude-prefix "drivers/dev/releases/download/"
--exclude-prefix "drivers/dev/releases/latest/"
)
fi
python3 tools/r2-release-retention.py "${budget_args[@]}" \
--add-bytes "${planned_bytes}" \
--max-bytes 9000000000
upload_immutable() {
local file="$1"
local key="$2"
local content_type="${3:-}"
local sha size head_json remote_sha remote_size
sha="$(sha256sum "${file}" | awk '{print $1}')"
size="$(stat -c '%s' "${file}")"
if head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json 2>&1)"; then
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
echo "Refusing to overwrite immutable R2 object ${key}" >&2
exit 1
fi
return
elif ! grep -Eq '\(404\)|Not Found|NoSuchKey' <<<"${head_json}"; then
echo "Unable to inspect immutable R2 object ${key}: ${head_json}" >&2
exit 1
fi
args=(s3 cp "${file}" "s3://${R2_BUCKET}/${key}" --endpoint-url "${endpoint}" --cache-control "public,max-age=31536000,immutable" --metadata "sha256=${sha}" --only-show-errors)
if [[ -n "${content_type}" ]]; then
args+=(--content-type "${content_type}")
fi
aws "${args[@]}"
head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json)"
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
echo "R2 verification failed for ${key}" >&2
exit 1
fi
}
while IFS=$'\t' read -r name expected_size expected_sha; do
[[ "${name}" == "$(basename "${name}")" ]] || {
echo "Invalid dev app asset name: ${name}" >&2
exit 1
}
file="${app_dir}/${name}"
[[ -f "${file}" ]] || {
echo "Dev app asset is missing: ${name}" >&2
exit 1
}
actual_size="$(stat -c '%s' "${file}")"
actual_sha="$(sha256sum "${file}" | awk '{print $1}')"
if [[ "${actual_size}" != "${expected_size}" || "${actual_sha}" != "${expected_sha,,}" ]]; then
echo "Dev app asset does not match latest-dev.json: ${name}" >&2
exit 1
fi
upload_immutable "${file}" "${app_prefix}/${name}"
done < <(jq -r '.assets[] | [.name, (.size | tostring), .sha256] | @tsv' "${app_dir}/latest-dev.json")
upload_immutable \
"${app_dir}/latest-dev.json" \
"${app_prefix}/latest-dev.json" \
"application/json; charset=utf-8"
if [[ "${HAS_DRIVER_ASSETS}" == "true" ]]; then
while IFS=$'\t' read -r name expected_size; do
[[ "${name}" == "$(basename "${name}")" ]] || {
echo "Invalid dev driver asset name: ${name}" >&2
exit 1
}
file="${driver_dir}/${name}"
[[ -f "${file}" ]] || {
echo "Dev driver asset is missing: ${name}" >&2
exit 1
}
actual_size="$(stat -c '%s' "${file}")"
if [[ "${actual_size}" != "${expected_size}" ]]; then
echo "Dev driver asset size does not match index: ${name}" >&2
exit 1
fi
upload_immutable "${file}" "${driver_prefix}/${name}"
done < <(jq -r '.assets | to_entries[] | [.key, (.value | tostring)] | @tsv' "${driver_latest_file}")
upload_immutable \
"${driver_latest_file}" \
"${driver_prefix}/GoNavi-DriverAgents-Index.json" \
"application/json; charset=utf-8"
fi
promote_mutable() {
local file="$1"
local key="$2"
local sha size head_json remote_sha remote_size
sha="$(sha256sum "${file}" | awk '{print $1}')"
size="$(stat -c '%s' "${file}")"
aws s3 cp "${file}" "s3://${R2_BUCKET}/${key}" \
--endpoint-url "${endpoint}" \
--cache-control "no-cache,must-revalidate" \
--content-type "application/json; charset=utf-8" \
--metadata "sha256=${sha}" \
--only-show-errors
head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json)"
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
echo "R2 mutable object verification failed for ${key}" >&2
exit 1
fi
}
app_latest_key="gonavi/dev/releases/latest/latest-dev.json"
promote_mutable "${app_dir}/latest-dev.json" "${app_latest_key}"
curl --fail --silent --show-error \
"https://download.syngnat.top/${app_latest_key}?verify=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
| jq -e --arg version "${DEV_VERSION}" '.channel == "dev" and .version == $version' >/dev/null
if [[ -s "${driver_latest_file}" ]]; then
driver_latest_key="drivers/dev/releases/latest/GoNavi-DriverAgents-Index.json"
promote_mutable "${driver_latest_file}" "${driver_latest_key}"
curl --fail --silent --show-error \
"https://download.syngnat.top/${driver_latest_key}?verify=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
| jq -e \
--arg mirror_tag "${DEV_VERSION}" \
'.tagName == "dev-latest" and .mirrorTagName == $mirror_tag' >/dev/null
fi
python3 tools/r2-release-retention.py prune \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--root-prefix "gonavi/dev/releases/download/" \
--keep-prefix "${app_prefix}/"
driver_keep_prefix=""
if [[ -s "${driver_latest_file}" ]]; then
driver_keep_prefix="${driver_prefix}/"
else
current_driver_state="${RUNNER_TEMP}/r2-current-dev-driver-state.json"
if python3 tools/r2-release-retention.py validate-driver-pointer \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--channel dev > "${current_driver_state}"; then
driver_keep_prefix="$(jq -r '.keepPrefix' "${current_driver_state}")"
else
echo "::warning::Skipping dev driver cleanup because the current R2 pointer or retained assets are invalid"
fi
fi
if [[ -n "${driver_keep_prefix}" ]]; then
python3 tools/r2-release-retention.py prune \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--root-prefix "drivers/dev/releases/download/" \
--keep-prefix "${driver_keep_prefix}"
fi
python3 tools/r2-release-retention.py measure \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--prefix "gonavi/" \
--prefix "drivers/" \
--max-bytes 9000000000