feat(release): add guarded prerelease and stable pipeline (#54)
* feat(release): add guarded prerelease and stable pipeline * feat(release): add guided dws-release entry (cherry picked from commit f7fa7b78f325f3574f0487862fc3e65bba5cdc96)
This commit is contained in:
@@ -39,6 +39,12 @@ func seedDistArtifacts(t *testing.T, distDir string, targets []string) {
|
||||
|
||||
for _, target := range targets {
|
||||
p := filepath.Join(distDir, target)
|
||||
if strings.HasPrefix(target, "dws-darwin-") && strings.HasSuffix(target, ".tar.gz") {
|
||||
stage := t.TempDir()
|
||||
mustWriteFile(t, filepath.Join(stage, "dws"), []byte("#!/bin/sh\nexit 0\n"), 0o755)
|
||||
mustRun(t, stage, "tar", "-czf", p, "dws")
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("fake-archive"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", p, err)
|
||||
}
|
||||
@@ -55,6 +61,13 @@ func seedDistArtifacts(t *testing.T, distDir string, targets []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func fakeCodesignPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
binDir := t.TempDir()
|
||||
mustWriteFile(t, filepath.Join(binDir, "codesign"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
return binDir + string(os.PathListSeparator) + os.Getenv("PATH")
|
||||
}
|
||||
|
||||
func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -78,8 +91,10 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"DWS_PACKAGE_VERSION=v1.2.3",
|
||||
"DWS_PACKAGE_DIST_DIR="+distDir,
|
||||
"DWS_RELEASE_BASE_URL=https://downloads.example.com/dws/releases/v1.2.3",
|
||||
"PATH="+fakeCodesignPath(t),
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -107,6 +122,8 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
|
||||
formulaText := string(formulaData)
|
||||
for _, want := range []string{
|
||||
"class DingtalkWorkspaceCliLocal < Formula",
|
||||
"version \"1.2.3\"",
|
||||
"skill_home_override = \"\"",
|
||||
"resource \"skills\" do",
|
||||
"DingTalk Workspace CLI",
|
||||
} {
|
||||
@@ -123,6 +140,7 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
|
||||
releaseFormulaText := string(releaseFormulaData)
|
||||
for _, want := range []string{
|
||||
"class DingtalkWorkspaceCli < Formula",
|
||||
"version \"1.2.3\"",
|
||||
"https://downloads.example.com/dws/releases/v1.2.3/" + archiveName,
|
||||
"https://downloads.example.com/dws/releases/v1.2.3/dws-skills.zip",
|
||||
} {
|
||||
@@ -165,13 +183,25 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify checksums.txt was updated to include skills zip
|
||||
// Re-running post packaging must replace, not duplicate, the skills checksum.
|
||||
cmd = exec.Command("sh", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"DWS_PACKAGE_VERSION=v1.2.3",
|
||||
"DWS_PACKAGE_DIST_DIR="+distDir,
|
||||
"DWS_RELEASE_BASE_URL=https://downloads.example.com/dws/releases/v1.2.3",
|
||||
"PATH="+fakeCodesignPath(t),
|
||||
)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("second post-goreleaser.sh error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
|
||||
// Verify checksums.txt includes exactly one skills zip entry.
|
||||
checksumsData, err := os.ReadFile(filepath.Join(distDir, "checksums.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(checksums.txt) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(checksumsData), "dws-skills.zip") {
|
||||
t.Fatalf("checksums.txt missing dws-skills.zip entry:\n%s", string(checksumsData))
|
||||
if count := strings.Count(string(checksumsData), "dws-skills.zip"); count != 1 {
|
||||
t.Fatalf("checksums.txt dws-skills.zip count = %d, want 1:\n%s", count, checksumsData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,8 +230,10 @@ func TestPostGoreleaserAllPlatformNpmAssets(t *testing.T) {
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"DWS_PACKAGE_VERSION=v9.9.9",
|
||||
"DWS_PACKAGE_DIST_DIR="+distDir,
|
||||
"DWS_RELEASE_BASE_URL=https://downloads.example.com/dws/releases/v9.9.9",
|
||||
"PATH="+fakeCodesignPath(t),
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -216,7 +248,7 @@ func TestPostGoreleaserAllPlatformNpmAssets(t *testing.T) {
|
||||
}
|
||||
|
||||
packageAssetsDir := filepath.Join(distDir, "npm", "dingtalk-workspace-cli", "assets")
|
||||
for _, rel := range append(allArchives, "dws-skills.zip") {
|
||||
for _, rel := range append(allArchives, "dws-skills.zip", "checksums.txt") {
|
||||
if _, err := os.Stat(filepath.Join(packageAssetsDir, rel)); err != nil {
|
||||
t.Fatalf("npm asset missing %q: %v", rel, err)
|
||||
}
|
||||
@@ -280,8 +312,10 @@ func TestPostGoreleaserSkillsZipLayout(t *testing.T) {
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"DWS_PACKAGE_VERSION=v0.0.0-test",
|
||||
"DWS_PACKAGE_DIST_DIR="+distDir,
|
||||
"DWS_RELEASE_BASE_URL=https://downloads.example.com/dws/releases/v0.0.0",
|
||||
"PATH="+fakeCodesignPath(t),
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type releaseEntryFixture struct {
|
||||
repo *releaseTestRepo
|
||||
entry string
|
||||
log string
|
||||
}
|
||||
|
||||
func newReleaseEntryFixture(t *testing.T) *releaseEntryFixture {
|
||||
t.Helper()
|
||||
r := newReleaseTestRepo(t)
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
entry := filepath.Join(r.root, "scripts", "release", "dws-release.sh")
|
||||
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
|
||||
releaseCopyFile(t, filepath.Join(sourceRoot, "scripts", "release", "dws-release.sh"), entry, 0o755)
|
||||
fakeDelegate := func(name string) []byte {
|
||||
return []byte("#!/bin/sh\nset -eu\nprintf '" + name + "' >> \"$DWS_RELEASE_CALL_LOG\"\nfor arg in \"$@\"; do printf '\\t%s' \"$arg\" >> \"$DWS_RELEASE_CALL_LOG\"; done\nprintf '\\n' >> \"$DWS_RELEASE_CALL_LOG\"\nexit \"${DWS_RELEASE_FAKE_EXIT:-0}\"\n")
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), fakeDelegate("prepare"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "release.sh"), fakeDelegate("release"), 0o755)
|
||||
r.commitAndPush(t, "install unified release entry fixture")
|
||||
return &releaseEntryFixture{repo: r, entry: entry, log: filepath.Join(t.TempDir(), "calls.log")}
|
||||
}
|
||||
|
||||
func (f *releaseEntryFixture) run(t *testing.T, extraEnv []string, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("sh", append([]string{f.entry}, args...)...)
|
||||
cmd.Dir = f.repo.root
|
||||
cmd.Env = append(os.Environ(), "DWS_RELEASE_CALL_LOG="+f.log)
|
||||
cmd.Env = append(cmd.Env, extraEnv...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
func (f *releaseEntryFixture) configureRemote(t *testing.T, remote string) {
|
||||
t.Helper()
|
||||
output, err := f.run(t, nil, "config", "--remote", remote)
|
||||
if err != nil {
|
||||
t.Fatalf("config remote error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *releaseEntryFixture) callLog(t *testing.T) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(f.log)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ""
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(call log) error = %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryPreparesMissingChangelogAndStops(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prerelease",
|
||||
args: []string{"v1.0.1-beta.1", "--remote", "origin", "--publish"},
|
||||
want: "prepare\tprerelease\tv1.0.1-beta.1\n",
|
||||
},
|
||||
{
|
||||
name: "stable",
|
||||
args: []string{"v1.0.1", "--from-beta", "v1.0.1-beta.1", "--remote", "origin"},
|
||||
want: "prepare\tstable\tv1.0.1\t--from-beta\tv1.0.1-beta.1\n",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
f.configureRemote(t, "origin")
|
||||
output, err := f.run(t, nil, test.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("dws-release prepare error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if got := f.callLog(t); got != test.want {
|
||||
t.Fatalf("delegate calls = %q, want %q", got, test.want)
|
||||
}
|
||||
if !strings.Contains(output, "publishing intentionally stopped") {
|
||||
t.Fatalf("prepare output did not make the stop boundary clear:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryRoutesOneGuardedCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
section string
|
||||
args []string
|
||||
wantCall string
|
||||
}{
|
||||
{
|
||||
name: "check prerelease",
|
||||
section: betaSection(),
|
||||
args: []string{"v1.0.1-beta.1", "--remote", "origin"},
|
||||
wantCall: "release\tprerelease\tv1.0.1-beta.1\t--remote\torigin\n",
|
||||
},
|
||||
{
|
||||
name: "publish prerelease keeps confirmation",
|
||||
section: betaSection(),
|
||||
args: []string{"v1.0.1-beta.1", "--remote", "origin", "--publish"},
|
||||
wantCall: "release\tprerelease\tv1.0.1-beta.1\t--remote\torigin\t--publish\n",
|
||||
},
|
||||
{
|
||||
name: "check stable",
|
||||
section: stableSection(),
|
||||
args: []string{"v1.0.1", "--from-beta", "v1.0.1-beta.1", "--remote", "origin"},
|
||||
wantCall: "release\tstable\tv1.0.1\t--remote\torigin\t--from-beta\tv1.0.1-beta.1\n",
|
||||
},
|
||||
{
|
||||
name: "publish stable keeps confirmation",
|
||||
section: stableSection(),
|
||||
args: []string{"v1.0.1", "--from-beta", "v1.0.1-beta.1", "--remote", "origin", "--publish"},
|
||||
wantCall: "release\tstable\tv1.0.1\t--remote\torigin\t--from-beta\tv1.0.1-beta.1\t--publish\n",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
f.configureRemote(t, "origin")
|
||||
mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(test.section)), 0o644)
|
||||
f.repo.commitAndPush(t, "add candidate changelog")
|
||||
output, err := f.run(t, nil, test.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("dws-release route error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if got := f.callLog(t); got != test.wantCall {
|
||||
t.Fatalf("delegate calls = %q, want %q\noutput:\n%s", got, test.wantCall, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryRejectsInvalidInvocationBeforeDelegation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "invalid version", args: []string{"1.0.1"}, want: "invalid release version"},
|
||||
{name: "stable missing beta", args: []string{"v1.0.1"}, want: "requires --from-beta"},
|
||||
{name: "prerelease with beta", args: []string{"v1.0.1-beta.1", "--from-beta", "v1.0.1-beta.1"}, want: "only valid for stable"},
|
||||
{name: "conflicting mode", args: []string{"v1.0.1-beta.1", "--check", "--publish"}, want: "cannot be used together"},
|
||||
{name: "yes bypass", args: []string{"v1.0.1-beta.1", "--publish", "--yes"}, want: "unknown argument"},
|
||||
{name: "unknown flag", args: []string{"v1.0.1-beta.1", "--force"}, want: "unknown argument"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
output, err := f.run(t, nil, test.args...)
|
||||
if err == nil || !strings.Contains(output, test.want) {
|
||||
t.Fatalf("invalid invocation: err=%v, want=%q\noutput:\n%s", err, test.want, output)
|
||||
}
|
||||
if got := f.callLog(t); got != "" {
|
||||
t.Fatalf("invalid invocation delegated work: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryUsesConfiguredRemote(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
f.configureRemote(t, "origin")
|
||||
mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
f.repo.commitAndPush(t, "add beta changelog")
|
||||
output, err := f.run(t, nil, "v1.0.1-beta.1")
|
||||
if err != nil {
|
||||
t.Fatalf("configured remote route error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
want := "release\tprerelease\tv1.0.1-beta.1\t--remote\torigin\n"
|
||||
if got := f.callLog(t); got != want {
|
||||
t.Fatalf("delegate calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryFailsClosedWithoutRemote(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
f.repo.commitAndPush(t, "add beta changelog")
|
||||
output, err := f.run(t, nil, "v1.0.1-beta.1")
|
||||
if err == nil || !strings.Contains(output, "release remote is not configured") {
|
||||
t.Fatalf("missing remote did not fail closed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if got := f.callLog(t); got != "" {
|
||||
t.Fatalf("missing remote delegated work: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryPropagatesDelegateFailure(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
f.configureRemote(t, "origin")
|
||||
mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
f.repo.commitAndPush(t, "add beta changelog")
|
||||
output, err := f.run(t, []string{"DWS_RELEASE_FAKE_EXIT=23"}, "v1.0.1-beta.1", "--remote", "origin")
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 23 {
|
||||
t.Fatalf("delegate error was not propagated: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryFastForwardsCleanMain(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
f.configureRemote(t, "origin")
|
||||
other := filepath.Join(t.TempDir(), "other")
|
||||
mustRun(t, filepath.Dir(other), "git", "clone", "-b", "main", f.repo.remote, other)
|
||||
mustRun(t, other, "git", "config", "user.name", "Release Test")
|
||||
mustRun(t, other, "git", "config", "user.email", "release-test@example.com")
|
||||
mustWriteFile(t, filepath.Join(other, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
mustRun(t, other, "git", "add", "CHANGELOG.md")
|
||||
mustRun(t, other, "git", "commit", "-m", "add remote beta changelog")
|
||||
mustRun(t, other, "git", "push", "origin", "main")
|
||||
|
||||
output, err := f.run(t, nil, "v1.0.1-beta.1", "--remote", "origin")
|
||||
if err != nil {
|
||||
t.Fatalf("fast-forward route error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
localHead := strings.TrimSpace(mustOutput(t, f.repo.root, "git", "rev-parse", "HEAD"))
|
||||
remoteHead := strings.TrimSpace(mustOutput(t, f.repo.root, "git", "rev-parse", "refs/remotes/origin/main"))
|
||||
if localHead != remoteHead {
|
||||
t.Fatalf("local main was not fast-forwarded: local=%s remote=%s", localHead, remoteHead)
|
||||
}
|
||||
if got := f.callLog(t); !strings.HasPrefix(got, "release\tprerelease\tv1.0.1-beta.1") {
|
||||
t.Fatalf("fast-forward did not reach guarded delegate: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryRejectsRetargetedRemote(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
f.configureRemote(t, "origin")
|
||||
mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
f.repo.commitAndPush(t, "add beta changelog")
|
||||
otherRemote := filepath.Join(t.TempDir(), "other.git")
|
||||
mustRun(t, f.repo.root, "git", "init", "--bare", otherRemote)
|
||||
mustRun(t, f.repo.root, "git", "remote", "set-url", "origin", otherRemote)
|
||||
|
||||
output, err := f.run(t, nil, "v1.0.1-beta.1")
|
||||
if err == nil || !strings.Contains(output, "changed repository identity") {
|
||||
t.Fatalf("retargeted remote was not rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if got := f.callLog(t); got != "" {
|
||||
t.Fatalf("retargeted remote delegated work: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryRejectsAuthorityOverrides(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
want string
|
||||
}{
|
||||
{name: "non github remote", env: "DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE=1", want: "test-only"},
|
||||
{name: "official tags URL", env: "DWS_RELEASE_OFFICIAL_TAGS_URL=https://example.com/tags.git", want: "cannot override"},
|
||||
{name: "official repository", env: "DWS_RELEASE_OFFICIAL_REPOSITORY=other/repo", want: "cannot override"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
output, err := f.run(t, []string{test.env}, "v1.0.1-beta.1")
|
||||
if err == nil || !strings.Contains(output, test.want) {
|
||||
t.Fatalf("authority override was not rejected: err=%v, want=%q\noutput:\n%s", err, test.want, output)
|
||||
}
|
||||
if got := f.callLog(t); got != "" {
|
||||
t.Fatalf("authority override delegated work: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandRejectsNonInteractivePublishWithoutYes(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "install release automation")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish",
|
||||
)
|
||||
if err == nil || !strings.Contains(output, "interactive confirmation is unavailable") {
|
||||
t.Fatalf("non-interactive publish did not require confirmation: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if got := mustOutput(t, r.root, "git", "tag", "--list", "v1.0.1-beta.1"); got != "" {
|
||||
t.Fatalf("rejected non-interactive publish created a tag: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,924 @@
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var releasePlatformAssets = []string{
|
||||
"dws-darwin-amd64.tar.gz",
|
||||
"dws-darwin-arm64.tar.gz",
|
||||
"dws-linux-amd64.tar.gz",
|
||||
"dws-linux-arm64.tar.gz",
|
||||
"dws-windows-amd64.zip",
|
||||
"dws-windows-arm64.zip",
|
||||
}
|
||||
|
||||
func writeVersionedReleaseArchive(t *testing.T, dist, asset, version string) {
|
||||
t.Helper()
|
||||
stage := t.TempDir()
|
||||
binary := "dws"
|
||||
if strings.HasSuffix(asset, ".zip") {
|
||||
binary = "dws.exe"
|
||||
}
|
||||
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
|
||||
}
|
||||
mustRun(t, stage, "tar", "-czf", filepath.Join(dist, asset), binary)
|
||||
}
|
||||
|
||||
func writeReleaseChecksums(t *testing.T, dist string, includeSkills bool) {
|
||||
t.Helper()
|
||||
assets := append([]string{}, releasePlatformAssets...)
|
||||
if includeSkills {
|
||||
assets = append(assets, "dws-skills.zip")
|
||||
}
|
||||
sort.Strings(assets)
|
||||
var lines []string
|
||||
for _, asset := range assets {
|
||||
data, err := os.ReadFile(filepath.Join(dist, asset))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", asset, err)
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%x %s", sha256.Sum256(data), asset))
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(dist, "checksums.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0o644)
|
||||
}
|
||||
|
||||
func seedVersionedReleaseArtifacts(t *testing.T, dist, version string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dist, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(%s) error = %v", dist, err)
|
||||
}
|
||||
for _, asset := range releasePlatformAssets {
|
||||
writeVersionedReleaseArchive(t, dist, asset, version)
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(dist, "dws-skills.zip"), []byte("fake skills\n"), 0o644)
|
||||
writeReleaseChecksums(t, dist, true)
|
||||
}
|
||||
|
||||
type releaseTestRepo struct {
|
||||
root string
|
||||
remote string
|
||||
contract string
|
||||
prepare string
|
||||
releaseCmd string
|
||||
lib string
|
||||
verify string
|
||||
}
|
||||
|
||||
func newReleaseTestRepo(t *testing.T) *releaseTestRepo {
|
||||
t.Helper()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "work")
|
||||
remote := filepath.Join(base, "remote.git")
|
||||
mustRun(t, base, "git", "init", "--bare", remote)
|
||||
mustRun(t, base, "git", "init", "-b", "main", root)
|
||||
mustRun(t, root, "git", "config", "user.name", "Release Test")
|
||||
mustRun(t, root, "git", "config", "user.email", "release-test@example.com")
|
||||
|
||||
mustWriteFile(t, filepath.Join(root, "CHANGELOG.md"), []byte(releaseChangelog()), 0o644)
|
||||
mustWriteFile(t, filepath.Join(root, "seed.txt"), []byte("initial\n"), 0o644)
|
||||
mustRun(t, root, "git", "add", ".")
|
||||
mustRun(t, root, "git", "commit", "-m", "initial stable")
|
||||
mustRun(t, root, "git", "tag", "-a", "v1.0.0", "-m", "Release v1.0.0")
|
||||
mustRun(t, root, "git", "remote", "add", "origin", remote)
|
||||
mustRun(t, root, "git", "push", "-u", "origin", "main")
|
||||
mustRun(t, root, "git", "push", "origin", "v1.0.0")
|
||||
|
||||
return &releaseTestRepo{
|
||||
root: root,
|
||||
remote: remote,
|
||||
contract: filepath.Join(sourceRoot, "scripts", "release", "release-contract.sh"),
|
||||
prepare: filepath.Join(sourceRoot, "scripts", "release", "prepare-changelog.sh"),
|
||||
releaseCmd: filepath.Join(sourceRoot, "scripts", "release", "release.sh"),
|
||||
lib: filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh"),
|
||||
verify: filepath.Join(sourceRoot, "scripts", "release", "verify-release-artifacts.sh"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseVersionOrdering(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
lib := filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh")
|
||||
tests := []struct {
|
||||
candidate string
|
||||
baseline string
|
||||
greater bool
|
||||
}{
|
||||
{candidate: "v1.0.2", baseline: "v1.0.1", greater: true},
|
||||
{candidate: "v1.1.0-beta.1", baseline: "v1.0.9-beta.9", greater: true},
|
||||
{candidate: "v1.0.1-beta.2", baseline: "v1.0.1-beta.1", greater: true},
|
||||
{candidate: "v1.0.1", baseline: "v1.0.1-beta.9", greater: true},
|
||||
{candidate: "v1.0.1-beta.1", baseline: "v1.0.1-beta.2", greater: false},
|
||||
{candidate: "v1.0.1-beta.1", baseline: "v1.0.1", greater: false},
|
||||
{candidate: "v1.0.1", baseline: "v1.0.1", greater: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
cmd := exec.Command("sh", "-c", `. "$1"; release_version_is_greater "$2" "$3"`, "sh", lib, test.candidate, test.baseline)
|
||||
err := cmd.Run()
|
||||
if (err == nil) != test.greater {
|
||||
t.Fatalf("release_version_is_greater(%s, %s) error = %v, want greater=%v", test.candidate, test.baseline, err, test.greater)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseNpmPackingIgnoresLifecycleScripts(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
packageDir := filepath.Join(t.TempDir(), "package")
|
||||
marker := filepath.Join(t.TempDir(), "prepack-ran")
|
||||
manifest := fmt.Sprintf(`{
|
||||
"name": "dingtalk-workspace-cli",
|
||||
"version": "1.2.3",
|
||||
"scripts": {"prepack": "touch %s"},
|
||||
"files": ["README.md"]
|
||||
}
|
||||
`, marker)
|
||||
mustWriteFile(t, filepath.Join(packageDir, "package.json"), []byte(manifest), 0o644)
|
||||
mustWriteFile(t, filepath.Join(packageDir, "README.md"), []byte("test package\n"), 0o644)
|
||||
outputTarball := filepath.Join(t.TempDir(), "package.tgz")
|
||||
cmd := exec.Command("sh", filepath.Join(sourceRoot, "scripts", "release", "pack-npm-package.sh"), packageDir, outputTarball)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("pack-npm-package error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimSpace(string(output)), "sha512-") {
|
||||
t.Fatalf("pack output is not an integrity value: %s", output)
|
||||
}
|
||||
if _, err := os.Stat(outputTarball); err != nil {
|
||||
t.Fatalf("packed tarball missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("npm prepack lifecycle unexpectedly ran: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseNpmStagingRejectsUnexpectedLifecycleScripts(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
source := filepath.Join(root, "source")
|
||||
dist := filepath.Join(root, "dist")
|
||||
mustWriteFile(t, filepath.Join(source, "build", "npm", "install.js"), []byte("\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(source, "build", "npm", "bin", "dws.js"), []byte("\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(source, "build", "npm", "README.md"), []byte("test\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(source, "build", "npm", "package.json.tmpl"), []byte(`{
|
||||
"name": "dingtalk-workspace-cli",
|
||||
"version": "__VERSION__",
|
||||
"bin": {"dws": "./bin/dws.js"},
|
||||
"scripts": {"postinstall": "node install.js", "prepublishOnly": "node steal.js"}
|
||||
}
|
||||
`), 0o644)
|
||||
cmd := exec.Command("sh", filepath.Join(sourceRoot, "scripts", "release", "stage-npm-package.sh"), "v1.2.3")
|
||||
cmd.Env = append(os.Environ(), "DWS_PACKAGE_SOURCE_ROOT="+source, "DWS_PACKAGE_DIST_DIR="+dist)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil || !strings.Contains(string(output), "unexpected npm lifecycle scripts") {
|
||||
t.Fatalf("unexpected lifecycle script was not rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseGitHubAssetSetIsExact(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
binDir := t.TempDir()
|
||||
fakeGH := filepath.Join(binDir, "gh")
|
||||
mustWriteFile(t, fakeGH, []byte("#!/bin/sh\nset -eu\nprintf '%s\\n' \"$GH_ASSETS\"\n"), 0o755)
|
||||
exact := strings.Join([]string{
|
||||
"dws-windows-arm64.zip",
|
||||
"dws-linux-amd64.tar.gz",
|
||||
"checksums.txt",
|
||||
"dws-skills.zip",
|
||||
"dws-darwin-amd64.tar.gz",
|
||||
"dws-windows-amd64.zip",
|
||||
"dws-linux-arm64.tar.gz",
|
||||
"dws-darwin-arm64.tar.gz",
|
||||
}, "\n")
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "verify-github-release-assets.sh")
|
||||
run := func(assets string) (string, error) {
|
||||
cmd := exec.Command("sh", script, "v1.2.3")
|
||||
cmd.Env = []string{
|
||||
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
"HOME=" + t.TempDir(),
|
||||
"GITHUB_REPOSITORY=owner/repo",
|
||||
"GH_ASSETS=" + assets,
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
if output, err := run(exact); err != nil {
|
||||
t.Fatalf("exact GitHub assets rejected: %v\n%s", err, output)
|
||||
}
|
||||
if output, err := run(exact + "\nmalware.exe"); err == nil || !strings.Contains(output, "exactly the supported assets") {
|
||||
t.Fatalf("extra GitHub asset was not rejected: err=%v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDeliveredStableRequiresSuccessfulPublicDelivery(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
r := newReleaseTestRepo(t)
|
||||
commit := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "v1.0.0^{commit}"))
|
||||
binDir := t.TempDir()
|
||||
fakeCurl := filepath.Join(binDir, "curl")
|
||||
mustWriteFile(t, fakeCurl, []byte(`#!/bin/sh
|
||||
set -eu
|
||||
for arg in "$@"; do last="$arg"; done
|
||||
case "$last" in
|
||||
*/releases/tags/*)
|
||||
printf '{"tag_name":"v1.0.0","draft":false,"prerelease":false}\n'
|
||||
;;
|
||||
*/actions/workflows/release.yml/runs*)
|
||||
printf '{"workflow_runs":[{"head_sha":"%s","head_branch":"v1.0.0","conclusion":"%s"}]}\n' "$EXPECTED_COMMIT" "$RUN_CONCLUSION"
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
`), 0o755)
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "verify-delivered-stable.sh")
|
||||
run := func(conclusion string) (string, error) {
|
||||
cmd := exec.Command("sh", script, "v1.0.0", commit)
|
||||
cmd.Dir = r.root
|
||||
cmd.Env = []string{
|
||||
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
"HOME=" + t.TempDir(),
|
||||
"DWS_RELEASE_OFFICIAL_REPOSITORY=owner/repo",
|
||||
"EXPECTED_COMMIT=" + commit,
|
||||
"RUN_CONCLUSION=" + conclusion,
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
if output, err := run("success"); err != nil {
|
||||
t.Fatalf("delivered stable was rejected: %v\n%s", err, output)
|
||||
}
|
||||
if output, err := run("failure"); err == nil || !strings.Contains(output, "did not complete successfully") {
|
||||
t.Fatalf("orphan stable tag was not rejected: err=%v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func releaseChangelog(sections ...string) string {
|
||||
text := "# Changelog\n\n## [Unreleased]\n\n"
|
||||
for _, section := range sections {
|
||||
text += section
|
||||
if !strings.HasSuffix(text, "\n\n") {
|
||||
text += "\n"
|
||||
}
|
||||
}
|
||||
text += "## [1.0.0] - 2026-07-01\n\n### Changed\n\n- Initial release.\n"
|
||||
return text
|
||||
}
|
||||
|
||||
func betaSection() string {
|
||||
return "## [1.0.1-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate the sealed beta candidate.\n\n"
|
||||
}
|
||||
|
||||
func stableSection() string {
|
||||
return "## [1.0.1] - 2026-07-11\n\nThis release promotes the sealed `v1.0.1-beta.1` contents to stable.\n\n### Changed\n\n- Publish the validated candidate to the stable channel.\n\n"
|
||||
}
|
||||
|
||||
func (r *releaseTestRepo) commitAndPush(t *testing.T, message string) {
|
||||
t.Helper()
|
||||
mustRun(t, r.root, "git", "add", ".")
|
||||
mustRun(t, r.root, "git", "commit", "-m", message)
|
||||
mustRun(t, r.root, "git", "push", "origin", "main")
|
||||
}
|
||||
|
||||
func (r *releaseTestRepo) seedBeta(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "feature.txt"), []byte("sealed candidate\n"), 0o644)
|
||||
r.commitAndPush(t, "prepare beta")
|
||||
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", "push", "origin", "v1.0.1-beta.1")
|
||||
}
|
||||
|
||||
func runReleaseScript(t *testing.T, workdir, script string, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("sh", append([]string{script}, args...)...)
|
||||
cmd.Dir = workdir
|
||||
cmd.Env = append(os.Environ(), "DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE=1")
|
||||
if remote, err := exec.Command("git", "-C", workdir, "remote", "get-url", "origin").Output(); err == nil {
|
||||
cmd.Env = append(cmd.Env, "DWS_RELEASE_OFFICIAL_TAGS_URL="+strings.TrimSpace(string(remote)))
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
func TestReleaseContractAcceptsPrereleaseAndWritesNotes(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "prepare beta changelog")
|
||||
|
||||
notes := filepath.Join(t.TempDir(), "notes.md")
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--context", "local",
|
||||
"--remote", "origin",
|
||||
"--notes-output", notes,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("release contract error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "Release contract passed") {
|
||||
t.Fatalf("contract output missing success:\n%s", output)
|
||||
}
|
||||
notesData, err := os.ReadFile(notes)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(notes) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(notesData), "Validate the sealed beta candidate") {
|
||||
t.Fatalf("notes did not come from exact changelog section:\n%s", notesData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseContractRejectsInvalidVersionChannelPairs(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
tests := []struct {
|
||||
channel string
|
||||
version string
|
||||
}{
|
||||
{channel: "prerelease", version: "v1.0.1"},
|
||||
{channel: "stable", version: "v1.0.1-beta.1"},
|
||||
{channel: "prerelease", version: "v1.0.1-rc.1"},
|
||||
{channel: "prerelease", version: "v1.0.1-preview"},
|
||||
{channel: "stable", version: "1.0.1"},
|
||||
{channel: "stable", version: "v01.0.1"},
|
||||
{channel: "prerelease", version: "v1.0.1-beta.0"},
|
||||
{channel: "prerelease", version: "v1.0.1-beta.2"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.channel+"_"+strings.ReplaceAll(test.version, ".", "_"), func(t *testing.T) {
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", test.channel,
|
||||
"--version", test.version,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid pair unexpectedly passed:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseContractRejectsBadChangelogSections(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sections string
|
||||
want string
|
||||
}{
|
||||
{name: "missing", sections: "", want: "exactly one section"},
|
||||
{name: "empty", sections: "## [1.0.1-beta.1] - 2026-07-11\n\n", want: "must contain release notes"},
|
||||
{name: "placeholder", sections: "## [1.0.1-beta.1] - 2026-07-11\n\n### Changed\n\n- TODO: write this.\n\n", want: "TODO/TBD"},
|
||||
{name: "duplicate", sections: betaSection() + betaSection(), want: "exactly one section"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(test.sections)), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "candidate.txt"), []byte(test.name+"\n"), 0o644)
|
||||
r.commitAndPush(t, "write candidate changelog")
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("bad changelog unexpectedly passed:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, test.want) {
|
||||
t.Fatalf("output missing %q:\n%s", test.want, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseContractRejectsDirtyOrUnsyncedMain(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "prepare beta")
|
||||
|
||||
mustWriteFile(t, filepath.Join(r.root, "dirty.txt"), []byte("dirty\n"), 0o644)
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err == nil || !strings.Contains(output, "worktree must be clean") {
|
||||
t.Fatalf("dirty worktree was not blocked: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
|
||||
mustRun(t, r.root, "git", "add", "dirty.txt")
|
||||
mustRun(t, r.root, "git", "commit", "-m", "local commit not pushed")
|
||||
output, err = runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err == nil || !strings.Contains(output, "must exactly match origin/main") {
|
||||
t.Fatalf("unsynced main was not blocked: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseContractStablePromotionAllowsOnlyChangelogDiff(t *testing.T) {
|
||||
t.Run("sealed", 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")
|
||||
|
||||
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("sealed stable promotion error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("source drift", 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")
|
||||
|
||||
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("drifted stable promotion unexpectedly passed:\n%s", 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleaseContractCIReadsStableBaselineFromAnnotatedTag(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")
|
||||
mustRun(t, r.root, "git", "tag", "-a", "v1.0.1", "-m", "Release v1.0.1", "-m", "Channel: stable", "-m", "From-Beta: v1.0.1-beta.1")
|
||||
mustRun(t, r.root, "git", "push", "origin", "v1.0.1")
|
||||
mustRun(t, r.root, "git", "checkout", "--detach", "v1.0.1")
|
||||
|
||||
metadata := filepath.Join(t.TempDir(), "metadata")
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "stable",
|
||||
"--version", "v1.0.1",
|
||||
"--context", "ci",
|
||||
"--remote", "origin",
|
||||
"--metadata-output", metadata,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CI stable contract error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
metadataData, err := os.ReadFile(metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(metadata) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(metadataData), "from_beta=v1.0.1-beta.1") {
|
||||
t.Fatalf("metadata missing annotated tag baseline:\n%s", metadataData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseContractCIRejectsLightweightTag(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "prepare beta")
|
||||
mustRun(t, r.root, "git", "tag", "v1.0.1-beta.1")
|
||||
mustRun(t, r.root, "git", "push", "origin", "v1.0.1-beta.1")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--context", "ci",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("lightweight release tag unexpectedly passed:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "must be annotated") {
|
||||
t.Fatalf("output missing annotated-tag guidance:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseContractCIAllowsMainToAdvanceAfterTagSeal(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
r.seedBeta(t)
|
||||
mustWriteFile(t, filepath.Join(r.root, "after-seal.txt"), []byte("main advanced\n"), 0o644)
|
||||
r.commitAndPush(t, "advance main after beta seal")
|
||||
mustRun(t, r.root, "git", "checkout", "--detach", "v1.0.1-beta.1")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--context", "ci",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("sealed tag should remain valid after main advances: %v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePrepareChangelogCreatesGuardedTemplate(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
|
||||
releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755)
|
||||
r.commitAndPush(t, "install changelog preparation")
|
||||
|
||||
cmd := exec.Command("sh", filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), "prerelease", "v1.0.1-beta.1")
|
||||
cmd.Dir = r.root
|
||||
cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("prepare changelog error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
changelog, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(changelog), "## [1.0.1-beta.1] - 2026-07-11") || !strings.Contains(string(changelog), "TODO") {
|
||||
t.Fatalf("prepared changelog missing guarded template:\n%s", changelog)
|
||||
}
|
||||
|
||||
r.commitAndPush(t, "commit unfinished release notes")
|
||||
contractOutput, contractErr := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if contractErr == nil || !strings.Contains(contractOutput, "TODO/TBD") {
|
||||
t.Fatalf("unfinished template was not blocked: err=%v\noutput:\n%s", contractErr, contractOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseMirrorUsesChannelSpecificPointer(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "sync-to-oss.sh")
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
version string
|
||||
channel string
|
||||
want string
|
||||
doNotWant string
|
||||
installer bool
|
||||
}{
|
||||
{name: "prerelease", version: "v1.2.3-beta.1", channel: "prerelease", want: "/beta.txt", doNotWant: "/latest.txt", installer: false},
|
||||
{name: "stable", version: "v1.2.3", channel: "stable", want: "/latest.txt", doNotWant: "/beta.txt", installer: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dist := filepath.Join(root, "dist")
|
||||
seedVersionedReleaseArtifacts(t, dist, test.version)
|
||||
logPath := filepath.Join(root, "ossutil.log")
|
||||
fakeOSSUtil := filepath.Join(root, "ossutil")
|
||||
mustWriteFile(t, fakeOSSUtil, []byte("#!/bin/sh\nset -eu\nprevious=\npenultimate=\nfor arg in \"$@\"; do penultimate=\"$previous\"; previous=\"$arg\"; done\nlast=\"$previous\"\ncase \"$penultimate\" in oss://*) echo 'ErrorCode=NoSuchKey' >&2; exit 1 ;; esac\nprintf '%s\\n' \"$last\" >> \"$OSSUTIL_LOG\"\n"), 0o755)
|
||||
|
||||
cmd := exec.Command("bash", script)
|
||||
cmd.Dir = sourceRoot
|
||||
cmd.Env = append(os.Environ(),
|
||||
"DIST_DIR="+dist,
|
||||
"VERSION="+test.version,
|
||||
"DWS_RELEASE_CHANNEL="+test.channel,
|
||||
"OSS_ACCESS_KEY_ID=test-key",
|
||||
"OSS_ACCESS_KEY_SECRET=test-secret",
|
||||
"OSS_ENDPOINT=https://oss.example.com",
|
||||
"OSS_BUCKET=test-bucket",
|
||||
"OSS_PREFIX=dws",
|
||||
"OSSUTIL="+fakeOSSUtil,
|
||||
"OSSUTIL_LOG="+logPath,
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("sync-to-oss error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
logData, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(ossutil log) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(logData), test.want) {
|
||||
t.Fatalf("mirror log missing %q:\n%s", test.want, logData)
|
||||
}
|
||||
if strings.Contains(string(logData), test.doNotWant) {
|
||||
t.Fatalf("mirror log unexpectedly contains %q:\n%s", test.doNotWant, logData)
|
||||
}
|
||||
hasInstaller := strings.Contains(string(logData), "/dws/install.sh")
|
||||
if hasInstaller != test.installer {
|
||||
t.Fatalf("installer upload = %v, want %v:\n%s", hasInstaller, test.installer, logData)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseMirrorFailsClosedWhenPointerCannotBeRead(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "sync-to-oss.sh")
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
readAction string
|
||||
want string
|
||||
}{
|
||||
{name: "transport error", readAction: "echo 'connection timed out' >&2; exit 7", want: "Could not read OSS beta.txt"},
|
||||
{name: "empty pointer", readAction: ": > \"$last\"; exit 0", want: "read successfully but is empty"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dist := filepath.Join(root, "dist")
|
||||
seedVersionedReleaseArtifacts(t, dist, "v1.2.3-beta.1")
|
||||
logPath := filepath.Join(root, "ossutil.log")
|
||||
fakeOSSUtil := filepath.Join(root, "ossutil")
|
||||
scriptText := "#!/bin/sh\nset -eu\nprevious=\npenultimate=\nfor arg in \"$@\"; do penultimate=\"$previous\"; previous=\"$arg\"; done\nlast=\"$previous\"\ncase \"$penultimate\" in oss://*) " + test.readAction + " ;; esac\nprintf '%s\\n' \"$last\" >> \"$OSSUTIL_LOG\"\n"
|
||||
mustWriteFile(t, fakeOSSUtil, []byte(scriptText), 0o755)
|
||||
|
||||
cmd := exec.Command("bash", script)
|
||||
cmd.Dir = sourceRoot
|
||||
cmd.Env = append(os.Environ(),
|
||||
"DIST_DIR="+dist,
|
||||
"VERSION=v1.2.3-beta.1",
|
||||
"DWS_RELEASE_CHANNEL=prerelease",
|
||||
"OSS_ACCESS_KEY_ID=test-key",
|
||||
"OSS_ACCESS_KEY_SECRET=test-secret",
|
||||
"OSS_ENDPOINT=https://oss.example.com",
|
||||
"OSS_BUCKET=test-bucket",
|
||||
"OSS_PREFIX=dws",
|
||||
"OSSUTIL="+fakeOSSUtil,
|
||||
"OSSUTIL_LOG="+logPath,
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil || !strings.Contains(string(output), test.want) {
|
||||
t.Fatalf("pointer failure was not closed: err=%v, want=%q\noutput:\n%s", err, test.want, output)
|
||||
}
|
||||
if logData, readErr := os.ReadFile(logPath); readErr == nil && strings.Contains(string(logData), "/beta.txt") {
|
||||
t.Fatalf("failed pointer read still published beta pointer:\n%s", logData)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseMirrorRepairsHistoricalAssetsWithoutMovingNewerPointer(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
dist := filepath.Join(root, "dist")
|
||||
seedVersionedReleaseArtifacts(t, dist, "v1.2.3-beta.1")
|
||||
logPath := filepath.Join(root, "ossutil.log")
|
||||
fakeOSSUtil := filepath.Join(root, "ossutil")
|
||||
mustWriteFile(t, fakeOSSUtil, []byte("#!/bin/sh\nset -eu\nprevious=\npenultimate=\nfor arg in \"$@\"; do penultimate=\"$previous\"; previous=\"$arg\"; done\nlast=\"$previous\"\ncase \"$penultimate\" in oss://*) printf 'v1.2.4-beta.1\\n' > \"$last\"; exit 0 ;; esac\nprintf '%s\\n' \"$last\" >> \"$OSSUTIL_LOG\"\n"), 0o755)
|
||||
cmd := exec.Command("bash", filepath.Join(sourceRoot, "scripts", "release", "sync-to-oss.sh"))
|
||||
cmd.Dir = sourceRoot
|
||||
cmd.Env = append(os.Environ(),
|
||||
"DIST_DIR="+dist,
|
||||
"VERSION=v1.2.3-beta.1",
|
||||
"DWS_RELEASE_CHANNEL=prerelease",
|
||||
"OSS_ACCESS_KEY_ID=test-key",
|
||||
"OSS_ACCESS_KEY_SECRET=test-secret",
|
||||
"OSS_ENDPOINT=https://oss.example.com",
|
||||
"OSS_BUCKET=test-bucket",
|
||||
"OSS_PREFIX=dws",
|
||||
"OSSUTIL="+fakeOSSUtil,
|
||||
"OSSUTIL_LOG="+logPath,
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil || !strings.Contains(string(output), "assets will be repaired without moving it") {
|
||||
t.Fatalf("historical OSS repair failed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
logData, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(ossutil log) error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(logData), "/beta.txt") {
|
||||
t.Fatalf("historical repair moved beta pointer:\n%s", logData)
|
||||
}
|
||||
if !strings.Contains(string(logData), "/download/v1.2.3-beta.1/") {
|
||||
t.Fatalf("historical repair did not upload target assets:\n%s", logData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseRequiredMirrorsFailWithoutCredentials(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
script string
|
||||
require string
|
||||
want string
|
||||
}{
|
||||
{name: "oss", script: "sync-to-oss.sh", require: "DWS_REQUIRE_OSS=1", want: "OSS mirror sync is required"},
|
||||
{name: "gitee", script: "sync-to-gitee.sh", require: "DWS_REQUIRE_GITEE=1", want: "Gitee mirror sync is enabled"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := exec.Command("bash", filepath.Join(sourceRoot, "scripts", "release", test.script))
|
||||
cmd.Dir = sourceRoot
|
||||
cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), test.require}
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil || !strings.Contains(string(output), test.want) {
|
||||
t.Fatalf("required mirror did not fail closed: err=%v, want=%q\noutput:\n%s", err, test.want, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseArtifactVerificationRequiresEveryChecksum(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
dist := t.TempDir()
|
||||
seedVersionedReleaseArtifacts(t, dist, "v1.2.3")
|
||||
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 {
|
||||
t.Fatalf("artifact verification error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
|
||||
writeReleaseChecksums(t, dist, false)
|
||||
cmd = exec.Command("sh", r.verify, "v1.2.3")
|
||||
cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil || !strings.Contains(string(output), "dws-skills.zip exactly once") {
|
||||
t.Fatalf("missing checksum was not blocked: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
|
||||
writeReleaseChecksums(t, dist, true)
|
||||
mustWriteFile(t, filepath.Join(dist, "dws-linux-riscv64.tar.gz"), []byte("unexpected\n"), 0o644)
|
||||
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), "public release assets") {
|
||||
t.Fatalf("extra public archive was not rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dist, "dws-linux-riscv64.tar.gz")); err != nil {
|
||||
t.Fatalf("Remove(extra archive) error = %v", err)
|
||||
}
|
||||
|
||||
writeVersionedReleaseArchive(t, dist, "dws-windows-arm64.zip", "v1.2.2")
|
||||
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") {
|
||||
t.Fatalf("mixed-version archive was not rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandValidatesThenPushesAnnotatedTag(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "install release automation")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "No tag was created") {
|
||||
t.Fatalf("validation output missing dry-run result:\n%s", output)
|
||||
}
|
||||
if mustOutput(t, r.root, "git", "tag", "--list", "v1.0.1-beta.1") != "" {
|
||||
t.Fatal("validation-only release created a tag")
|
||||
}
|
||||
|
||||
output, err = runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("release publish error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if got := strings.TrimSpace(mustOutput(t, r.root, "git", "cat-file", "-t", "v1.0.1-beta.1")); got != "tag" {
|
||||
t.Fatalf("release tag type = %q, want annotated tag", got)
|
||||
}
|
||||
if got := mustOutput(t, r.root, "git", "ls-remote", "--tags", "origin", "refs/tags/v1.0.1-beta.1"); !strings.Contains(got, "refs/tags/v1.0.1-beta.1") {
|
||||
t.Fatalf("remote release tag missing:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandCleansLocalTagWhenPushFails(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "install release automation")
|
||||
|
||||
hook := filepath.Join(r.remote, "hooks", "pre-receive")
|
||||
mustWriteFile(t, hook, []byte("#!/bin/sh\nset -eu\nwhile read -r old new ref; do\n case \"$ref\" in refs/tags/*) exit 1 ;; esac\ndone\nexit 0\n"), 0o755)
|
||||
|
||||
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("rejected tag push unexpectedly passed:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "new local tag was removed") {
|
||||
t.Fatalf("push failure output missing cleanup result:\n%s", output)
|
||||
}
|
||||
if mustOutput(t, r.root, "git", "tag", "--list", "v1.0.1-beta.1") != "" {
|
||||
t.Fatal("failed push left the local release tag behind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandRejectsDifferentFetchAndPushRepositories(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "install release automation")
|
||||
otherRemote := filepath.Join(t.TempDir(), "other.git")
|
||||
mustRun(t, r.root, "git", "init", "--bare", otherRemote)
|
||||
mustRun(t, r.root, "git", "remote", "set-url", "--push", "origin", otherRemote)
|
||||
|
||||
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin",
|
||||
)
|
||||
if err == nil || !strings.Contains(output, "fetch and push URLs target different repositories") {
|
||||
t.Fatalf("split release authority was not rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
section := "## [1.0.2-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate a candidate after stable authority advances.\n\n"
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'compatibility %s\\n' \"$*\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644)
|
||||
r.commitAndPush(t, "install advancing release fixture")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
"prerelease", "v1.0.2-beta.1", "--remote", "origin",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "Stable authority advanced from v1.0.0 to v1.0.1") ||
|
||||
!strings.Contains(output, "--stable-ref v1.0.1") {
|
||||
t.Fatalf("advanced stable command tree was not rechecked:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func installReleaseCommandFixture(t *testing.T, r *releaseTestRepo) {
|
||||
t.Helper()
|
||||
for _, source := range []string{r.lib, r.contract, r.releaseCmd} {
|
||||
releaseCopyFile(t, source, filepath.Join(r.root, "scripts", "release", filepath.Base(source)), 0o755)
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-package-managers.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-release-artifacts.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644)
|
||||
}
|
||||
|
||||
func releaseCopyFile(t *testing.T, source, target string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", source, err)
|
||||
}
|
||||
mustWriteFile(t, target, data, mode)
|
||||
}
|
||||
Reference in New Issue
Block a user