From 9e88116a2d8d9c4b76c51c1a5d7824f5ccaf023f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Tue, 21 Jul 2026 14:55:11 +0800 Subject: [PATCH 01/11] fix(release): fingerprint allocated tag refs --- .github/workflows/release.yml | 17 +++++--- CHANGELOG.md | 1 + test/scripts/package_script_test.go | 67 ++++++++++++++++++++++++++++- 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 18a3c74f..38a505f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2481,13 +2481,16 @@ jobs: value="$(sed -n "s/^$key=//p" "$allocation")" printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT" done - refs_fingerprint="$( - git for-each-ref --format='%(refname)=%(objectname)' \ - refs/tags/v refs/tags/withdrawn/v \ - | LC_ALL=C sort \ - | sha256sum \ - | awk '{print $1}' - )" + refs_unsorted="$RUNNER_TEMP/release-refs.unsorted" + refs_manifest="$RUNNER_TEMP/release-refs" + git for-each-ref --format='%(refname)=%(objectname)' \ + 'refs/tags/v*' 'refs/tags/withdrawn/v*' > "$refs_unsorted" + LC_ALL=C sort "$refs_unsorted" > "$refs_manifest" + test -s "$refs_manifest" || { + echo "release ref manifest is empty after fetching allocated tags" >&2 + exit 1 + } + refs_fingerprint="$(sha256sum "$refs_manifest" | awk '{print $1}')" echo "release_commit=$GITHUB_SHA" >> "$GITHUB_OUTPUT" echo "refs_fingerprint=$refs_fingerprint" >> "$GITHUB_OUTPUT" case "$OSS_MIRROR" in enabled|deferred) ;; *) exit 1 ;; esac diff --git a/CHANGELOG.md b/CHANGELOG.md index 60f83206..29317fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This beta validates guarded local release compatibility and tag-bound OSS deferr ### 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. +- **Cloud release tag allocation fingerprint** — Release planning now fingerprints the actual `v*` and `withdrawn/v*` refs fetched from GitHub, matching the seal job's API view instead of hashing an empty non-wildcard ref prefix and rejecting every publish before tag creation. ## [1.0.53-beta.5] - 2026-07-21 diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 699824d4..047fb527 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strings" "testing" ) @@ -836,7 +837,8 @@ func TestReleaseWorkflowPlansAndSealsCurrentMainInTheCloud(t *testing.T) { "persist-credentials: false", `refs/remotes/origin/main)" = "$GITHUB_SHA`, "next-release-version.sh", - "refs/tags/withdrawn/v", + `'refs/tags/v*' 'refs/tags/withdrawn/v*'`, + "release ref manifest is empty after fetching allocated tags", "refs_fingerprint", "Validate the candidate release contract before sealing", "release-contract.sh", @@ -850,6 +852,9 @@ func TestReleaseWorkflowPlansAndSealsCurrentMainInTheCloud(t *testing.T) { if strings.Contains(plan, "contents: write") { t.Error("cloud release planning must remain read-only") } + if strings.Contains(plan, "refs/tags/v refs/tags/withdrawn/v") { + t.Error("cloud release planning must use wildcard ref patterns that match the seal API prefixes") + } for _, required := range []string{ "name: Seal cloud release tag", @@ -884,6 +889,66 @@ func TestReleaseWorkflowPlansAndSealsCurrentMainInTheCloud(t *testing.T) { } } +func TestReleaseFingerprintRefPatternsMatchAllAllocatedTags(t *testing.T) { + t.Parallel() + repo := t.TempDir() + mustRun(t, repo, "git", "init", "-b", "main") + mustRun(t, repo, "git", "config", "user.name", "Release Fingerprint Test") + mustRun(t, repo, "git", "config", "user.email", "release-fingerprint@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") + + allocatedTags := []string{ + "v1.0.52", + "v1.0.53-beta.5", + "withdrawn/v1.0.51", + } + for _, tag := range allocatedTags { + mustRun(t, repo, "git", "tag", "-a", tag, "-m", "Release "+tag) + } + mustRun(t, repo, "git", "tag", "-a", "release/v1.0.52", "-m", "unrelated namespace") + legacy := exec.Command( + "git", "for-each-ref", "--format=%(refname)=%(objectname)", + "refs/tags/v", "refs/tags/withdrawn/v", + ) + legacy.Dir = repo + legacyOutput, err := legacy.CombinedOutput() + if err != nil { + t.Fatalf("legacy git for-each-ref error = %v\noutput:\n%s", err, legacyOutput) + } + if strings.TrimSpace(string(legacyOutput)) != "" { + t.Fatalf("legacy component patterns unexpectedly matched flat release refs:\n%s", legacyOutput) + } + + cmd := exec.Command( + "git", "for-each-ref", "--format=%(refname)=%(objectname)", + "refs/tags/v*", "refs/tags/withdrawn/v*", + ) + cmd.Dir = repo + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git for-each-ref error = %v\noutput:\n%s", err, output) + } + got := strings.Split(strings.TrimSpace(string(output)), "\n") + sort.Strings(got) + + want := make([]string, 0, len(allocatedTags)) + for _, tag := range allocatedTags { + object := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/"+tag)) + want = append(want, "refs/tags/"+tag+"="+object) + } + sort.Strings(want) + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("release ref set differs from the seal API set\ngot:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + workflowDigest := sha256.Sum256([]byte(strings.Join(got, "\n") + "\n")) + sealDigest := sha256.Sum256([]byte(strings.Join(want, "\n") + "\n")) + if workflowDigest != sealDigest { + t.Fatalf("release ref fingerprint differs from seal fingerprint: workflow=%x seal=%x", workflowDigest, sealDigest) + } +} + func TestReleaseWorkflowAcceptsGuardedLocalTagMetadata(t *testing.T) { t.Parallel() workflow := readReleaseWorkflow(t) From e5c8ff9acd8c1c052425384c95fdf41345b9401e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Tue, 21 Jul 2026 15:27:38 +0800 Subject: [PATCH 02/11] chore(release): prepare v1.0.53 --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29317fd2..4f31facc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.53] - 2026-07-21 + +This release promotes the sealed `v1.0.53-beta.6` 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. + +### Added + +- **Enterprise and office command coverage** — adds enterprise creation, employee invitation, and account provisioning commands; 366 declarative service shortcuts; Sheet import commands; and Aitable workflow create/update support with reviewed Schema contracts. +- **Multiple accounts in one DingTalk organization** — profiles can distinguish accounts by organization and user, select them explicitly, and log out one account or an entire organization without overwriting another account's credentials. +- **Expanded personal IM event subscriptions** (#651) — adds read-receipt, recall, and reaction events for one-to-one and group chats, plus specified-sender subscriptions by staff ID or OpenDingTalk ID. +- **Official multi-platform Homebrew channel** — ships separate stable and keg-only beta Formulae for macOS and Linux across amd64 and arm64, with isolated update PRs. + +### Changed + +- **Personal event output contract** (#651) — `event consume` now emits event-specific top-level structured fields; scripts that consumed the former transport envelope must use the flat fields or select `-f raw`, while `--debug-raw-events` retains the diagnostic envelope. +- **Guarded release lifecycle** — beta/stable publication now uses explicit promotion, immutable delivery proofs, protected recovery, and tag-bound optional OSS policy; an unprovisioned OSS mirror is sealed as `deferred` so GitHub, npm, and Homebrew are not blocked. + +### Fixed + +- **Authentication and credential reliability** — organization-policy denials stop before mutation or polling, long-running clients reload and refresh access tokens consistently, concurrent credential writes are atomic, and Windows portable-auth commands fail before reading or writing unsupported credential bundles. +- **Command validation and compatibility** — invalid Sheet/task targets fail locally, IM shortcuts preserve AI-tag and alias compatibility, and Aitable import uploads require and forward a positive file size. +- **Release publication reliability** — GitHub draft publication is bound to one verified release ID and exact assets, preflight uses isolated installer worktrees, guarded local tags remain compatible, and cloud planning fingerprints the actual allocated release refs. + ## [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. From 20750df20ba418bdd9bba9957109af871edafe50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Tue, 21 Jul 2026 15:46:19 +0800 Subject: [PATCH 03/11] fix(release): wait for npm channel propagation --- .github/workflows/release.yml | 29 ++++-- CHANGELOG.md | 12 ++- test/scripts/package_script_test.go | 154 ++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 38a505f0..7147bf5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1694,13 +1694,28 @@ jobs: run: | set -eu . ./scripts/release/release-lib.sh - delivered="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG")" - if [ "$delivered" = "$SEMVER" ]; then exit 0; fi - release_version_is_greater "v$delivered" "v$SEMVER" || { - echo "npm $NPM_TAG=$delivered does not deliver or supersede v$SEMVER" >&2 - exit 1 - } - echo "npm $NPM_TAG already supersedes this historical rerun at v$delivered." + attempt=1 + max_attempts=12 + while :; do + delivered="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG" \ + --registry=https://registry.npmjs.org --prefer-online)" + if [ "$delivered" = "$SEMVER" ]; then exit 0; fi + if release_version_is_greater "v$delivered" "v$SEMVER"; then + echo "npm $NPM_TAG already supersedes this historical rerun at v$delivered." + exit 0 + fi + release_version_is_greater "v$SEMVER" "v$delivered" || { + echo "npm $NPM_TAG=$delivered cannot be reconciled with v$SEMVER" >&2 + exit 1 + } + if [ "$attempt" -ge "$max_attempts" ]; then + echo "npm $NPM_TAG still reports older v$delivered after $attempt attempts; expected v$SEMVER" >&2 + exit 1 + fi + echo "npm $NPM_TAG still reports older v$delivered; waiting for registry propagation ($attempt/$max_attempts)." + sleep 5 + attempt=$((attempt + 1)) + done - name: Sync release artifacts to China OSS mirror if: ${{ needs.release-contract.outputs.oss_mirror == 'enabled' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f31facc..f406e017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [1.0.53] - 2026-07-21 -This release promotes the sealed `v1.0.53-beta.6` 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. +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. ### Added @@ -26,7 +26,15 @@ This release promotes the sealed `v1.0.53-beta.6` contents to stable. It adds en - **Authentication and credential reliability** — organization-policy denials stop before mutation or polling, long-running clients reload and refresh access tokens consistently, concurrent credential writes are atomic, and Windows portable-auth commands fail before reading or writing unsupported credential bundles. - **Command validation and compatibility** — invalid Sheet/task targets fail locally, IM shortcuts preserve AI-tag and alias compatibility, and Aitable import uploads require and forward a positive file size. -- **Release publication reliability** — GitHub draft publication is bound to one verified release ID and exact assets, preflight uses isolated installer worktrees, guarded local tags remain compatible, and cloud planning fingerprints the actual allocated release refs. +- **Release publication reliability** — GitHub draft publication is bound to one verified release ID and exact assets, preflight uses isolated installer worktrees, guarded local tags remain compatible, cloud planning fingerprints the actual allocated release refs, and npm channel verification waits for bounded registry propagation without moving tags. + +## [1.0.53-beta.7] - 2026-07-21 + +This beta validates bounded npm channel verification after registry publication. + +### Fixed + +- **npm dist-tag eventual consistency** — Release delivery now tolerates a briefly stale `latest` or `beta` read after publishing by retrying only when npm reports a valid older version. Registry errors, invalid or incomparable tags, and channels that never converge still fail closed without moving any tag during verification. ## [1.0.53-beta.6] - 2026-07-21 diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 047fb527..87833afa 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -637,6 +637,33 @@ func releaseWorkflowSection(t *testing.T, workflow, startMarker, endMarker strin return workflow[start : start+len(startMarker)+end] } +func releaseWorkflowRunScript(t *testing.T, workflow, stepName, nextStepName string) string { + t.Helper() + section := releaseWorkflowSection( + t, + workflow, + " - name: "+stepName+"\n", + "\n - name: "+nextStepName+"\n", + ) + const runMarker = " run: |\n" + start := strings.Index(section, runMarker) + if start == -1 { + t.Fatalf("release workflow step %q is missing a run block", stepName) + } + + lines := strings.Split(section[start+len(runMarker):], "\n") + for i, line := range lines { + if line == "" { + continue + } + if !strings.HasPrefix(line, " ") { + t.Fatalf("release workflow step %q has an unexpected run indentation: %q", stepName, line) + } + lines[i] = strings.TrimPrefix(line, " ") + } + return strings.Join(lines, "\n") +} + func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) { t.Parallel() workflow := readReleaseWorkflow(t) @@ -1794,6 +1821,133 @@ func TestReleaseWorkflowOpensVersionedHomebrewPRForBetaTags(t *testing.T) { } } +func TestReleaseWorkflowWaitsForNPMDistTagPropagation(t *testing.T) { + workflow := readReleaseWorkflow(t) + script := releaseWorkflowRunScript( + t, + workflow, + "Verify npm channel delivery", + "Sync release artifacts to China OSS mirror", + ) + + tests := []struct { + name string + sequence string + wantSuccess bool + wantCalls int + wantSleeps int + wantOutput string + }{ + { + name: "stale beta converges to target", + sequence: "1.0.53-beta.6\n1.0.53-beta.6\n1.0.53-beta.7\n", + wantSuccess: true, + wantCalls: 3, + wantSleeps: 2, + }, + { + name: "stale beta never converges", + sequence: "1.0.53-beta.6\n", + wantCalls: 12, + wantSleeps: 11, + wantOutput: "still reports older v1.0.53-beta.6 after 12 attempts", + }, + { + name: "permanent registry error fails immediately", + sequence: "__NPM_ERROR__\n", + wantCalls: 1, + wantSleeps: 0, + wantOutput: "permanent npm registry error", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + fakeBin := filepath.Join(root, "bin") + if err := os.MkdirAll(fakeBin, 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", fakeBin, err) + } + sequencePath := filepath.Join(root, "sequence") + statePath := filepath.Join(root, "state") + npmLogPath := filepath.Join(root, "npm.log") + sleepLogPath := filepath.Join(root, "sleep.log") + mustWriteFile(t, sequencePath, []byte(test.sequence), 0o644) + mustWriteFile(t, filepath.Join(fakeBin, "npm"), []byte(`#!/bin/sh +set -eu +printf '%s\n' "$*" >> "$NPM_CALL_LOG" +test "$*" = "view dingtalk-workspace-cli dist-tags.beta --registry=https://registry.npmjs.org --prefer-online" || { + echo "unexpected npm mutation: $*" >&2 + exit 97 +} +call=0 +if test -f "$NPM_STATE"; then call="$(cat "$NPM_STATE")"; fi +call=$((call + 1)) +printf '%s\n' "$call" > "$NPM_STATE" +value="$(sed -n "${call}p" "$NPM_SEQUENCE")" +if test -z "$value"; then value="$(tail -n 1 "$NPM_SEQUENCE")"; fi +if test "$value" = "__NPM_ERROR__"; then + echo "permanent npm registry error" >&2 + exit 42 +fi +printf '%s\n' "$value" +`), 0o755) + mustWriteFile(t, filepath.Join(fakeBin, "sleep"), []byte(`#!/bin/sh +set -eu +printf '%s\n' "$*" >> "$SLEEP_CALL_LOG" +`), 0o755) + + repoRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repository root) error = %v", err) + } + cmd := exec.Command("sh", "-c", script) + cmd.Dir = repoRoot + cmd.Env = append(os.Environ(), + "PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"), + "NPM_TAG=beta", + "SEMVER=1.0.53-beta.7", + "NPM_SEQUENCE="+sequencePath, + "NPM_STATE="+statePath, + "NPM_CALL_LOG="+npmLogPath, + "SLEEP_CALL_LOG="+sleepLogPath, + ) + output, runErr := cmd.CombinedOutput() + if test.wantSuccess && runErr != nil { + t.Fatalf("npm delivery verification error = %v\noutput:\n%s", runErr, output) + } + if !test.wantSuccess && runErr == nil { + t.Fatalf("npm delivery verification unexpectedly succeeded\noutput:\n%s", output) + } + if test.wantOutput != "" && !strings.Contains(string(output), test.wantOutput) { + t.Errorf("npm delivery verification output is missing %q:\n%s", test.wantOutput, output) + } + + npmLog, err := os.ReadFile(npmLogPath) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", npmLogPath, err) + } + npmCalls := strings.Split(strings.TrimSpace(string(npmLog)), "\n") + if got := len(npmCalls); got != test.wantCalls { + t.Errorf("npm view call count = %d, want %d; log:\n%s", got, test.wantCalls, npmLog) + } + if strings.Contains(string(npmLog), "dist-tag add") || strings.Contains(string(npmLog), "publish") { + t.Errorf("delivery verification must remain read-only; log:\n%s", npmLog) + } + + sleepCalls := 0 + if sleepLog, err := os.ReadFile(sleepLogPath); err == nil { + sleepCalls = len(strings.Fields(string(sleepLog))) + } else if !os.IsNotExist(err) { + t.Fatalf("ReadFile(%s) error = %v", sleepLogPath, err) + } + if sleepCalls != test.wantSleeps { + t.Errorf("sleep call count = %d, want %d", sleepCalls, test.wantSleeps) + } + }) + } +} + func TestReleaseStaysDraftUntilFinalizedAssetDigestsMatch(t *testing.T) { t.Parallel() From f83c305749e4017ac7315135c32921b8c8cfb236 Mon Sep 17 00:00:00 2001 From: zhengyubai Date: Tue, 21 Jul 2026 18:35:59 +0900 Subject: [PATCH 04/11] 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 --- CHANGELOG.md | 4 ++ docs/releasing.md | 8 ++-- scripts/release/dws-release.sh | 15 +++++--- scripts/release/release-contract.sh | 15 +------- scripts/release/release.sh | 13 +++---- test/scripts/release_script_test.go | 60 ++++++++++++++++++++++++----- 6 files changed, 76 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f406e017..5f5fc7f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/releasing.md b/docs/releasing.md index addfb5d5..44934361 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 漂移产生不同字节。 diff --git a/scripts/release/dws-release.sh b/scripts/release/dws-release.sh index c9b205f4..c064dea1 100755 --- a/scripts/release/dws-release.sh +++ b/scripts/release/dws-release.sh @@ -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 diff --git a/scripts/release/release-contract.sh b/scripts/release/release-contract.sh index 3e4aadc7..44c2b89a 100755 --- a/scripts/release/release-contract.sh +++ b/scripts/release/release-contract.sh @@ -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")" diff --git a/scripts/release/release.sh b/scripts/release/release.sh index fa0f87c9..cd98060f 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -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=$? diff --git a/test/scripts/release_script_test.go b/test/scripts/release_script_test.go index 56848652..3d3a43de 100644 --- a/test/scripts/release_script_test.go +++ b/test/scripts/release_script_test.go @@ -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) } }) } From e154b4ecdee00688c2ae4675d5b94ee383874e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Tue, 21 Jul 2026 17:39:08 +0800 Subject: [PATCH 05/11] fix(release): validate packaged version at runtime --- .github/workflows/release.yml | 17 +++++--- scripts/release/verify-release-artifacts.sh | 46 +++++++++++++++++++-- test/scripts/package_script_test.go | 17 ++++++++ test/scripts/release_script_test.go | 24 ++++++++++- 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7147bf5a..1ea442f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1047,7 +1047,9 @@ jobs: rm -f "$RUNNER_TEMP/dws-developer-id-password" - name: Verify final release artifacts - run: ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" + run: | + DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION" - name: Verify npm package before sealing GitHub Release run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION" @@ -1172,7 +1174,9 @@ jobs: path: dist - name: Verify finalized release artifacts before publication - run: ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" + run: | + DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION" - name: Verify npm package before sealing GitHub Release run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION" @@ -1268,7 +1272,7 @@ jobs: "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \ "$RELEASE_VERSION" "$published_dir" DWS_PACKAGE_DIST_DIR="$published_dir" \ - ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION" for asset in "$published_dir"/dws-* "$published_dir"/checksums.txt; do if [ "$IS_RECOVERY" = "true" ] && ! cmp -s "$asset" "dist/$(basename "$asset")"; then echo "Public recovery asset differs from this run's sealed artifact: $(basename "$asset")" >&2 @@ -1374,7 +1378,7 @@ jobs: "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \ "$RELEASE_VERSION" "$sealed_upload" DWS_PACKAGE_DIST_DIR="$sealed_upload" \ - ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION" for remote_asset in "$sealed_upload"/dws-* "$sealed_upload"/checksums.txt; do local_asset="dist/$(basename "$remote_asset")" cmp -s "$local_asset" "$remote_asset" || { @@ -1444,7 +1448,7 @@ jobs: "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \ "$RELEASE_VERSION" "$immutable_dir" DWS_PACKAGE_DIST_DIR="$immutable_dir" \ - ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION" for immutable_asset in "$immutable_dir"/dws-* "$immutable_dir"/checksums.txt; do sealed_asset="dist/$(basename "$immutable_asset")" cmp -s "$sealed_asset" "$immutable_asset" || { @@ -1566,7 +1570,8 @@ jobs: --pattern 'dws-*' \ --pattern 'checksums.txt' \ --clobber - ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" + DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION" DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \ DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ ./scripts/release/stage-npm-package.sh "$RELEASE_VERSION" diff --git a/scripts/release/verify-release-artifacts.sh b/scripts/release/verify-release-artifacts.sh index 0ddc65df..5f49249f 100755 --- a/scripts/release/verify-release-artifacts.sh +++ b/scripts/release/verify-release-artifacts.sh @@ -72,7 +72,7 @@ else exit 1 fi -verify_binary_version() { +verify_binary_archive() { asset="$1" extract_dir="$tmp/extract-${asset}" mkdir -p "$extract_dir" @@ -94,14 +94,52 @@ verify_binary_version() { printf '%s does not contain the expected dws binary\n' "$asset" >&2 return 1 } - strings "$binary" | grep -Fqx "v$SEMVER" || { - printf '%s binary does not embed expected version v%s\n' "$asset" "$SEMVER" >&2 + LC_ALL=C grep -aFq "v$SEMVER" "$binary" || { + printf '%s binary does not contain expected version marker v%s\n' \ + "$asset" "$SEMVER" >&2 return 1 } + # Execute one native artifact and validate the CLI's public version contract. + # `strings` output has no symbol boundaries, so requiring the injected version + # to appear on an exact line can reject a correct Go binary when adjacent + # printable bytes are coalesced into the same output line. + if [ "$asset" = dws-linux-amd64.tar.gz ]; then + command -v python3 >/dev/null 2>&1 || { + printf 'python3 is required to validate the release binary version\n' >&2 + return 1 + } + version_json="$extract_dir/version.json" + isolated_home="$extract_dir/home" + mkdir -p "$isolated_home" + if ! env -i HOME="$isolated_home" PATH=/usr/bin:/bin LANG=C.UTF-8 \ + "$binary" version --format json >"$version_json"; then + printf '%s binary could not report its version\n' "$asset" >&2 + return 1 + fi + reported_version="$(python3 -c ' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + payload = json.load(handle) +version = payload.get("version") +if not isinstance(version, str) or not version: + raise SystemExit(1) +print(version) +' "$version_json")" || { + printf '%s binary returned an invalid version payload\n' "$asset" >&2 + return 1 + } + [ "$reported_version" = "v$SEMVER" ] || { + printf '%s binary reports version %s, expected v%s\n' \ + "$asset" "$reported_version" "$SEMVER" >&2 + return 1 + } + fi } for asset in $EXPECTED_PLATFORM_ASSETS; do - verify_binary_version "$asset" + verify_binary_archive "$asset" done printf 'Release artifacts verified for v%s.\n' "$SEMVER" diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 87833afa..bf060e3c 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -1948,6 +1948,23 @@ printf '%s\n' "$*" >> "$SLEEP_CALL_LOG" } } +func TestReleaseWorkflowUsesTrustedArtifactVerifierForRecovery(t *testing.T) { + t.Parallel() + workflow := readReleaseWorkflow(t) + trusted := `"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh"` + if got := strings.Count(workflow, trusted); got != 6 { + t.Fatalf("trusted artifact verifier call count = %d, want 6", got) + } + workspaceDist := `DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \` + "\n" + + ` "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh"` + if got := strings.Count(workflow, workspaceDist); got != 3 { + t.Fatalf("workspace dist-bound trusted verifier call count = %d, want 3", got) + } + if strings.Contains(workflow, "./scripts/release/verify-release-artifacts.sh") { + t.Fatal("release workflow still executes the sealed tag's artifact verifier") + } +} + func TestReleaseStaysDraftUntilFinalizedAssetDigestsMatch(t *testing.T) { t.Parallel() diff --git a/test/scripts/release_script_test.go b/test/scripts/release_script_test.go index 56848652..b55f5db7 100644 --- a/test/scripts/release_script_test.go +++ b/test/scripts/release_script_test.go @@ -30,7 +30,18 @@ func writeVersionedReleaseArchive(t *testing.T, dist, asset, version string) { if strings.HasSuffix(asset, ".zip") { binary = "dws.exe" } - mustWriteFile(t, filepath.Join(stage, binary), []byte("fake release binary\n"+version+"\n"), 0o755) + content := []byte("fake release binary\n" + version + "\n") + if asset == "dws-linux-amd64.tar.gz" { + content = []byte(fmt.Sprintf(`#!/bin/sh +set -eu +if [ "$#" -ne 3 ] || [ "$1" != version ] || [ "$2" != --format ] || [ "$3" != json ]; then + printf 'unexpected version command\n' >&2 + exit 2 +fi +printf '{"version":"%s"}\n' +`, version)) + } + mustWriteFile(t, filepath.Join(stage, binary), content, 0o755) if strings.HasSuffix(asset, ".zip") { mustRun(t, stage, "zip", "-q", filepath.Join(dist, asset), binary) return @@ -1906,9 +1917,18 @@ func TestReleaseArtifactVerificationRequiresEveryChecksum(t *testing.T) { writeReleaseChecksums(t, dist, true) cmd = exec.Command("sh", r.verify, "v1.2.3") cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist) - if output, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(output), "dws-windows-arm64.zip binary") { + if output, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(output), "dws-windows-arm64.zip binary does not contain expected version marker v1.2.3") { t.Fatalf("mixed-version archive was not rejected: err=%v\noutput:\n%s", err, output) } + + writeVersionedReleaseArchive(t, dist, "dws-windows-arm64.zip", "v1.2.3") + writeVersionedReleaseArchive(t, dist, "dws-linux-amd64.tar.gz", "v1.2.3-beta.1") + writeReleaseChecksums(t, dist, true) + cmd = exec.Command("sh", r.verify, "v1.2.3") + cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist) + if output, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(output), "dws-linux-amd64.tar.gz binary reports version v1.2.3-beta.1, expected v1.2.3") { + t.Fatalf("native prefixed version was not rejected: err=%v\noutput:\n%s", err, output) + } } func TestReleaseCommandValidatesThenPushesAnnotatedTag(t *testing.T) { From b78a0dee473f2d65dec0ef2d3af521788e90e8dc Mon Sep 17 00:00:00 2001 From: SCzheng <144249901+sczheng189@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:46:32 +0900 Subject: [PATCH 06/11] Revert "fix(release): validate packaged version at runtime" --- .github/workflows/release.yml | 17 +++----- scripts/release/verify-release-artifacts.sh | 46 ++------------------- test/scripts/package_script_test.go | 17 -------- test/scripts/release_script_test.go | 24 +---------- 4 files changed, 12 insertions(+), 92 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ea442f8..7147bf5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1047,9 +1047,7 @@ jobs: rm -f "$RUNNER_TEMP/dws-developer-id-password" - name: Verify final release artifacts - run: | - DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ - "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION" + run: ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" - name: Verify npm package before sealing GitHub Release run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION" @@ -1174,9 +1172,7 @@ jobs: path: dist - name: Verify finalized release artifacts before publication - run: | - DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ - "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION" + run: ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" - name: Verify npm package before sealing GitHub Release run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION" @@ -1272,7 +1268,7 @@ jobs: "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \ "$RELEASE_VERSION" "$published_dir" DWS_PACKAGE_DIST_DIR="$published_dir" \ - "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION" + ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" for asset in "$published_dir"/dws-* "$published_dir"/checksums.txt; do if [ "$IS_RECOVERY" = "true" ] && ! cmp -s "$asset" "dist/$(basename "$asset")"; then echo "Public recovery asset differs from this run's sealed artifact: $(basename "$asset")" >&2 @@ -1378,7 +1374,7 @@ jobs: "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \ "$RELEASE_VERSION" "$sealed_upload" DWS_PACKAGE_DIST_DIR="$sealed_upload" \ - "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION" + ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" for remote_asset in "$sealed_upload"/dws-* "$sealed_upload"/checksums.txt; do local_asset="dist/$(basename "$remote_asset")" cmp -s "$local_asset" "$remote_asset" || { @@ -1448,7 +1444,7 @@ jobs: "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \ "$RELEASE_VERSION" "$immutable_dir" DWS_PACKAGE_DIST_DIR="$immutable_dir" \ - "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION" + ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" for immutable_asset in "$immutable_dir"/dws-* "$immutable_dir"/checksums.txt; do sealed_asset="dist/$(basename "$immutable_asset")" cmp -s "$sealed_asset" "$immutable_asset" || { @@ -1570,8 +1566,7 @@ jobs: --pattern 'dws-*' \ --pattern 'checksums.txt' \ --clobber - DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ - "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh" "$RELEASE_VERSION" + ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \ DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ ./scripts/release/stage-npm-package.sh "$RELEASE_VERSION" diff --git a/scripts/release/verify-release-artifacts.sh b/scripts/release/verify-release-artifacts.sh index 5f49249f..0ddc65df 100755 --- a/scripts/release/verify-release-artifacts.sh +++ b/scripts/release/verify-release-artifacts.sh @@ -72,7 +72,7 @@ else exit 1 fi -verify_binary_archive() { +verify_binary_version() { asset="$1" extract_dir="$tmp/extract-${asset}" mkdir -p "$extract_dir" @@ -94,52 +94,14 @@ verify_binary_archive() { printf '%s does not contain the expected dws binary\n' "$asset" >&2 return 1 } - LC_ALL=C grep -aFq "v$SEMVER" "$binary" || { - printf '%s binary does not contain expected version marker v%s\n' \ - "$asset" "$SEMVER" >&2 + strings "$binary" | grep -Fqx "v$SEMVER" || { + printf '%s binary does not embed expected version v%s\n' "$asset" "$SEMVER" >&2 return 1 } - # Execute one native artifact and validate the CLI's public version contract. - # `strings` output has no symbol boundaries, so requiring the injected version - # to appear on an exact line can reject a correct Go binary when adjacent - # printable bytes are coalesced into the same output line. - if [ "$asset" = dws-linux-amd64.tar.gz ]; then - command -v python3 >/dev/null 2>&1 || { - printf 'python3 is required to validate the release binary version\n' >&2 - return 1 - } - version_json="$extract_dir/version.json" - isolated_home="$extract_dir/home" - mkdir -p "$isolated_home" - if ! env -i HOME="$isolated_home" PATH=/usr/bin:/bin LANG=C.UTF-8 \ - "$binary" version --format json >"$version_json"; then - printf '%s binary could not report its version\n' "$asset" >&2 - return 1 - fi - reported_version="$(python3 -c ' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - payload = json.load(handle) -version = payload.get("version") -if not isinstance(version, str) or not version: - raise SystemExit(1) -print(version) -' "$version_json")" || { - printf '%s binary returned an invalid version payload\n' "$asset" >&2 - return 1 - } - [ "$reported_version" = "v$SEMVER" ] || { - printf '%s binary reports version %s, expected v%s\n' \ - "$asset" "$reported_version" "$SEMVER" >&2 - return 1 - } - fi } for asset in $EXPECTED_PLATFORM_ASSETS; do - verify_binary_archive "$asset" + verify_binary_version "$asset" done printf 'Release artifacts verified for v%s.\n' "$SEMVER" diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index bf060e3c..87833afa 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -1948,23 +1948,6 @@ printf '%s\n' "$*" >> "$SLEEP_CALL_LOG" } } -func TestReleaseWorkflowUsesTrustedArtifactVerifierForRecovery(t *testing.T) { - t.Parallel() - workflow := readReleaseWorkflow(t) - trusted := `"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh"` - if got := strings.Count(workflow, trusted); got != 6 { - t.Fatalf("trusted artifact verifier call count = %d, want 6", got) - } - workspaceDist := `DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \` + "\n" + - ` "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-artifacts.sh"` - if got := strings.Count(workflow, workspaceDist); got != 3 { - t.Fatalf("workspace dist-bound trusted verifier call count = %d, want 3", got) - } - if strings.Contains(workflow, "./scripts/release/verify-release-artifacts.sh") { - t.Fatal("release workflow still executes the sealed tag's artifact verifier") - } -} - func TestReleaseStaysDraftUntilFinalizedAssetDigestsMatch(t *testing.T) { t.Parallel() diff --git a/test/scripts/release_script_test.go b/test/scripts/release_script_test.go index b6046722..3d3a43de 100644 --- a/test/scripts/release_script_test.go +++ b/test/scripts/release_script_test.go @@ -30,18 +30,7 @@ func writeVersionedReleaseArchive(t *testing.T, dist, asset, version string) { if strings.HasSuffix(asset, ".zip") { binary = "dws.exe" } - content := []byte("fake release binary\n" + version + "\n") - if asset == "dws-linux-amd64.tar.gz" { - content = []byte(fmt.Sprintf(`#!/bin/sh -set -eu -if [ "$#" -ne 3 ] || [ "$1" != version ] || [ "$2" != --format ] || [ "$3" != json ]; then - printf 'unexpected version command\n' >&2 - exit 2 -fi -printf '{"version":"%s"}\n' -`, version)) - } - mustWriteFile(t, filepath.Join(stage, binary), content, 0o755) + mustWriteFile(t, filepath.Join(stage, binary), []byte("fake release binary\n"+version+"\n"), 0o755) if strings.HasSuffix(asset, ".zip") { mustRun(t, stage, "zip", "-q", filepath.Join(dist, asset), binary) return @@ -1959,18 +1948,9 @@ func TestReleaseArtifactVerificationRequiresEveryChecksum(t *testing.T) { writeReleaseChecksums(t, dist, true) cmd = exec.Command("sh", r.verify, "v1.2.3") cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist) - if output, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(output), "dws-windows-arm64.zip binary does not contain expected version marker v1.2.3") { + if output, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(output), "dws-windows-arm64.zip binary") { t.Fatalf("mixed-version archive was not rejected: err=%v\noutput:\n%s", err, output) } - - writeVersionedReleaseArchive(t, dist, "dws-windows-arm64.zip", "v1.2.3") - writeVersionedReleaseArchive(t, dist, "dws-linux-amd64.tar.gz", "v1.2.3-beta.1") - writeReleaseChecksums(t, dist, true) - cmd = exec.Command("sh", r.verify, "v1.2.3") - cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist) - if output, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(output), "dws-linux-amd64.tar.gz binary reports version v1.2.3-beta.1, expected v1.2.3") { - t.Fatalf("native prefixed version was not rejected: err=%v\noutput:\n%s", err, output) - } } func TestReleaseCommandValidatesThenPushesAnnotatedTag(t *testing.T) { From 29dceec5ce2fca4f53763952163dc07a3e1a14b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:48:03 +0800 Subject: [PATCH 07/11] 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. --- .github/workflows/release.yml | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ea442f8..93cee5f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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); From 8add2c00cfa6bf06b9860a6fc540f5f6b27ddc3e Mon Sep 17 00:00:00 2001 From: SCzheng <144249901+sczheng189@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:48:47 +0900 Subject: [PATCH 08/11] Revert "fix(release): check commit statuses in Code Admission gates (#732)" This reverts commit 29dceec5ce2fca4f53763952163dc07a3e1a14b1. --- .github/workflows/release.yml | 38 ----------------------------------- 1 file changed, 38 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93cee5f0..1ea442f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -358,25 +358,6 @@ 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); @@ -772,25 +753,6 @@ 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); From c14e24569c1a815ec4fe2cf0d177ad1b826ce9ea Mon Sep 17 00:00:00 2001 From: zhengyubai Date: Tue, 21 Jul 2026 19:49:18 +0900 Subject: [PATCH 09/11] fix(release): verify package versions from raw binaries --- scripts/release/verify-package-managers.sh | 10 ++++------ test/scripts/package_script_test.go | 23 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh index 886afbbc..2d517273 100755 --- a/scripts/release/verify-package-managers.sh +++ b/scripts/release/verify-package-managers.sh @@ -53,8 +53,6 @@ while [ "$#" -gt 0 ]; do esac done -[ -z "$EXPECTED_VERSION" ] || need_cmd strings - TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")" HOME_AGENT_PARENTS=" .claude @@ -145,8 +143,8 @@ verify_npm() { if [ -n "$EXPECTED_VERSION" ]; then vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws" need_file "$vendor_bin" - strings "$vendor_bin" | grep -Fqx "v$EXPECTED_VERSION" || \ - err "npm-installed binary does not embed expected version v$EXPECTED_VERSION" + LC_ALL=C grep -aFq "v$EXPECTED_VERSION" "$vendor_bin" || \ + err "npm-installed binary does not contain expected version marker v$EXPECTED_VERSION" EXPECTED_VERSION="$EXPECTED_VERSION" node -e ' const pkg = require(process.argv[1]); if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1); @@ -193,8 +191,8 @@ verify_brew() { [ -x "$prefix/bin/dws" ] || err "brew install did not create $prefix/bin/dws" "$prefix/bin/dws" --help >/dev/null if [ -n "$EXPECTED_VERSION" ]; then - strings "$prefix/bin/dws" | grep -Fqx "v$EXPECTED_VERSION" || \ - err "Homebrew-installed binary does not embed expected version v$EXPECTED_VERSION" + LC_ALL=C grep -aFq "v$EXPECTED_VERSION" "$prefix/bin/dws" || \ + err "Homebrew-installed binary does not contain expected version marker v$EXPECTED_VERSION" fi need_file "$prefix/share/dingtalk-workspace-cli-local/skills/dws/SKILL.md" } diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index bf060e3c..2c400a22 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -46,6 +46,29 @@ var expectedReleaseAdmissionContexts = []string{ "Mock MCP", } +func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) { + t.Parallel() + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-package-managers.sh")) + if err != nil { + t.Fatalf("Abs(verify-package-managers.sh) error = %v", err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", scriptPath, err) + } + script := string(data) + for _, binary := range []string{`"$vendor_bin"`, `"$prefix/bin/dws"`} { + want := `LC_ALL=C grep -aFq "v$EXPECTED_VERSION" ` + binary + if !strings.Contains(script, want) { + t.Errorf("package-manager verifier is missing raw binary marker check %q", want) + } + } + if strings.Contains(script, `strings "$vendor_bin"`) || strings.Contains(script, `strings "$prefix/bin/dws"`) { + t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line") + } +} + func seedDistArchive(t *testing.T, path string) { t.Helper() file, err := os.Create(path) From b047b2c3c90cdca6f33fbe92adc073ab3fbce01c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Tue, 21 Jul 2026 18:59:56 +0800 Subject: [PATCH 10/11] docs(changelog): fold post-beta.7 entries into v1.0.53 stable section --- CHANGELOG.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f5fc7f3..14b221a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,9 @@ 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. +This release promotes the validated `v1.0.53-beta.7` baseline 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. ### Added @@ -25,12 +21,14 @@ This release promotes the sealed `v1.0.53-beta.7` contents to stable. It adds en - **Personal event output contract** (#651) — `event consume` now emits event-specific top-level structured fields; scripts that consumed the former transport envelope must use the flat fields or select `-f raw`, while `--debug-raw-events` retains the diagnostic envelope. - **Guarded release lifecycle** — beta/stable publication now uses explicit promotion, immutable delivery proofs, protected recovery, and tag-bound optional OSS policy; an unprovisioned OSS mirror is sealed as `deferred` so GitHub, npm, and Homebrew are not blocked. +- **Relaxed stable promotion contract** (#729) — 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. ### Fixed - **Authentication and credential reliability** — organization-policy denials stop before mutation or polling, long-running clients reload and refresh access tokens consistently, concurrent credential writes are atomic, and Windows portable-auth commands fail before reading or writing unsupported credential bundles. - **Command validation and compatibility** — invalid Sheet/task targets fail locally, IM shortcuts preserve AI-tag and alias compatibility, and Aitable import uploads require and forward a positive file size. - **Release publication reliability** — GitHub draft publication is bound to one verified release ID and exact assets, preflight uses isolated installer worktrees, guarded local tags remain compatible, cloud planning fingerprints the actual allocated release refs, and npm channel verification waits for bounded registry propagation without moving tags. +- **Package-manager version verification** (#735) — npm-vendored and Homebrew-installed binaries are now verified by searching their raw bytes for the injected version marker, so a correctly versioned stable binary is no longer rejected when the short version marker coalesces with adjacent printable linker metadata; incorrect or missing markers still fail closed. ## [1.0.53-beta.7] - 2026-07-21 From 068d9ff2f551d2888084105a0a703471062f76c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Tue, 21 Jul 2026 19:25:14 +0800 Subject: [PATCH 11/11] fix(release): verify packaged artifact versions from raw binary bytes --- CHANGELOG.md | 2 +- scripts/release/verify-release-artifacts.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b221a6..b274ee9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ This release promotes the validated `v1.0.53-beta.7` baseline to stable. It adds - **Authentication and credential reliability** — organization-policy denials stop before mutation or polling, long-running clients reload and refresh access tokens consistently, concurrent credential writes are atomic, and Windows portable-auth commands fail before reading or writing unsupported credential bundles. - **Command validation and compatibility** — invalid Sheet/task targets fail locally, IM shortcuts preserve AI-tag and alias compatibility, and Aitable import uploads require and forward a positive file size. - **Release publication reliability** — GitHub draft publication is bound to one verified release ID and exact assets, preflight uses isolated installer worktrees, guarded local tags remain compatible, cloud planning fingerprints the actual allocated release refs, and npm channel verification waits for bounded registry propagation without moving tags. -- **Package-manager version verification** (#735) — npm-vendored and Homebrew-installed binaries are now verified by searching their raw bytes for the injected version marker, so a correctly versioned stable binary is no longer rejected when the short version marker coalesces with adjacent printable linker metadata; incorrect or missing markers still fail closed. +- **Package-manager version verification** (#735) — npm-vendored, Homebrew-installed, and packaged release binaries are now verified by searching their raw bytes for the injected version marker, so a correctly versioned stable binary is no longer rejected when the short version marker coalesces with adjacent printable linker metadata; incorrect or missing markers still fail closed. ## [1.0.53-beta.7] - 2026-07-21 diff --git a/scripts/release/verify-release-artifacts.sh b/scripts/release/verify-release-artifacts.sh index 0ddc65df..f7602cc3 100755 --- a/scripts/release/verify-release-artifacts.sh +++ b/scripts/release/verify-release-artifacts.sh @@ -94,7 +94,7 @@ verify_binary_version() { printf '%s does not contain the expected dws binary\n' "$asset" >&2 return 1 } - strings "$binary" | grep -Fqx "v$SEMVER" || { + LC_ALL=C grep -aFq "v$SEMVER" "$binary" || { printf '%s binary does not embed expected version v%s\n' "$asset" "$SEMVER" >&2 return 1 }