Compare commits

..
Author SHA1 Message Date
修雨 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
9 changed files with 168 additions and 51 deletions
+11 -6
View File
@@ -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"
+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=$?
+42 -4
View File
@@ -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"
+17
View File
@@ -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()
+73 -11
View File
@@ -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
@@ -1494,12 +1505,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 +1529,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 +1543,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)
}
})
}
@@ -1906,9 +1959,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) {