Compare commits

...
Author SHA1 Message Date
修雨 eedc41ac54 Merge branch 'main' into revert-730-codex/fix-release-version-verifier 2026-07-21 18:52:05 +08:00
修雨 29dceec5ce fix(release): check commit statuses in Code Admission gates (#732)
The "AI Behavior" context is reported as a commit status (via
github.rest.repos.createCommitStatus) rather than a check run, but the
Code Admission gates only queried check runs via
github.rest.checks.listForRef. This caused every release to fail with
"missing: AI Behavior" since the context was never found.

Add a commit-status query after the check-run loop in both the preflight
and sealed-commit Code Admission gates. Statuses are merged only for
required contexts not already covered by a check run, preserving the
existing check-run precedence.
2026-07-21 18:48:03 +08:00
SCzheng b78a0dee47 Revert "fix(release): validate packaged version at runtime" 2026-07-21 19:46:32 +09:00
修雨 807191396e Merge pull request #730 from DingTalk-Real-AI/codex/fix-release-version-verifier
fix(release): validate packaged version at runtime
2026-07-21 18:12:40 +08:00
修雨 cce9b798d5 Merge remote-tracking branch 'origin/main' into codex/fix-release-version-verifier 2026-07-21 17:51:46 +08:00
修雨 bfa3a1bf33 Merge pull request #729 from sczheng189/feat/relax-stable-promotion-contract
feat(release): allow stable promotion with commits after the beta baseline
2026-07-21 17:47:53 +08:00
修雨 e154b4ecde fix(release): validate packaged version at runtime 2026-07-21 17:47:02 +08:00
zhengyubai f83c305749 feat(release): allow stable promotion with commits after the beta baseline
Stable releases previously required a byte-identical tree with the
promoted beta (only CHANGELOG.md could differ) and local releases had
to run exactly at the origin/main tip with an atomic main+tag push.
Together these froze main for the whole beta-to-stable window.

Relax both gates while keeping the beta soak mandatory:
- stable still requires an explicit delivered, non-withdrawn beta whose
  commit is an ancestor of the sealed release commit; the tree-identity
  drift check is removed
- local releases accept any clean sealed commit contained in
  origin/main history (any branch or detached HEAD) and push only the
  release tag; command-compatibility checks compare the sealed HEAD,
  matching CI
2026-07-21 18:35:59 +09:00
7 changed files with 114 additions and 39 deletions
+38
View File
@@ -358,6 +358,25 @@ jobs:
latestByName.set(run.name, run);
}
}
// Also check commit statuses — some contexts (e.g. "AI Behavior")
// are reported as commit statuses rather than check runs.
const statuses = await github.paginate(
github.rest.repos.listCommitStatusesForRef,
{ owner, repo, ref: sha, per_page: 100 },
);
const seenStatusContexts = new Set();
for (const status of statuses) {
if (!requiredContexts.includes(status.context) || seenStatusContexts.has(status.context)) {
continue;
}
seenStatusContexts.add(status.context);
if (!latestByName.has(status.context)) {
latestByName.set(status.context, {
conclusion: status.state,
status: "completed",
});
}
}
const missing = requiredContexts.filter((name) => !latestByName.has(name));
const nonSuccess = requiredContexts.flatMap((name) => {
const run = latestByName.get(name);
@@ -753,6 +772,25 @@ jobs:
latestByName.set(run.name, run);
}
}
// Also check commit statuses — some contexts (e.g. "AI Behavior")
// are reported as commit statuses rather than check runs.
const statuses = await github.paginate(
github.rest.repos.listCommitStatusesForRef,
{ owner, repo, ref: sha, per_page: 100 },
);
const seenStatusContexts = new Set();
for (const status of statuses) {
if (!requiredContexts.includes(status.context) || seenStatusContexts.has(status.context)) {
continue;
}
seenStatusContexts.add(status.context);
if (!latestByName.has(status.context)) {
latestByName.set(status.context, {
conclusion: status.state,
status: "completed",
});
}
}
const missing = requiredContexts.filter((name) => !latestByName.has(name));
const nonSuccess = requiredContexts.flatMap((name) => {
const run = latestByName.get(name);
+4
View File
@@ -6,6 +6,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Changed
- **Relaxed stable promotion contract** — a stable release still requires a delivered, non-withdrawn beta baseline in its commit history, but no longer requires a byte-identical tree with that beta; reviewed commits merged to `main` after the beta can now ship in the stable release. Local releases now accept any sealed commit contained in `main` history and push only the release tag, so `main` is never frozen during the beta-to-stable window.
## [1.0.53] - 2026-07-21
This release promotes the sealed `v1.0.53-beta.7` contents to stable. It adds enterprise onboarding, declarative shortcuts, Sheet/Aitable writes, multi-account profiles, and broader personal IM events, while hardening authentication and the guarded release path.
+4 -4
View File
@@ -69,11 +69,11 @@ dws-release config --remote origin
```text
main 上的候选代码 + beta CHANGELOG
→ vX.Y.Z-beta.N(预发验证)
→ 只允许补正式 CHANGELOG,源码不得再变化
→ vX.Y.Z(正式发布)
→ 补正式 CHANGELOG;允许继续通过 PR 合入新 commit
→ vX.Y.Z(正式发布,封板提交必须包含该 beta 提交)
```
云端入口自动选择本次最新、已交付且未撤回的 beta;本地入口必须显式指定。流水线会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。
云端入口自动选择本次最新、已交付且未撤回的 beta;本地入口必须显式指定。流水线要求该 beta 已成功交付、未撤回,且 beta 提交必须位于正式发布封板提交的历史中——不能跳过 beta 直接发正式版,但允许在 beta 之后把经过 review 合入 `main` 的 commit 一起发布。
## 预发发布
@@ -131,7 +131,7 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走受保护恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
- tag 必须是 annotated tag;本地脚本在推送前重新确认 HEAD 与远端 `main` 完全一致,CI 允许其后 `main` 前进,但要求封板提交仍位于 `main` 历史中。
- tag 必须是 annotated tag;本地脚本要求封板提交已通过 PR 合入并包含在远端 `main` 历史中,发布只推送 tag。CI 允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
+9 -6
View File
@@ -128,13 +128,8 @@ require_remote() {
}
sync_main_if_safe() {
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
[ "$current_branch" = "main" ] || {
printf 'release validation must run from the main worktree (current: %s)\n' "${current_branch:-detached HEAD}" >&2
exit 1
}
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
printf '%s\n' 'release main worktree must be clean before synchronization' >&2
printf '%s\n' 'release worktree must be clean before synchronization' >&2
exit 1
}
@@ -146,6 +141,14 @@ sync_main_if_safe() {
if [ "$head_commit" = "$remote_commit" ]; then
return 0
fi
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
if [ "$current_branch" != "main" ]; then
if git merge-base --is-ancestor HEAD "$remote_main"; then
return 0
fi
printf 'HEAD is not contained in %s/main history; merge it through a reviewed PR before release\n' "$REMOTE" >&2
exit 1
fi
if git merge-base --is-ancestor HEAD "$remote_main"; then
git merge --ff-only "$remote_main"
return 0
+2 -13
View File
@@ -71,20 +71,14 @@ git rev-parse --verify --quiet "$remote_main^{commit}" >/dev/null || {
printf 'release branch is not available locally: %s/%s\n' "$REMOTE" "$BRANCH" >&2
exit 1
}
remote_main_commit="$(git rev-parse "$remote_main^{commit}")"
if [ "$CONTEXT" = "local" ]; then
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
printf 'release worktree must be clean (staged, unstaged, and untracked files are blocked)\n' >&2
exit 1
}
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
[ "$current_branch" = "$BRANCH" ] || {
printf 'local release must run from branch %s (current: %s)\n' "$BRANCH" "${current_branch:-detached HEAD}" >&2
exit 1
}
[ "$head_commit" = "$remote_main_commit" ] || {
printf 'HEAD must exactly match %s/%s before release\n' "$REMOTE" "$BRANCH" >&2
git merge-base --is-ancestor HEAD "$remote_main" || {
printf 'HEAD must be contained in %s/%s history before release\n' "$REMOTE" "$BRANCH" >&2
exit 1
}
if git rev-parse --verify --quiet "refs/tags/$VERSION" >/dev/null; then
@@ -227,11 +221,6 @@ else
printf 'stable beta baseline is not an ancestor of HEAD: %s\n' "$FROM_BETA" >&2
exit 1
}
if ! git diff --quiet "$FROM_BETA^{commit}" HEAD -- . ':(exclude)CHANGELOG.md'; then
printf 'stable source drifted from %s; only CHANGELOG.md may differ\n' "$FROM_BETA" >&2
git diff --name-only "$FROM_BETA^{commit}" HEAD -- . ':(exclude)CHANGELOG.md' >&2
exit 1
fi
fi
semver="$(release_semver "$VERSION")"
+6 -7
View File
@@ -351,7 +351,7 @@ else
if [ -n "$previous_stable" ]; then
printf '==> Comparing command tree with %s\n' "$previous_stable"
"$ROOT/scripts/policy/check-command-compatibility.sh" \
--base-ref "$REMOTE/$BRANCH" \
--base-ref HEAD \
--stable-ref "$previous_stable"
fi
@@ -405,7 +405,7 @@ if [ "$previous_stable" != "$previous_stable_before_refresh" ]; then
printf '==> Stable authority advanced from %s to %s; rechecking command compatibility\n' \
"${previous_stable_before_refresh:-none}" "$previous_stable"
"$ROOT/scripts/policy/check-command-compatibility.sh" \
--base-ref "$REMOTE/$BRANCH" \
--base-ref HEAD \
--stable-ref "$previous_stable"
fi
@@ -416,9 +416,9 @@ fi
# Delivery, compatibility, and publication checks above may take long enough
# for main or stable authority to move. This last refresh must be followed only
# by local proof/tag creation. The atomic push advertises main with the tag, so
# an already-advanced remote main rejects the whole transaction; a later main
# advance is safe because the sealed commit remains in protected main history.
# by local proof/tag creation. Only the tag is pushed: the sealed commit is
# already contained in protected main history, so a later main advance never
# invalidates the release.
printf '==> Settling final %s/%s and stable authority\n' "$REMOTE" "$BRANCH"
git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH"
fetch_release_tags
@@ -447,8 +447,7 @@ else
git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: prerelease'
fi
if ! git push --atomic "$push_url" \
"HEAD:refs/heads/$BRANCH" "refs/tags/$VERSION"; then
if ! git push "$push_url" "refs/tags/$VERSION"; then
set +e
remote_refs="$(git ls-remote --tags "$push_url" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")"
query_status=$?
+51 -9
View File
@@ -1494,12 +1494,12 @@ func TestReleaseContractRejectsDirtyOrUnsyncedMain(t *testing.T) {
"--version", "v1.0.1-beta.1",
"--remote", "origin",
)
if err == nil || !strings.Contains(output, "must exactly match origin/main") {
if err == nil || !strings.Contains(output, "must be contained in origin/main history") {
t.Fatalf("unsynced main was not blocked: err=%v\noutput:\n%s", err, output)
}
}
func TestReleaseContractStablePromotionAllowsOnlyChangelogDiff(t *testing.T) {
func TestReleaseContractStablePromotionRequiresBetaAncestry(t *testing.T) {
t.Run("sealed", func(t *testing.T) {
r := newReleaseTestRepo(t)
r.seedBeta(t)
@@ -1518,12 +1518,12 @@ func TestReleaseContractStablePromotionAllowsOnlyChangelogDiff(t *testing.T) {
}
})
t.Run("source drift", func(t *testing.T) {
t.Run("commits after beta", func(t *testing.T) {
r := newReleaseTestRepo(t)
r.seedBeta(t)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644)
mustWriteFile(t, filepath.Join(r.root, "drift.txt"), []byte("untested change\n"), 0o644)
r.commitAndPush(t, "drift after beta")
mustWriteFile(t, filepath.Join(r.root, "followup.txt"), []byte("merged after beta\n"), 0o644)
r.commitAndPush(t, "merge follow-up after beta")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
@@ -1532,11 +1532,53 @@ func TestReleaseContractStablePromotionAllowsOnlyChangelogDiff(t *testing.T) {
"--from-beta", "v1.0.1-beta.1",
"--remote", "origin",
)
if err == nil {
t.Fatalf("drifted stable promotion unexpectedly passed:\n%s", output)
if err != nil {
t.Fatalf("stable promotion with commits after beta error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(output, "only CHANGELOG.md may differ") || !strings.Contains(output, "drift.txt") {
t.Fatalf("drift output is not actionable:\n%s", output)
})
t.Run("beta outside HEAD history", func(t *testing.T) {
r := newReleaseTestRepo(t)
mustRun(t, r.root, "git", "checkout", "-b", "sidecar")
mustWriteFile(t, filepath.Join(r.root, "sidecar.txt"), []byte("never merged\n"), 0o644)
mustRun(t, r.root, "git", "add", ".")
mustRun(t, r.root, "git", "commit", "-m", "sidecar beta candidate")
mustRun(t, r.root, "git", "tag", "-a", "v1.0.1-beta.1", "-m", "Release v1.0.1-beta.1", "-m", "Channel: prerelease")
mustRun(t, r.root, "git", "checkout", "main")
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644)
r.commitAndPush(t, "prepare stable changelog")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "stable",
"--version", "v1.0.1",
"--from-beta", "v1.0.1-beta.1",
"--remote", "origin",
)
if err == nil || !strings.Contains(output, "not an ancestor of HEAD") {
t.Fatalf("beta outside HEAD history was promoted: err=%v\noutput:\n%s", err, output)
}
})
t.Run("older sealed commit after main advanced", func(t *testing.T) {
r := newReleaseTestRepo(t)
r.seedBeta(t)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644)
r.commitAndPush(t, "prepare stable changelog")
sealed := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "HEAD"))
mustWriteFile(t, filepath.Join(r.root, "after.txt"), []byte("main advanced\n"), 0o644)
r.commitAndPush(t, "advance main after stable candidate")
mustRun(t, r.root, "git", "checkout", "--detach", sealed)
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "stable",
"--version", "v1.0.1",
"--from-beta", "v1.0.1-beta.1",
"--remote", "origin",
)
if err != nil {
t.Fatalf("older sealed commit in main history was rejected: %v\noutput:\n%s", err, output)
}
})
}