Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dcc796f4c | ||
|
|
3e792b1c86 | ||
|
|
b9c822d49d |
@@ -438,6 +438,7 @@ jobs:
|
||||
release_version: ${{ steps.target.outputs.release_version }}
|
||||
release_commit: ${{ steps.target.outputs.release_commit }}
|
||||
release_tag_object: ${{ steps.target.outputs.release_tag_object }}
|
||||
oss_mirror: ${{ steps.target.outputs.oss_mirror }}
|
||||
is_recovery: ${{ steps.target.outputs.is_recovery }}
|
||||
failed_run_id: ${{ steps.target.outputs.failed_run_id }}
|
||||
channel: ${{ steps.contract.outputs.channel }}
|
||||
@@ -541,8 +542,14 @@ jobs:
|
||||
}
|
||||
tagFields.set(key, line.slice(separator + 2));
|
||||
}
|
||||
const cloudKeys = [
|
||||
"Channel",
|
||||
const ossMirror = tagFields.has("OSS-Mirror")
|
||||
? tagFields.get("OSS-Mirror")
|
||||
: "enabled";
|
||||
if (!["enabled", "deferred"].includes(ossMirror)) {
|
||||
core.setFailed(`${version} contains invalid OSS-Mirror metadata`);
|
||||
return;
|
||||
}
|
||||
const cloudOnlyKeys = [
|
||||
"Release-Run",
|
||||
"Release-Run-Attempt",
|
||||
"Requested-By",
|
||||
@@ -551,7 +558,8 @@ jobs:
|
||||
"Workflow-Commit",
|
||||
"Allocation-Fingerprint",
|
||||
];
|
||||
const hasAnyCloudMetadata = cloudKeys.some((key) => tagFields.has(key));
|
||||
const cloudKeys = ["Channel", ...cloudOnlyKeys];
|
||||
const hasAnyCloudMetadata = cloudOnlyKeys.some((key) => tagFields.has(key));
|
||||
const isCloudSeal = cloudKeys.every((key) => tagFields.has(key));
|
||||
if (hasAnyCloudMetadata && !isCloudSeal) {
|
||||
core.setFailed(`${version} contains incomplete cloud seal metadata`);
|
||||
@@ -655,6 +663,7 @@ jobs:
|
||||
core.setOutput("release_version", version);
|
||||
core.setOutput("release_commit", commit);
|
||||
core.setOutput("release_tag_object", tagObject);
|
||||
core.setOutput("oss_mirror", ossMirror);
|
||||
core.setOutput("is_recovery", isRecovery ? "true" : "false");
|
||||
core.setOutput("failed_run_id", failedRunId);
|
||||
|
||||
@@ -1694,6 +1703,7 @@ jobs:
|
||||
echo "npm $NPM_TAG already supersedes this historical rerun at v$delivered."
|
||||
|
||||
- name: Sync release artifacts to China OSS mirror
|
||||
if: ${{ needs.release-contract.outputs.oss_mirror == 'enabled' }}
|
||||
run: ./scripts/release/sync-to-oss.sh
|
||||
env:
|
||||
VERSION: ${{ needs.release-contract.outputs.release_version }}
|
||||
@@ -1703,7 +1713,7 @@ jobs:
|
||||
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
|
||||
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
|
||||
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
|
||||
DWS_REQUIRE_OSS: ${{ github.repository_owner == 'DingTalk-Real-AI' && '1' || '0' }}
|
||||
DWS_REQUIRE_OSS: "1"
|
||||
|
||||
mirror-gitee-release:
|
||||
name: Mirror immutable release to Gitee
|
||||
@@ -2218,6 +2228,24 @@ jobs:
|
||||
core.setFailed(`${version} does not resolve to one annotated commit tag`);
|
||||
return;
|
||||
}
|
||||
const tagFields = new Map();
|
||||
for (const line of tag.data.message.split(/\r?\n/)) {
|
||||
const separator = line.indexOf(": ");
|
||||
if (separator < 1) continue;
|
||||
const key = line.slice(0, separator);
|
||||
if (tagFields.has(key)) {
|
||||
core.setFailed(`${version} annotated tag contains duplicate ${key} metadata`);
|
||||
return;
|
||||
}
|
||||
tagFields.set(key, line.slice(separator + 2));
|
||||
}
|
||||
const ossMirror = tagFields.has("OSS-Mirror")
|
||||
? tagFields.get("OSS-Mirror")
|
||||
: "enabled";
|
||||
if (!["enabled", "deferred"].includes(ossMirror)) {
|
||||
core.setFailed(`${version} contains invalid OSS-Mirror metadata`);
|
||||
return;
|
||||
}
|
||||
const commitSha = tag.data.object.sha;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
const branch = await github.rest.repos.getBranch({
|
||||
@@ -2273,6 +2301,17 @@ jobs:
|
||||
}
|
||||
core.setOutput("commit_sha", commitSha);
|
||||
core.setOutput("tag_object", ref.data.object.sha);
|
||||
core.setOutput("oss_mirror", ossMirror);
|
||||
|
||||
- name: Require sealed OSS policy for repair
|
||||
if: ${{ needs.dispatch-contract.outputs.mode == 'repair_oss' }}
|
||||
env:
|
||||
OSS_MIRROR: ${{ steps.authority.outputs.oss_mirror }}
|
||||
run: |
|
||||
test "$OSS_MIRROR" = enabled || {
|
||||
echo "OSS repair is unavailable because this immutable release deferred the OSS channel." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Check out sealed release source
|
||||
uses: actions/checkout@v4
|
||||
@@ -2375,6 +2414,7 @@ jobs:
|
||||
from_beta: ${{ steps.allocate.outputs.from_beta }}
|
||||
base: ${{ steps.allocate.outputs.base }}
|
||||
refs_fingerprint: ${{ steps.allocate.outputs.refs_fingerprint }}
|
||||
oss_mirror: ${{ steps.allocate.outputs.oss_mirror }}
|
||||
steps:
|
||||
- name: Validate official default-branch release request
|
||||
env:
|
||||
@@ -2425,6 +2465,7 @@ jobs:
|
||||
env:
|
||||
REQUESTED_CHANNEL: ${{ inputs.release_channel }}
|
||||
REQUESTED_BUMP: ${{ inputs.release_bump }}
|
||||
OSS_MIRROR: ${{ vars.ENABLE_OSS_MIRROR == 'true' && 'enabled' || 'deferred' }}
|
||||
run: |
|
||||
set -eu
|
||||
allocation="$RUNNER_TEMP/next-release-version"
|
||||
@@ -2449,6 +2490,8 @@ jobs:
|
||||
)"
|
||||
echo "release_commit=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "refs_fingerprint=$refs_fingerprint" >> "$GITHUB_OUTPUT"
|
||||
case "$OSS_MIRROR" in enabled|deferred) ;; *) exit 1 ;; esac
|
||||
echo "oss_mirror=$OSS_MIRROR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
release_version="$(sed -n 's/^release_version=//p' "$allocation")"
|
||||
from_beta="$(sed -n 's/^from_beta=//p' "$allocation")"
|
||||
@@ -2459,6 +2502,7 @@ jobs:
|
||||
echo "- Version: \`$release_version\`"
|
||||
echo "- Channel: \`$channel\`"
|
||||
echo "- Commit: \`$GITHUB_SHA\`"
|
||||
echo "- OSS mirror: \`$OSS_MIRROR\`"
|
||||
if test -n "$from_beta"; then
|
||||
echo "- Promotes beta: \`$from_beta\`"
|
||||
fi
|
||||
@@ -2474,6 +2518,7 @@ jobs:
|
||||
RELEASE_VERSION: ${{ steps.allocate.outputs.release_version }}
|
||||
RELEASE_CHANNEL: ${{ steps.allocate.outputs.channel }}
|
||||
FROM_BETA: ${{ steps.allocate.outputs.from_beta }}
|
||||
OSS_MIRROR: ${{ steps.allocate.outputs.oss_mirror }}
|
||||
run: |
|
||||
set -eu
|
||||
git config user.name "dws release cloud seal"
|
||||
@@ -2482,6 +2527,7 @@ jobs:
|
||||
printf 'Release %s\n\n' "$RELEASE_VERSION"
|
||||
printf 'Channel: %s\n' "$RELEASE_CHANNEL"
|
||||
test -z "$FROM_BETA" || printf 'From-Beta: %s\n' "$FROM_BETA"
|
||||
printf 'OSS-Mirror: %s\n' "$OSS_MIRROR"
|
||||
} > "$RUNNER_TEMP/candidate-tag-message"
|
||||
git tag -a "$RELEASE_VERSION" HEAD -F "$RUNNER_TEMP/candidate-tag-message"
|
||||
set -- \
|
||||
@@ -2554,6 +2600,7 @@ jobs:
|
||||
RELEASE_CHANNEL: ${{ needs.release-plan.outputs.channel }}
|
||||
FROM_BETA: ${{ needs.release-plan.outputs.from_beta }}
|
||||
REFS_FINGERPRINT: ${{ needs.release-plan.outputs.refs_fingerprint }}
|
||||
OSS_MIRROR: ${{ needs.release-plan.outputs.oss_mirror }}
|
||||
with:
|
||||
script: |
|
||||
const crypto = require("crypto");
|
||||
@@ -2565,6 +2612,7 @@ jobs:
|
||||
const channel = process.env.RELEASE_CHANNEL;
|
||||
const fromBeta = process.env.FROM_BETA;
|
||||
const expectedFingerprint = process.env.REFS_FINGERPRINT;
|
||||
const ossMirror = process.env.OSS_MIRROR;
|
||||
|
||||
if (
|
||||
context.payload.repository.full_name !== expectedRepository ||
|
||||
@@ -2577,7 +2625,8 @@ jobs:
|
||||
if (
|
||||
!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.[1-9]\d*)?$/.test(version) ||
|
||||
!/^[0-9a-f]{40}$/.test(commit) ||
|
||||
!/^[0-9a-f]{64}$/.test(expectedFingerprint)
|
||||
!/^[0-9a-f]{64}$/.test(expectedFingerprint) ||
|
||||
!["enabled", "deferred"].includes(ossMirror)
|
||||
) {
|
||||
core.setFailed("cloud release plan contains an invalid sealed identity");
|
||||
return;
|
||||
@@ -2646,6 +2695,7 @@ jobs:
|
||||
"",
|
||||
`Channel: ${channel}`,
|
||||
...(fromBeta ? [`From-Beta: ${fromBeta}`] : []),
|
||||
`OSS-Mirror: ${ossMirror}`,
|
||||
`Release-Run: ${context.runId}`,
|
||||
`Release-Run-Attempt: ${process.env.GITHUB_RUN_ATTEMPT}`,
|
||||
`Requested-By: ${context.actor}`,
|
||||
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
echo "- Workflow result: ${RESULT}"
|
||||
echo "- Success means every configured channel was verified and the permanent withdrawn/${VERSION} tombstone remains as the version-reuse barrier."
|
||||
echo "- Failure may occur before or after the tombstone/channel mutations; inspect the failed step and rerun the exact same inputs after fixing the cause."
|
||||
echo "- The problem GitHub Release and original tag are removed after npm/OSS/Gitee rollback so GitHub installers stop resolving the bad version while the Homebrew rollback PR is reviewed."
|
||||
echo "- The problem GitHub Release and original tag are removed after npm and every tag-enabled/configured mirror are rolled back, so GitHub installers stop resolving the bad version while the Homebrew rollback PR is reviewed."
|
||||
echo "- npm is deprecated rather than unpublished; already-installed clients cannot be remotely downgraded."
|
||||
echo "- If a Homebrew rollback PR was opened, this run remains failed until that PR is independently reviewed, merged, and the workflow is rerun."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -6,6 +6,18 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.53-beta.6] - 2026-07-21
|
||||
|
||||
This beta validates guarded local release compatibility and tag-bound OSS deferral so an unprovisioned mirror cannot block the primary release channels.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Tag-bound optional OSS release mirror** — Official cloud Release runs no longer block GitHub, npm, and Homebrew delivery when an OSS bucket has not been provisioned. Cloud tags immutably record `OSS-Mirror: enabled|deferred`; publication, repair, and withdrawal consume that sealed policy instead of the current repository variable. Enabled releases remain fail-closed, while deferred releases skip the nonexistent channel and cannot be backfilled without a future audited repair proof.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Guarded local release compatibility** — The tag-push Release workflow now accepts the `Channel`-only annotated tags created by the guarded local release entry while continuing to reject any partial cloud-only seal metadata.
|
||||
|
||||
## [1.0.53-beta.5] - 2026-07-21
|
||||
|
||||
This beta validates long-running access-token recovery and the faster, recoverable guarded release path introduced after v1.0.53-beta.4.
|
||||
|
||||
+27
-15
@@ -108,21 +108,33 @@ commit, and failed tag-push run all match; it then reuses the normal release
|
||||
jobs. Do not put publication secrets in temporary branches or create ad-hoc
|
||||
recovery workflows.
|
||||
|
||||
If the immutable GitHub Release and npm package were delivered but a downstream
|
||||
China mirror failed, dispatch the normal `Release` workflow from the protected
|
||||
default branch with exactly one of `repair_gitee_version` or
|
||||
`repair_oss_version`. Channel repair accepts a failed exact-tag run only when
|
||||
its latest attempt completed the release contract, build, Apple signature,
|
||||
immutable GitHub publication, and npm delivery checks for the exact tagged
|
||||
commit. It then downloads and re-verifies the immutable assets before invoking
|
||||
only the selected mirror. An OSS repair requires the OSS step itself to be the
|
||||
recorded failure. A Gitee repair accepts either a failed Gitee job or a Gitee
|
||||
job that was skipped behind that OSS failure; the latter is an explicit Gitee
|
||||
backfill and does not claim that OSS has been repaired. Gitee repair requires
|
||||
`GITEE_TOKEN`, `GITEE_USER`, and `GITEE_REPO`; OSS repair requires
|
||||
`OSS_ACCESS_KEY_ID`, `OSS_ACCESS_KEY_SECRET`, `OSS_ENDPOINT`, and `OSS_BUCKET`
|
||||
(with optional `OSS_PREFIX`) as Actions secrets. Missing credentials fail the
|
||||
selected repair closed.
|
||||
Cloud-sealed releases mirror to OSS only when the repository variable
|
||||
`ENABLE_OSS_MIRROR` is exactly `true`. Leave the variable unset while no Bucket
|
||||
is provisioned; GitHub, npm, and Homebrew delivery can then complete without
|
||||
running the OSS step. Once enabled, missing credentials, an invalid Bucket, or
|
||||
an upload failure remains fail-closed. The cloud tag immutably records the
|
||||
decision as `OSS-Mirror: enabled|deferred`; publication and withdrawal consume
|
||||
that sealed value instead of the variable's later state. Deferred releases
|
||||
cannot use `repair_oss_version`; enabling OSS applies to later release tags
|
||||
until an audited immutable repair marker is implemented.
|
||||
|
||||
If an immutable GitHub Release and npm package were delivered but an enabled
|
||||
downstream China mirror failed, dispatch the normal `Release` workflow from the
|
||||
protected default branch with exactly one of `repair_gitee_version` or
|
||||
`repair_oss_version`. Channel repair accepts a fully successful exact release,
|
||||
or a failed exact-tag run only when its latest attempt completed the release
|
||||
contract, build, Apple signature, immutable GitHub publication, and npm
|
||||
delivery checks for the exact tagged commit. OSS repair additionally requires
|
||||
the tag's sealed policy to be `enabled`. It then downloads and re-verifies the
|
||||
immutable assets before invoking only the selected mirror. For a failed
|
||||
release, an OSS repair requires the OSS step itself to be the recorded failure.
|
||||
A Gitee repair accepts either a failed Gitee job or a Gitee job that was
|
||||
skipped behind that OSS failure; the latter is an explicit Gitee backfill and
|
||||
does not claim that OSS has been repaired. Gitee repair requires `GITEE_TOKEN`,
|
||||
`GITEE_USER`, and `GITEE_REPO`; OSS repair requires `OSS_ACCESS_KEY_ID`,
|
||||
`OSS_ACCESS_KEY_SECRET`, `OSS_ENDPOINT`, and `OSS_BUCKET` (with optional
|
||||
`OSS_PREFIX`) as Actions secrets. Missing credentials fail the selected repair
|
||||
closed.
|
||||
|
||||
## Handoff Checklist
|
||||
|
||||
|
||||
+11
-9
@@ -13,7 +13,9 @@
|
||||
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
|
||||
4. 再次运行,改为 `release_operation=publish`,并输入 `PUBLISH beta` 或 `PUBLISH stable`。
|
||||
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm/OSS/Gitee 发布和 Homebrew PR DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew PR DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
|
||||
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
|
||||
|
||||
## 自动版本规则
|
||||
|
||||
@@ -35,8 +37,8 @@
|
||||
|
||||
1. 先创建永久 annotated tag `withdrawn/<version>`,记录原 tag object、commit、原因、申请人和 workflow run。这个墓碑是版本号永久占用记录,永不移动、永不删除。
|
||||
2. 先验证 Homebrew Formula;若它仍指向问题版本,先创建回退 PR,再继续其他渠道撤回。这样 PR 创建失败时只留下可安全续跑的墓碑,不会先造成渠道分裂。若 Formula 尚未指向问题版本或已经处于安全版本,则直接校验。
|
||||
3. GitHub Release 先标记为 withdrawn;npm 精确版本执行 `deprecate`,并把 `latest` / `beta` dist-tag 回退;OSS 先补齐回退版本资产,再移动 `latest.txt` / `beta.txt` 并删除问题版本目录;启用 Gitee 时同样先补齐回退 Release,再删除问题 Release 和 tag。
|
||||
4. npm、OSS 和 Gitee 均已验证安全后,删除 GitHub 上的问题 Release 和原 `v...` tag,并验证 `/releases/latest` 对正式版回到安全版本。若本次创建了 Homebrew PR,run 最后故意保持失败,直到另一名维护者审核合入;合入后,从新的 `main` 使用完全相同的 version、reason 和 confirmation 重跑并完成。永久 `withdrawn/v...` 墓碑始终保留。
|
||||
3. GitHub Release 先标记为 withdrawn;npm 精确版本执行 `deprecate`,并把 `latest` / `beta` dist-tag 回退;只有目标 tag 封存了 `OSS-Mirror: enabled` 时,OSS 才会先补齐回退版本资产,再移动 `latest.txt` / `beta.txt` 并删除问题版本目录;启用 Gitee 时同样先补齐回退 Release,再删除问题 Release 和 tag。
|
||||
4. npm 以及目标 tag 启用或发布时配置的镜像渠道均已验证安全后,删除 GitHub 上的问题 Release 和原 `v...` tag,并验证 `/releases/latest` 对正式版回到安全版本。若本次创建了 Homebrew PR,run 最后故意保持失败,直到另一名维护者审核合入;合入后,从新的 `main` 使用完全相同的 version、reason 和 confirmation 重跑并完成。永久 `withdrawn/v...` 墓碑始终保留。
|
||||
|
||||
GitHub、npm、OSS、Gitee 和 Homebrew 的“回滚”指新的安装、升级和渠道解析不再拿到问题版本。已经装到用户电脑上的二进制无法被服务端强制降级;用户必须重新安装回退版本、安装后续修复版,或使用 CLI 自带的本地 rollback 能力。npm 不执行 `unpublish`:问题版本保留明确的弃用警告,但 `latest` / `beta` 不再指向它;即使 registry 允许删除,已发布过的版本号也不会重新使用。
|
||||
|
||||
@@ -133,15 +135,15 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
||||
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
||||
- stable 发布到 npm `latest`,更新 OSS `latest.txt` 和共享安装脚本;prerelease 发布到 npm `beta`,只更新 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;版本规划、云端封板、发布、恢复、修复和撤回共享同一发布锁。
|
||||
- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时走受保护恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
|
||||
|
||||
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
|
||||
|
||||
OSS/Gitee 分发失败且 GitHub immutable Release、npm 已交付时,从受保护的默认分支触发
|
||||
已启用的 OSS 或 Gitee 分发失败且 GitHub immutable Release、npm 已交付时,从受保护的默认分支触发
|
||||
Release workflow,并且只填写 `repair_oss_version` 或 `repair_gitee_version` 之一。channel
|
||||
repair 会精确绑定失败 tag run 的最新 attempt;contract、构建、Developer ID 签名、
|
||||
repair 会精确绑定失败 tag run 的最新 attempt,且 OSS repair 要求 tag 的 sealed policy 为 `enabled`;contract、构建、Developer ID 签名、
|
||||
immutable GitHub 发布和 npm delivery 必须全部成功,且只能有一个 OSS/Gitee 下游失败,
|
||||
随后才会下载并重新校验原始资产、修复所选镜像。OSS repair 必须匹配失败的 OSS step;
|
||||
Gitee repair 还允许其 job 因该 OSS 失败而 skipped,此时只代表 Gitee backfill 成功,
|
||||
@@ -163,14 +165,14 @@ dws-release recover v1.2.3-beta.1
|
||||
- 输入精确绑定原 annotated tag object、commit 和失败的 sealed `Release` run;云端 run 还必须与 tag 内的 run ID、attempt、requester 完全一致,commit 必须仍在 `main` 历史中。
|
||||
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本不能全量重建:单个下游故障走对应的 channel repair,版本本身有问题则走受保护的全平台 withdrawal。
|
||||
- `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。
|
||||
- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm 和 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。
|
||||
- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm,以及已启用的 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。
|
||||
- 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。
|
||||
|
||||
成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。
|
||||
|
||||
云端 seal 后不要使用 GitHub 的 “Re-run failed jobs” 作为交付修复:annotated tag 永久绑定最初的 run attempt,普通 rerun 不会成为可接受的交付证据。GitHub Release 尚未公开时走上述 protected recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
|
||||
|
||||
OSS 的 `latest.txt` / `beta.txt` 是镜像频道元数据;当前仓库安装器仍主要从 GitHub/Gitee 解析版本。发布和撤回仍把 OSS 作为受控分发渠道处理,保证一旦外部消费者接入该 pointer,也不会继续解析到已撤回版本。
|
||||
OSS 的 `latest.txt` / `beta.txt` 是镜像频道元数据;当前仓库安装器仍主要从 GitHub/Gitee 解析版本。启用 OSS 后,发布和撤回把它作为受控分发渠道处理,保证一旦外部消费者接入该 pointer,也不会继续解析到已撤回版本;未启用时两条流程都明确跳过不存在的 OSS 渠道。
|
||||
|
||||
Release workflow 会生成 Darwin/Linux 双架构 Formula,并分别为 stable/beta 打开 Homebrew PR;tap 的默认分支仍以独立审核合入为交付边界。撤回 workflow 使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
|
||||
|
||||
@@ -183,7 +185,7 @@ Release workflow 会生成 Darwin/Linux 双架构 Formula,并分别为 stable/
|
||||
- tag ruleset 还必须覆盖 `withdrawn/v*`:只允许受保护的撤回 workflow 创建墓碑,禁止更新或删除墓碑;同时应允许 Release workflow 创建新的 `v*`,允许撤回 workflow 在全部渠道回退后删除精确的问题 `v*`。若组织级规则阻止这两个 workflow 的预期动作,发布或撤回会 fail closed,不能靠手工移动 tag 绕过。
|
||||
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
|
||||
- 配置 `APPLE_CERTIFICATE_P12_BASE64`、`APPLE_CERTIFICATE_PASSWORD` 和具备发布权限的 `NPM_TOKEN`;撤回还要求该 npm 身份能够执行 `deprecate` 和修改 dist-tag。
|
||||
- 配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。
|
||||
- 启用 OSS 镜像时,先创建有效 Bucket,再设置仓库变量 `ENABLE_OSS_MIRROR=true`,并配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。启用后发布保持 fail-closed;撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。尚未 provision Bucket 时保持该变量未设置或不等于 `true`,新 tag 会封存 `OSS-Mirror: deferred` 并跳过 OSS;该版本不能通过现有 repair 流程事后改成启用。
|
||||
- 若启用 Gitee fallback,设置 `ENABLE_GITEE_UPLOAD_FALLBACK=true`,并配置 `GITEE_TOKEN`、`GITEE_USER`、`GITEE_REPO`;该身份必须能够创建和删除目标仓库的 Release 与 tag。
|
||||
- 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。
|
||||
- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。
|
||||
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
TAG="${1:-}"
|
||||
[ -n "$TAG" ] || {
|
||||
printf 'usage: release-tag-oss-mode.sh <tag>\n' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
tag_ref="refs/tags/$TAG"
|
||||
git rev-parse --verify --quiet "$tag_ref" >/dev/null || {
|
||||
printf 'release tag is not available: %s\n' "$TAG" >&2
|
||||
exit 1
|
||||
}
|
||||
tag_object="$(git rev-parse "$tag_ref")"
|
||||
[ "$(git cat-file -t "$tag_object")" = tag ] || {
|
||||
printf 'release tag must be annotated: %s\n' "$TAG" >&2
|
||||
exit 1
|
||||
}
|
||||
tag_contents="$(git cat-file tag "$tag_object")" || {
|
||||
printf 'release tag could not be read: %s\n' "$TAG" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mode="$(
|
||||
printf '%s\n' "$tag_contents" |
|
||||
awk '
|
||||
{
|
||||
line = $0
|
||||
sub(/\r$/, "", line)
|
||||
}
|
||||
line ~ /^OSS-Mirror: / {
|
||||
count++
|
||||
value = substr(line, length("OSS-Mirror: ") + 1)
|
||||
}
|
||||
END {
|
||||
if (count > 1) exit 2
|
||||
if (count == 0) print "enabled"
|
||||
else print value
|
||||
}
|
||||
'
|
||||
)" || {
|
||||
printf 'release tag contains duplicate OSS-Mirror metadata: %s\n' "$TAG" >&2
|
||||
exit 1
|
||||
}
|
||||
case "$mode" in
|
||||
enabled|deferred) printf '%s\n' "$mode" ;;
|
||||
*)
|
||||
printf 'release tag contains invalid OSS-Mirror metadata: %s (%s)\n' "$TAG" "$mode" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -24,6 +24,7 @@ TOMBSTONE="withdrawn/${VERSION}"
|
||||
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
|
||||
GITEE_REPO="${GITEE_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
||||
OSS_PREFIX="${OSS_PREFIX:-dws}"
|
||||
OSS_ENABLED=""
|
||||
DELIVERY_VERIFIER="${DWS_DELIVERY_VERIFIER:-$SCRIPT_DIR/verify-release-workflow-delivery.sh}"
|
||||
STABLE_DELIVERY_VERIFIER="${DWS_STABLE_DELIVERY_VERIFIER:-$SCRIPT_DIR/verify-delivered-stable.sh}"
|
||||
GITHUB_DOWNLOAD_HELPER="${DWS_GITHUB_DOWNLOAD_HELPER:-$SCRIPT_DIR/download-github-release-assets.sh}"
|
||||
@@ -404,6 +405,7 @@ prepare_tombstone_message() {
|
||||
printf 'Original-Commit: %s\n' "$TARGET_COMMIT"
|
||||
printf 'Original-Release-ID: %s\n' "$TARGET_RELEASE_ID"
|
||||
printf 'Channel: %s\n' "$CHANNEL"
|
||||
printf 'OSS-Mirror: %s\n' "$TARGET_OSS_MODE"
|
||||
printf 'Reason-SHA256: %s\n' "$reason_sha"
|
||||
printf 'Reason: %s\n' "$REASON"
|
||||
printf 'Requested-By: %s\n' "$GITHUB_ACTOR"
|
||||
@@ -414,7 +416,7 @@ prepare_tombstone_message() {
|
||||
verify_existing_tombstone() {
|
||||
local reason_sha="$1" ref_sha tombstone_json parsed status
|
||||
local actual_target original_tag_object original_commit original_release_id
|
||||
local tombstone_channel tombstone_version tombstone_reason_sha tombstone_reason
|
||||
local tombstone_channel tombstone_version tombstone_oss_mode tombstone_reason_sha tombstone_reason
|
||||
set +e
|
||||
ref_sha="$(
|
||||
github_optional \
|
||||
@@ -478,6 +480,9 @@ if not re.fullmatch(r"[1-9][0-9]*", fields["Original-Release-ID"]):
|
||||
raise SystemExit(1)
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", fields["Reason-SHA256"]):
|
||||
raise SystemExit(1)
|
||||
oss_mode = fields.get("OSS-Mirror", "enabled")
|
||||
if oss_mode not in {"enabled", "deferred"}:
|
||||
raise SystemExit(1)
|
||||
print("\t".join((
|
||||
obj["sha"],
|
||||
fields["Original-Tag-Object"],
|
||||
@@ -485,6 +490,7 @@ print("\t".join((
|
||||
fields["Original-Release-ID"],
|
||||
fields["Channel"],
|
||||
fields["Version"],
|
||||
oss_mode,
|
||||
fields["Reason-SHA256"],
|
||||
fields["Reason"],
|
||||
)))' "$TOMBSTONE"
|
||||
@@ -495,11 +501,13 @@ print("\t".join((
|
||||
original_release_id="$(printf '%s\n' "$parsed" | cut -f4)"
|
||||
tombstone_channel="$(printf '%s\n' "$parsed" | cut -f5)"
|
||||
tombstone_version="$(printf '%s\n' "$parsed" | cut -f6)"
|
||||
tombstone_reason_sha="$(printf '%s\n' "$parsed" | cut -f7)"
|
||||
tombstone_reason="$(printf '%s\n' "$parsed" | cut -f8-)"
|
||||
tombstone_oss_mode="$(printf '%s\n' "$parsed" | cut -f7)"
|
||||
tombstone_reason_sha="$(printf '%s\n' "$parsed" | cut -f8)"
|
||||
tombstone_reason="$(printf '%s\n' "$parsed" | cut -f9-)"
|
||||
[ "$actual_target" = "$original_commit" ] &&
|
||||
[ "$tombstone_version" = "$VERSION" ] &&
|
||||
[ "$tombstone_channel" = "$CHANNEL" ] &&
|
||||
[ "$tombstone_oss_mode" = "${TARGET_OSS_MODE:-$tombstone_oss_mode}" ] &&
|
||||
[ "$tombstone_reason_sha" = "$reason_sha" ] &&
|
||||
[ "$tombstone_reason" = "$REASON" ] ||
|
||||
err "existing tombstone $TOMBSTONE has different immutable withdrawal metadata"
|
||||
@@ -515,6 +523,7 @@ print("\t".join((
|
||||
TARGET_TAG_OBJECT="$original_tag_object"
|
||||
TARGET_COMMIT="$original_commit"
|
||||
TARGET_RELEASE_ID="$original_release_id"
|
||||
TARGET_OSS_MODE="$tombstone_oss_mode"
|
||||
}
|
||||
|
||||
create_tombstone() {
|
||||
@@ -1000,14 +1009,6 @@ for command in git gh npm curl python3 awk sed grep sort unzip ruby cmp cut; do
|
||||
done
|
||||
need_env GITHUB_TOKEN "${GITHUB_TOKEN:-}"
|
||||
need_env NODE_AUTH_TOKEN "${NODE_AUTH_TOKEN:-}"
|
||||
need_env OSS_ACCESS_KEY_ID "${OSS_ACCESS_KEY_ID:-}"
|
||||
need_env OSS_ACCESS_KEY_SECRET "${OSS_ACCESS_KEY_SECRET:-}"
|
||||
need_env OSS_ENDPOINT "${OSS_ENDPOINT:-}"
|
||||
need_env OSS_BUCKET "${OSS_BUCKET:-}"
|
||||
case "$OSS_PREFIX" in
|
||||
''|/*|*'..'*|*'//'*) err "OSS_PREFIX must be a non-empty safe relative prefix" ;;
|
||||
*[!A-Za-z0-9._/-]*) err "OSS_PREFIX contains unsupported characters" ;;
|
||||
esac
|
||||
|
||||
validate_context
|
||||
git fetch --force --tags origin
|
||||
@@ -1018,6 +1019,7 @@ TARGET_COMMIT=""
|
||||
TARGET_RELEASE_ID=""
|
||||
TARGET_TAG_EXISTS=false
|
||||
TARGET_RELEASE_EXISTS=false
|
||||
TARGET_OSS_MODE=""
|
||||
|
||||
set +e
|
||||
remote_tag_object="$(
|
||||
@@ -1038,6 +1040,8 @@ case "$remote_tag_status" in
|
||||
err "$VERSION must be an annotated GitHub release tag"
|
||||
printf '%s\n' "$TARGET_COMMIT" | grep -Eq '^[0-9a-f]{40}$' ||
|
||||
err "could not resolve the release commit for $VERSION"
|
||||
TARGET_OSS_MODE="$("$SCRIPT_DIR/release-tag-oss-mode.sh" "$VERSION")" ||
|
||||
err "could not resolve immutable OSS policy for $VERSION"
|
||||
;;
|
||||
4) ;;
|
||||
*) err "could not inspect the authoritative GitHub tag $VERSION" ;;
|
||||
@@ -1084,6 +1088,22 @@ if [ "$TARGET_TAG_EXISTS" != true ] || [ "$TARGET_RELEASE_EXISTS" != true ]; the
|
||||
say "Resuming withdrawal for $VERSION from exact permanent tombstone metadata."
|
||||
fi
|
||||
|
||||
case "$TARGET_OSS_MODE" in
|
||||
enabled)
|
||||
OSS_ENABLED=true
|
||||
need_env OSS_ACCESS_KEY_ID "${OSS_ACCESS_KEY_ID:-}"
|
||||
need_env OSS_ACCESS_KEY_SECRET "${OSS_ACCESS_KEY_SECRET:-}"
|
||||
need_env OSS_ENDPOINT "${OSS_ENDPOINT:-}"
|
||||
need_env OSS_BUCKET "${OSS_BUCKET:-}"
|
||||
case "$OSS_PREFIX" in
|
||||
''|/*|*'..'*|*'//'*) err "OSS_PREFIX must be a non-empty safe relative prefix" ;;
|
||||
*[!A-Za-z0-9._/-]*) err "OSS_PREFIX contains unsupported characters" ;;
|
||||
esac
|
||||
;;
|
||||
deferred) OSS_ENABLED=false ;;
|
||||
*) err "could not resolve immutable OSS policy for $VERSION" ;;
|
||||
esac
|
||||
|
||||
[ "$(npm_exact_version "$VERSION" || true)" = "${VERSION#v}" ] ||
|
||||
err "npm exact version ${PACKAGE_NAME}@${VERSION#v} is not published"
|
||||
if [ "$TARGET_TAG_EXISTS" = true ] && [ "$TARGET_RELEASE_EXISTS" = true ]; then
|
||||
@@ -1109,23 +1129,28 @@ NPM_POINTER_BEFORE="$(
|
||||
[ -n "$NPM_POINTER_BEFORE" ] || err "npm $NPM_TAG is empty"
|
||||
require_safe_channel_pointer "v$NPM_POINTER_BEFORE" "$CHANNEL"
|
||||
|
||||
ensure_ossutil
|
||||
if [ -z "${OSS_REGION:-}" ]; then
|
||||
OSS_REGION="$(
|
||||
printf '%s' "$OSS_ENDPOINT" |
|
||||
sed -n 's#^\(https\{0,1\}://\)\{0,1\}oss-\([a-z0-9-]*[a-z0-9]\)\.aliyuncs\.com.*#\2#p' |
|
||||
sed 's#-internal$##'
|
||||
)"
|
||||
fi
|
||||
[ -n "$OSS_REGION" ] || err "could not derive OSS_REGION; set it explicitly"
|
||||
export OSS_REGION
|
||||
if [ "$CHANNEL" = stable ]; then
|
||||
OSS_POINTER_NAME=latest.txt
|
||||
if [ "$OSS_ENABLED" = true ]; then
|
||||
ensure_ossutil
|
||||
if [ -z "${OSS_REGION:-}" ]; then
|
||||
OSS_REGION="$(
|
||||
printf '%s' "$OSS_ENDPOINT" |
|
||||
sed -n 's#^\(https\{0,1\}://\)\{0,1\}oss-\([a-z0-9-]*[a-z0-9]\)\.aliyuncs\.com.*#\2#p' |
|
||||
sed 's#-internal$##'
|
||||
)"
|
||||
fi
|
||||
[ -n "$OSS_REGION" ] || err "could not derive OSS_REGION; set it explicitly"
|
||||
export OSS_REGION
|
||||
if [ "$CHANNEL" = stable ]; then
|
||||
OSS_POINTER_NAME=latest.txt
|
||||
else
|
||||
OSS_POINTER_NAME=beta.txt
|
||||
fi
|
||||
OSS_POINTER_BEFORE="$(read_oss_pointer "$OSS_POINTER_NAME")"
|
||||
require_safe_channel_pointer "$OSS_POINTER_BEFORE" "$CHANNEL"
|
||||
else
|
||||
OSS_POINTER_NAME=beta.txt
|
||||
OSS_POINTER_NAME=""
|
||||
say "OSS mirroring is disabled; no OSS rollback is required."
|
||||
fi
|
||||
OSS_POINTER_BEFORE="$(read_oss_pointer "$OSS_POINTER_NAME")"
|
||||
require_safe_channel_pointer "$OSS_POINTER_BEFORE" "$CHANNEL"
|
||||
|
||||
GITEE_ENABLED="${DWS_GITEE_ENABLED:-false}"
|
||||
case "$GITEE_ENABLED" in
|
||||
@@ -1168,8 +1193,12 @@ create_tombstone
|
||||
update_homebrew
|
||||
update_github_release
|
||||
update_npm_channel
|
||||
ensure_oss_rollback
|
||||
update_oss_channel "$OSS_POINTER_NAME"
|
||||
if [ "$OSS_ENABLED" = true ]; then
|
||||
ensure_oss_rollback
|
||||
update_oss_channel "$OSS_POINTER_NAME"
|
||||
else
|
||||
say "OSS did not contain $VERSION because mirroring was disabled; no mirror mutation was needed."
|
||||
fi
|
||||
if [ "$GITEE_ENABLED" = true ]; then
|
||||
ensure_gitee_rollback
|
||||
withdraw_gitee
|
||||
|
||||
@@ -884,6 +884,76 @@ func TestReleaseWorkflowPlansAndSealsCurrentMainInTheCloud(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowAcceptsGuardedLocalTagMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
|
||||
for _, required := range []string{
|
||||
`const cloudOnlyKeys = [`,
|
||||
`const cloudKeys = ["Channel", ...cloudOnlyKeys];`,
|
||||
`const hasAnyCloudMetadata = cloudOnlyKeys.some((key) => tagFields.has(key));`,
|
||||
`const isCloudSeal = cloudKeys.every((key) => tagFields.has(key));`,
|
||||
} {
|
||||
if !strings.Contains(workflow, required) {
|
||||
t.Errorf("local tag metadata compatibility is missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(workflow, `const hasAnyCloudMetadata = cloudKeys.some((key) => tagFields.has(key));`) {
|
||||
t.Error("Channel-only guarded local tags must not be classified as partial cloud seals")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowRequiresOSSOnlyWhenMirrorIsEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
releaseContract := releaseWorkflowSection(t, workflow, " release-contract:\n", "\n release:\n")
|
||||
targetAuthority := releaseWorkflowSection(
|
||||
t,
|
||||
releaseContract,
|
||||
" - name: Resolve and verify exact release target\n",
|
||||
"\n - name: Check out repository\n",
|
||||
)
|
||||
ossStep := releaseWorkflowSection(
|
||||
t,
|
||||
workflow,
|
||||
" - name: Sync release artifacts to China OSS mirror\n",
|
||||
"\n mirror-gitee-release:\n",
|
||||
)
|
||||
|
||||
for _, required := range []string{
|
||||
`if: ${{ needs.release-contract.outputs.oss_mirror == 'enabled' }}`,
|
||||
`run: ./scripts/release/sync-to-oss.sh`,
|
||||
`DWS_REQUIRE_OSS: "1"`,
|
||||
} {
|
||||
if !strings.Contains(ossStep, required) {
|
||||
t.Errorf("opt-in OSS publication is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{
|
||||
`OSS_MIRROR: ${{ vars.ENABLE_OSS_MIRROR == 'true' && 'enabled' || 'deferred' }}`,
|
||||
`OSS-Mirror: ${ossMirror}`,
|
||||
`core.setOutput("oss_mirror", ossMirror);`,
|
||||
} {
|
||||
if !strings.Contains(workflow, required) {
|
||||
t.Errorf("immutable OSS release policy is missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(ossStep, "vars.ENABLE_OSS_MIRROR") {
|
||||
t.Error("channel publication must use the immutable tag policy, not the current repository variable")
|
||||
}
|
||||
for _, required := range []string{
|
||||
`const ossMirror = tagFields.has("OSS-Mirror")`,
|
||||
`? tagFields.get("OSS-Mirror")`,
|
||||
`: "enabled";`,
|
||||
`!["enabled", "deferred"].includes(ossMirror)`,
|
||||
`core.setOutput("oss_mirror", ossMirror);`,
|
||||
} {
|
||||
if !strings.Contains(targetAuthority, required) {
|
||||
t.Errorf("release target OSS policy authority is missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
@@ -897,6 +967,12 @@ func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
|
||||
t.Fatal("release workflow channel repair job is missing its end marker")
|
||||
}
|
||||
repair := workflow[start : start+end]
|
||||
authority := releaseWorkflowSection(
|
||||
t,
|
||||
repair,
|
||||
" - name: Verify immutable release authority\n",
|
||||
"\n - name: Require sealed OSS policy for repair\n",
|
||||
)
|
||||
tagAuthority := releaseWorkflowSection(
|
||||
t,
|
||||
repair,
|
||||
@@ -945,6 +1021,8 @@ func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
|
||||
"assetNames.length !== expectedAssets.size",
|
||||
"new Set(assetNames).size !== expectedAssets.size",
|
||||
`core.setOutput("tag_object", ref.data.object.sha)`,
|
||||
"Require sealed OSS policy for repair",
|
||||
"OSS repair is unavailable because this immutable release deferred the OSS channel.",
|
||||
`ref: ${{ steps.authority.outputs.commit_sha }}`,
|
||||
"path: release-source",
|
||||
"verify-github-tag-authority.sh",
|
||||
@@ -975,6 +1053,25 @@ func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
|
||||
t.Errorf("channel repair authority is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{
|
||||
`const tagFields = new Map();`,
|
||||
`const ossMirror = tagFields.has("OSS-Mirror")`,
|
||||
`? tagFields.get("OSS-Mirror")`,
|
||||
`: "enabled";`,
|
||||
`!["enabled", "deferred"].includes(ossMirror)`,
|
||||
`core.setOutput("oss_mirror", ossMirror);`,
|
||||
} {
|
||||
if !strings.Contains(authority, required) {
|
||||
t.Errorf("channel repair tag policy authority is missing %q", required)
|
||||
}
|
||||
}
|
||||
npmRepair := releaseWorkflowSection(t, workflow, " repair-npm:\n", "\n release-delivery-gate:\n")
|
||||
if strings.Contains(npmRepair, `const ossMirror`) || strings.Contains(npmRepair, `core.setOutput("oss_mirror"`) {
|
||||
t.Error("npm repair must not parse or export the channel-only OSS policy")
|
||||
}
|
||||
if strings.Contains(repair, "ENABLE_OSS_MIRROR") {
|
||||
t.Error("OSS repair must use the immutable tag policy, not the current repository variable")
|
||||
}
|
||||
for _, asset := range []string{
|
||||
"dws-darwin-amd64.tar.gz",
|
||||
"dws-darwin-arm64.tar.gz",
|
||||
|
||||
@@ -943,6 +943,11 @@ esac
|
||||
if output, err := run(); err != nil || !strings.Contains(output, "cloud release run 42") {
|
||||
t.Fatalf("tag-bound cloud delivery was rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if output, err := runWithArgs(
|
||||
[]string{"--channel-repair", "oss", tag, commit},
|
||||
); err != nil || !strings.Contains(output, "cloud release run 42") {
|
||||
t.Fatalf("OSS repair could not use a successful release with the mirror enabled: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if output, err := run("RUN_ACTOR=renamed-release-user"); err != nil ||
|
||||
!strings.Contains(output, "cloud release run 42") {
|
||||
t.Fatalf("cloud delivery broke after a harmless login rename: err=%v\noutput:\n%s", err, output)
|
||||
|
||||
@@ -82,6 +82,11 @@ func TestWithdrawReleaseScriptDeletesProblemReleaseLastAndUsesPermanentTombstone
|
||||
`github_api --method PATCH`,
|
||||
`npm deprecate "${PACKAGE_NAME}@${VERSION#v}"`,
|
||||
`npm dist-tag add "${PACKAGE_NAME}@${ROLLBACK_VERSION#v}"`,
|
||||
`TARGET_OSS_MODE="$("$SCRIPT_DIR/release-tag-oss-mode.sh" "$VERSION")"`,
|
||||
`printf 'OSS-Mirror: %s\n' "$TARGET_OSS_MODE"`,
|
||||
`oss_mode = fields.get("OSS-Mirror", "enabled")`,
|
||||
`if [ "$OSS_ENABLED" = true ]; then`,
|
||||
`*) err "could not resolve immutable OSS policy for $VERSION" ;;`,
|
||||
`"$OSSUTIL" rm -rf`,
|
||||
`curl -fsS -X DELETE`,
|
||||
`git push "$GITEE_GIT_REMOTE" ":refs/tags/${VERSION}"`,
|
||||
@@ -114,7 +119,7 @@ func TestWithdrawReleaseScriptDeletesProblemReleaseLastAndUsesPermanentTombstone
|
||||
tombstone := strings.LastIndex(script, "\ncreate_tombstone\n")
|
||||
githubMutation := strings.LastIndex(script, "\nupdate_github_release\n")
|
||||
npmMutation := strings.LastIndex(script, "\nupdate_npm_channel\n")
|
||||
ossMutation := strings.LastIndex(script, "\nupdate_oss_channel ")
|
||||
ossMutation := strings.LastIndex(script, `update_oss_channel "$OSS_POINTER_NAME"`)
|
||||
homebrewGate := strings.LastIndex(script, "\nupdate_homebrew\n")
|
||||
githubDelete := strings.LastIndex(script, "\ndelete_github_release_and_tag\n")
|
||||
if tombstone < 0 || githubMutation < 0 || npmMutation < 0 || ossMutation < 0 ||
|
||||
@@ -128,6 +133,122 @@ func TestWithdrawReleaseScriptDeletesProblemReleaseLastAndUsesPermanentTombstone
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseTagOSSModeIsImmutableAndFailClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "release-tag-oss-mode.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(OSS mode script) error = %v", err)
|
||||
}
|
||||
repo := t.TempDir()
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
mustRun(t, repo, "git", "config", "user.name", "OSS Mode Test")
|
||||
mustRun(t, repo, "git", "config", "user.email", "oss-mode@example.com")
|
||||
mustWriteFile(t, filepath.Join(repo, "tracked"), []byte("fixture\n"), 0o644)
|
||||
mustRun(t, repo, "git", "add", "tracked")
|
||||
mustRun(t, repo, "git", "commit", "-m", "fixture")
|
||||
|
||||
tag := func(version, message string) {
|
||||
t.Helper()
|
||||
messagePath := filepath.Join(repo, version+".message")
|
||||
mustWriteFile(t, messagePath, []byte(message), 0o644)
|
||||
mustRun(t, repo, "git", "tag", "-a", version, "-F", messagePath)
|
||||
}
|
||||
tag("v1.0.1", "Release v1.0.1\n")
|
||||
tag("v1.0.2", "Release v1.0.2\n\nOSS-Mirror: enabled\n")
|
||||
tag("v1.0.3", "Release v1.0.3\n\nOSS-Mirror: deferred\n")
|
||||
tag("v1.0.4", "Release v1.0.4\n\nOSS-Mirror: invalid\n")
|
||||
tag("v1.0.5", "Release v1.0.5\n\nOSS-Mirror: enabled\nOSS-Mirror: deferred\n")
|
||||
mustRun(t, repo, "git", "tag", "v1.0.6")
|
||||
rawTag := func(version, message string) {
|
||||
t.Helper()
|
||||
commit := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD"))
|
||||
payload := strings.Join([]string{
|
||||
"object " + commit,
|
||||
"type commit",
|
||||
"tag " + version,
|
||||
"tagger OSS Mode Test <oss-mode@example.com> 1700000000 +0000",
|
||||
"",
|
||||
message,
|
||||
}, "\n")
|
||||
cmd := exec.Command("git", "mktag")
|
||||
cmd.Dir = repo
|
||||
cmd.Stdin = strings.NewReader(payload)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git mktag %s error = %v\noutput:\n%s", version, err, output)
|
||||
}
|
||||
mustRun(t, repo, "git", "update-ref", "refs/tags/"+version, strings.TrimSpace(string(output)))
|
||||
}
|
||||
rawTag("v1.0.7", "Release v1.0.7\n\nOSS-Mirror: \n")
|
||||
rawTag("v1.0.8", "Release v1.0.8\r\n\r\nOSS-Mirror: deferred\r\n")
|
||||
|
||||
for _, test := range []struct {
|
||||
version string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{version: "v1.0.1", want: "enabled", ok: true},
|
||||
{version: "v1.0.2", want: "enabled", ok: true},
|
||||
{version: "v1.0.3", want: "deferred", ok: true},
|
||||
{version: "v1.0.4", want: "invalid OSS-Mirror metadata", ok: false},
|
||||
{version: "v1.0.5", want: "duplicate OSS-Mirror metadata", ok: false},
|
||||
{version: "v1.0.6", want: "must be annotated", ok: false},
|
||||
{version: "v1.0.7", want: "invalid OSS-Mirror metadata", ok: false},
|
||||
{version: "v1.0.8", want: "deferred", ok: true},
|
||||
} {
|
||||
t.Run(test.version, func(t *testing.T) {
|
||||
cmd := exec.Command(scriptPath, test.version)
|
||||
cmd.Dir = repo
|
||||
output, err := cmd.CombinedOutput()
|
||||
if test.ok && err != nil {
|
||||
t.Fatalf("release-tag-oss-mode.sh error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !test.ok && err == nil {
|
||||
t.Fatalf("release-tag-oss-mode.sh unexpectedly accepted %s", test.version)
|
||||
}
|
||||
if !strings.Contains(string(output), test.want) {
|
||||
t.Fatalf("release-tag-oss-mode.sh output missing %q:\n%s", test.want, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawReleaseDefersOSSRequirementUntilImmutablePolicyResolution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "withdraw-release.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(withdraw script) error = %v", err)
|
||||
}
|
||||
cmd := exec.Command("bash", scriptPath,
|
||||
"v1.2.3",
|
||||
"critical startup regression",
|
||||
"WITHDRAW v1.2.3",
|
||||
)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GITHUB_TOKEN=test-github-token",
|
||||
"NODE_AUTH_TOKEN=test-npm-token",
|
||||
"GITHUB_REPOSITORY=DingTalk-Real-AI/dingtalk-workspace-cli",
|
||||
"GITHUB_REF_NAME=main",
|
||||
"GITHUB_SHA="+strings.Repeat("a", 40),
|
||||
"GITHUB_RUN_ID=1",
|
||||
"GITHUB_ACTOR=test-operator",
|
||||
"GITHUB_EVENT_DEFAULT_BRANCH=main",
|
||||
"GITHUB_ACTIONS=false",
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatalf("withdraw-release.sh unexpectedly ran outside GitHub Actions:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(string(output), "withdrawal may run only inside GitHub Actions") {
|
||||
t.Fatalf("withdrawal did not reach the protected execution gate before resolving OSS policy:\n%s", output)
|
||||
}
|
||||
if strings.Contains(string(output), "missing required environment variable OSS_") {
|
||||
t.Fatalf("withdrawal required OSS credentials before resolving immutable tag policy:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawReleaseRejectsInvalidInputsBeforeMutation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -180,6 +301,20 @@ func TestWithdrawReleaseRejectsInvalidInputsBeforeMutation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWithdrawReleaseRollsBackConfiguredChannelsAndStopsForHomebrewReview(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ossDeferred bool
|
||||
}{
|
||||
{name: "legacy tag defaults OSS to enabled"},
|
||||
{name: "deferred OSS survives tombstone retry", ossDeferred: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
testWithdrawReleaseRollsBackConfiguredChannelsAndStopsForHomebrewReview(t, test.ossDeferred)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testWithdrawReleaseRollsBackConfiguredChannelsAndStopsForHomebrewReview(t *testing.T, ossDeferred bool) {
|
||||
root := t.TempDir()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
@@ -188,6 +323,7 @@ func TestWithdrawReleaseRollsBackConfiguredChannelsAndStopsForHomebrewReview(t *
|
||||
|
||||
for _, rel := range []string{
|
||||
"scripts/release/withdraw-release.sh",
|
||||
"scripts/release/release-tag-oss-mode.sh",
|
||||
"scripts/release/release-lib.sh",
|
||||
"build/homebrew-release.rb.tmpl",
|
||||
} {
|
||||
@@ -235,6 +371,10 @@ printf 'gitee-sync %s\n' "$VERSION" >> "$CALL_LOG"
|
||||
cp "$DWS_FORMULA_SOURCE" "$MOCK_STATE/homebrew-formula"
|
||||
printf 'homebrew-pr %s %s\n' "$DWS_TAP_PR_TITLE" "$DWS_TAP_PR_BRANCH" >> "$CALL_LOG"
|
||||
exit 0
|
||||
`), 0o755)
|
||||
mustWriteFile(t, filepath.Join(root, "scripts", "release", "unexpected-ossutil"), []byte(`#!/bin/sh
|
||||
printf 'unexpected-oss-call\n' >> "$CALL_LOG"
|
||||
exit 99
|
||||
`), 0o755)
|
||||
|
||||
stateDir := filepath.Join(root, "state")
|
||||
@@ -262,7 +402,11 @@ end
|
||||
`), 0o644)
|
||||
mustRun(t, root, "git", "add", "Formula/dingtalk-workspace-cli.rb")
|
||||
mustRun(t, root, "git", "commit", "-m", "target")
|
||||
mustRun(t, root, "git", "tag", "-a", "v1.0.52", "-m", "Release v1.0.52")
|
||||
tagArgs := []string{"tag", "-a", "v1.0.52", "-m", "Release v1.0.52"}
|
||||
if ossDeferred {
|
||||
tagArgs = append(tagArgs, "-m", "OSS-Mirror: deferred")
|
||||
}
|
||||
mustRun(t, root, "git", tagArgs...)
|
||||
targetCommit := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "HEAD"))
|
||||
targetTagObject := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "refs/tags/v1.0.52"))
|
||||
|
||||
@@ -308,11 +452,6 @@ end
|
||||
"GITHUB_EVENT_DEFAULT_BRANCH=main",
|
||||
"GITHUB_TOKEN=github-token",
|
||||
"NODE_AUTH_TOKEN=npm-token",
|
||||
"OSS_ACCESS_KEY_ID=oss-id",
|
||||
"OSS_ACCESS_KEY_SECRET=oss-secret",
|
||||
"OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com",
|
||||
"OSS_BUCKET=dws-test",
|
||||
"OSSUTIL="+filepath.Join(fakeBin, "ossutil"),
|
||||
"DWS_GITEE_ENABLED=true",
|
||||
"GITEE_TOKEN=gitee-token",
|
||||
"GITEE_USER=gitee-user",
|
||||
@@ -326,6 +465,23 @@ end
|
||||
"DWS_GITEE_SYNC_HELPER="+filepath.Join(root, "scripts", "release", "sync-gitee"),
|
||||
"ORIGIN_GIT="+origin,
|
||||
)
|
||||
if ossDeferred {
|
||||
withdrawEnv = append(withdrawEnv,
|
||||
"OSS_ACCESS_KEY_ID=",
|
||||
"OSS_ACCESS_KEY_SECRET=",
|
||||
"OSS_ENDPOINT=",
|
||||
"OSS_BUCKET=",
|
||||
"OSSUTIL="+filepath.Join(root, "scripts", "release", "unexpected-ossutil"),
|
||||
)
|
||||
} else {
|
||||
withdrawEnv = append(withdrawEnv,
|
||||
"OSS_ACCESS_KEY_ID=oss-id",
|
||||
"OSS_ACCESS_KEY_SECRET=oss-secret",
|
||||
"OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com",
|
||||
"OSS_BUCKET=dws-test",
|
||||
"OSSUTIL="+filepath.Join(fakeBin, "ossutil"),
|
||||
)
|
||||
}
|
||||
cmd.Env = withdrawEnv
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
@@ -338,9 +494,13 @@ end
|
||||
assertFileEquals(t, filepath.Join(stateDir, "latest"), "v1.0.51")
|
||||
assertFileEquals(t, filepath.Join(stateDir, "npm-latest"), "1.0.51")
|
||||
assertFileContains(t, filepath.Join(stateDir, "npm-deprecated"), "WITHDRAWN v1.0.52")
|
||||
assertFileEquals(t, filepath.Join(stateDir, "oss-latest"), "v1.0.51")
|
||||
if _, err := os.Stat(filepath.Join(stateDir, "oss-removed")); err != nil {
|
||||
t.Fatalf("OSS withdrawn prefix was not removed: %v", err)
|
||||
if ossDeferred {
|
||||
assertFileEquals(t, filepath.Join(stateDir, "oss-latest"), "v1.0.52")
|
||||
} else {
|
||||
assertFileEquals(t, filepath.Join(stateDir, "oss-latest"), "v1.0.51")
|
||||
if _, err := os.Stat(filepath.Join(stateDir, "oss-removed")); err != nil {
|
||||
t.Fatalf("OSS withdrawn prefix was not removed: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(stateDir, "gitee-release-v1.0.52")); !os.IsNotExist(err) {
|
||||
t.Fatalf("Gitee release still exists, stat error = %v", err)
|
||||
@@ -357,6 +517,11 @@ end
|
||||
assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Original-Commit: "+targetCommit)
|
||||
assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Original-Tag-Object: "+targetTagObject)
|
||||
assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Original-Release-ID: 52")
|
||||
ossMode := "enabled"
|
||||
if ossDeferred {
|
||||
ossMode = "deferred"
|
||||
}
|
||||
assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "OSS-Mirror: "+ossMode)
|
||||
assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Reason: critical startup regression")
|
||||
assertFileContains(t, callLog, "homebrew-pr revert: withdraw v1.0.52 and restore v1.0.51")
|
||||
|
||||
@@ -369,20 +534,49 @@ end
|
||||
githubIndex := strings.Index(logText, "github-withdraw")
|
||||
npmIndex := strings.Index(logText, "npm-deprecate")
|
||||
ossIndex := strings.Index(logText, "oss-remove")
|
||||
if tombstoneIndex < 0 || githubIndex < 0 || npmIndex < 0 || ossIndex < 0 {
|
||||
if tombstoneIndex < 0 || githubIndex < 0 || npmIndex < 0 || (!ossDeferred && ossIndex < 0) {
|
||||
t.Fatalf("missing mutation audit entries:\n%s", logText)
|
||||
}
|
||||
homebrewIndex := strings.Index(logText, "homebrew-pr")
|
||||
if !(tombstoneIndex < homebrewIndex && homebrewIndex < githubIndex &&
|
||||
githubIndex < npmIndex && npmIndex < ossIndex) {
|
||||
if !(tombstoneIndex < homebrewIndex && homebrewIndex < githubIndex && githubIndex < npmIndex) ||
|
||||
(!ossDeferred && npmIndex >= ossIndex) {
|
||||
t.Fatalf("tombstone was not durable before channel mutations:\n%s", logText)
|
||||
}
|
||||
deleteReleaseIndex := strings.Index(logText, "github-delete-release")
|
||||
deleteTagIndex := strings.Index(logText, "github-delete-tag")
|
||||
channelsCompleteIndex := npmIndex
|
||||
if !ossDeferred {
|
||||
channelsCompleteIndex = ossIndex
|
||||
}
|
||||
if deleteReleaseIndex < 0 || deleteTagIndex < 0 || homebrewIndex < 0 ||
|
||||
!(homebrewIndex < ossIndex && ossIndex < deleteReleaseIndex && deleteReleaseIndex < deleteTagIndex) {
|
||||
!(homebrewIndex < channelsCompleteIndex && channelsCompleteIndex < deleteReleaseIndex && deleteReleaseIndex < deleteTagIndex) {
|
||||
t.Fatalf("GitHub problem release was not removed before the Homebrew review pause:\n%s", logText)
|
||||
}
|
||||
if ossDeferred {
|
||||
if strings.Contains(logText, "unexpected-oss-call") || ossIndex >= 0 {
|
||||
t.Fatalf("deferred withdrawal invoked OSS tooling:\n%s", logText)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"OSS mirroring is disabled; no OSS rollback is required.",
|
||||
"OSS did not contain v1.0.52 because mirroring was disabled",
|
||||
} {
|
||||
if !strings.Contains(string(output), want) {
|
||||
t.Fatalf("deferred withdrawal output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ossDeferred {
|
||||
tombstonePath := filepath.Join(stateDir, "tombstone-message")
|
||||
tombstoneMessage, err := os.ReadFile(tombstonePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(legacy tombstone) error = %v", err)
|
||||
}
|
||||
legacyMessage := strings.Replace(string(tombstoneMessage), "OSS-Mirror: enabled\n", "", 1)
|
||||
if legacyMessage == string(tombstoneMessage) {
|
||||
t.Fatal("could not convert tombstone fixture to the legacy format")
|
||||
}
|
||||
mustWriteFile(t, tombstonePath, []byte(legacyMessage), 0o644)
|
||||
}
|
||||
|
||||
mergedFormula, err := os.ReadFile(filepath.Join(stateDir, "homebrew-formula"))
|
||||
if err != nil {
|
||||
@@ -420,6 +614,9 @@ end
|
||||
t.Fatalf("withdrawal retry output missing %q:\n%s", want, string(retryOutput))
|
||||
}
|
||||
}
|
||||
if ossDeferred && !strings.Contains(string(retryOutput), "OSS mirroring is disabled; no OSS rollback is required.") {
|
||||
t.Fatalf("deferred tombstone retry did not restore the sealed OSS policy:\n%s", retryOutput)
|
||||
}
|
||||
assertFileEquals(t, filepath.Join(stateDir, "latest"), "v1.0.51")
|
||||
if _, err := os.Stat(filepath.Join(stateDir, "release-v1.0.52.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("GitHub problem release still exists, stat error = %v", err)
|
||||
|
||||
Reference in New Issue
Block a user