Merge remote-tracking branch 'origin/main' into codex/todo-skill-optimization-run1
# Conflicts: # internal/shortcut/todo/lifecycle.go
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type packagedAgent struct {
|
||||
universal bool
|
||||
path string
|
||||
}
|
||||
|
||||
var upstreamAgentIDs = strings.Fields(`
|
||||
aider-desk amp antigravity antigravity-cli astrbot autohand-code augment bob
|
||||
claude-code openclaw cline codearts-agent codebuddy codemaker codestudio codex
|
||||
command-code continue cortex crush cursor deepagents devin dexto droid eve
|
||||
firebender forgecode gemini-cli github-copilot goose grok hermes-agent inference-sh
|
||||
jazz junie iflow-cli kilo kimchi kimi-code-cli kiro-cli kode lingma loaf mcpjam
|
||||
minimax-code mistral-vibe moxby mux opencode openhands ona pi qoder qoder-cn
|
||||
qwen-code replit reasonix rovodev roo tabnine-cli terramind tinycloud trae trae-cn
|
||||
warp windsurf zed zcode zencoder zenflow neovate pochi promptscript adal universal
|
||||
`)
|
||||
|
||||
func TestAgentRegistriesMatchUpstream76(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := filepath.Join("..", "..")
|
||||
installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1"))
|
||||
devappPS := readTestFile(t, filepath.Join(root, "scripts", "install-devapp.ps1"))
|
||||
verifier := readTestFile(t, filepath.Join(root, "scripts", "release", "verify-package-managers.sh"))
|
||||
|
||||
wantIDs := append([]string(nil), upstreamAgentIDs...)
|
||||
sort.Strings(wantIDs)
|
||||
registries := map[string]map[string]packagedAgent{
|
||||
"install.ps1": parseInstallPowerShellRegistry(t, installPS),
|
||||
"install-devapp.ps1": parsePipeRegistry(t, between(t, devappPS, "$AgentRegistryRows = @(", ")\n$LegacyAgentCleanupRows")),
|
||||
"verifier": parsePipeRegistry(t, between(t, verifier, "UPSTREAM_AGENT_REGISTRY='", "'\n\n# DWS-only")),
|
||||
}
|
||||
|
||||
for name, registry := range registries {
|
||||
gotIDs := make([]string, 0, len(registry))
|
||||
universal := 0
|
||||
for id, agent := range registry {
|
||||
gotIDs = append(gotIDs, id)
|
||||
if agent.universal {
|
||||
universal++
|
||||
}
|
||||
}
|
||||
sort.Strings(gotIDs)
|
||||
if strings.Join(gotIDs, "\n") != strings.Join(wantIDs, "\n") {
|
||||
t.Fatalf("%s agent IDs differ from upstream 76\ngot: %v\nwant: %v", name, gotIDs, wantIDs)
|
||||
}
|
||||
if universal != 19 || len(registry)-universal != 57 {
|
||||
t.Fatalf("%s classification = %d universal/%d non-universal, want 19/57", name, universal, len(registry)-universal)
|
||||
}
|
||||
}
|
||||
|
||||
canonical := registries["install.ps1"]
|
||||
for name, got := range registries {
|
||||
for id, want := range canonical {
|
||||
if got[id] != want {
|
||||
t.Errorf("%s[%s] = %+v, want %+v", name, id, got[id], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for id, wantPath := range map[string]string{
|
||||
"amp": ".config/agents/skills",
|
||||
"github-copilot": ".copilot/skills",
|
||||
"opencode": ".config/opencode/skills",
|
||||
"windsurf": ".codeium/windsurf/skills",
|
||||
"cline": ".agents/skills",
|
||||
"eve": "-",
|
||||
"promptscript": "-",
|
||||
} {
|
||||
if got := canonical[id].path; got != wantPath {
|
||||
t.Errorf("upstream path for %s = %q, want %q", id, got, wantPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSCompatibilityAgentTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := filepath.Join("..", "..")
|
||||
installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1"))
|
||||
legacyBlock := between(t, installPS, "$LegacyAgentCleanupTargets = @(", ")\n\n# Kept as")
|
||||
if !strings.Contains(legacyBlock, `Id = "dws-qoderwork"; Universal = $false`) {
|
||||
t.Error("DWS Qoderwork compatibility target must remain non-universal")
|
||||
}
|
||||
for _, id := range []string{
|
||||
"dws-legacy-github", "dws-legacy-amp", "dws-legacy-cline", "dws-legacy-windsurf",
|
||||
} {
|
||||
if !strings.Contains(legacyBlock, `Id = "`+id+`"; Universal = $true`) {
|
||||
t.Errorf("legacy cleanup registry missing cleanup-only target %s", id)
|
||||
}
|
||||
}
|
||||
for _, id := range upstreamAgentIDs {
|
||||
if strings.HasPrefix(id, "dws-") {
|
||||
t.Fatalf("DWS compatibility target %s leaked into upstream registry", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPowerShellUsesAbsoluteJunctionTargetsAndCopyFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
installPS := readTestFile(t, filepath.Join("..", "..", "scripts", "install.ps1"))
|
||||
for _, want := range []string{
|
||||
`$absoluteTarget = [System.IO.Path]::GetFullPath((Join-Path $canonical $name))`,
|
||||
`New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target $absoluteTarget`,
|
||||
`Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir`,
|
||||
`Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir`,
|
||||
} {
|
||||
if !strings.Contains(installPS, want) {
|
||||
t.Errorf("install.ps1 missing Windows junction/fallback contract %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentInstallersCarryUpstreamShallowAndApplicationDetection(t *testing.T) {
|
||||
t.Parallel()
|
||||
checks := map[string][]string{
|
||||
"build/npm/install.js": {"agentTargetDetected", `case "kimchi"`, `case "tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
|
||||
"scripts/install.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
|
||||
"scripts/install-skills.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
|
||||
"scripts/install-event.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
|
||||
"scripts/install-devapp.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
|
||||
"scripts/install.ps1": {"Test-AgentSkillBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
|
||||
"scripts/install-devapp.ps1": {"Test-AgentBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
|
||||
}
|
||||
for rel, wants := range checks {
|
||||
body := readTestFile(t, filepath.Join("..", "..", filepath.FromSlash(rel)))
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("%s missing detection contract %q", rel, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPowerShellResolvesCustomAndLegacyAgentHomes(t *testing.T) {
|
||||
pwsh, err := exec.LookPath("pwsh")
|
||||
if err != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
pwsh, err = exec.LookPath("powershell")
|
||||
}
|
||||
if err != nil {
|
||||
t.Skip("PowerShell is not available")
|
||||
}
|
||||
}
|
||||
|
||||
root := filepath.Join("..", "..")
|
||||
installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1"))
|
||||
cut := strings.LastIndex(installPS, "# ── Main")
|
||||
if cut < 0 {
|
||||
t.Fatal("install.ps1 main section not found")
|
||||
}
|
||||
home := t.TempDir()
|
||||
custom := filepath.Join(t.TempDir(), "custom roots")
|
||||
xdg := filepath.Join(custom, "xdg")
|
||||
clawdbot := filepath.Join(home, ".clawdbot")
|
||||
if err := os.MkdirAll(clawdbot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prefix := strings.ReplaceAll(installPS[:cut], "$HOME", "$env:DWS_TEST_HOME")
|
||||
prefix += `
|
||||
$ids = @("amp", "autohand-code", "claude-code", "codex", "grok", "hermes-agent", "mistral-vibe", "openclaw", "goose", "kimchi", "opencode")
|
||||
foreach ($id in $ids) {
|
||||
$agent = $AgentRegistry | Where-Object { $_.Id -eq $id } | Select-Object -First 1
|
||||
Write-Output ($id + "|" + (Resolve-AgentSkillBase -Root $env:DWS_TEST_HOME -Agent $agent))
|
||||
}
|
||||
exit 0
|
||||
`
|
||||
harness := filepath.Join(t.TempDir(), "resolve-agent-roots.ps1")
|
||||
mustWriteFile(t, harness, []byte(prefix), 0o644)
|
||||
|
||||
envRoots := map[string]string{
|
||||
"AUTOHAND_HOME": filepath.Join(custom, "autohand"),
|
||||
"CLAUDE_CONFIG_DIR": filepath.Join(custom, "claude"),
|
||||
"CODEX_HOME": filepath.Join(custom, "codex"),
|
||||
"GROK_HOME": filepath.Join(custom, "grok"),
|
||||
"HERMES_HOME": filepath.Join(custom, "hermes"),
|
||||
"VIBE_HOME": filepath.Join(custom, "vibe"),
|
||||
"XDG_CONFIG_HOME": xdg,
|
||||
}
|
||||
cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harness)
|
||||
cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home)
|
||||
for key, value := range envRoots {
|
||||
cmd.Env = append(cmd.Env, key+"="+value)
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("PowerShell root resolver failed: %v\n%s", err, output)
|
||||
}
|
||||
want := map[string]string{
|
||||
"amp": filepath.Join(xdg, "agents", "skills"),
|
||||
"autohand-code": filepath.Join(envRoots["AUTOHAND_HOME"], "skills"),
|
||||
"claude-code": filepath.Join(envRoots["CLAUDE_CONFIG_DIR"], "skills"),
|
||||
"codex": filepath.Join(envRoots["CODEX_HOME"], "skills"),
|
||||
"grok": filepath.Join(envRoots["GROK_HOME"], "skills"),
|
||||
"hermes-agent": filepath.Join(envRoots["HERMES_HOME"], "skills"),
|
||||
"mistral-vibe": filepath.Join(envRoots["VIBE_HOME"], "skills"),
|
||||
"openclaw": filepath.Join(clawdbot, "skills"),
|
||||
"goose": filepath.Join(xdg, "goose", "skills"),
|
||||
"kimchi": filepath.Join(xdg, "kimchi", "harness", "skills"),
|
||||
"opencode": filepath.Join(xdg, "opencode", "skills"),
|
||||
}
|
||||
got := make(map[string]string)
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
|
||||
parts := strings.SplitN(strings.TrimSpace(line), "|", 2)
|
||||
if len(parts) == 2 {
|
||||
got[parts[0]] = filepath.Clean(parts[1])
|
||||
}
|
||||
}
|
||||
for id, path := range want {
|
||||
if got[id] != filepath.Clean(path) {
|
||||
t.Errorf("resolved %s = %q, want %q\noutput:\n%s", id, got[id], path, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readTestFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s): %v", path, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func between(t *testing.T, text, start, end string) string {
|
||||
t.Helper()
|
||||
i := strings.Index(text, start)
|
||||
if i < 0 {
|
||||
t.Fatalf("start marker %q missing", start)
|
||||
}
|
||||
i += len(start)
|
||||
j := strings.Index(text[i:], end)
|
||||
if j < 0 {
|
||||
t.Fatalf("end marker %q missing", end)
|
||||
}
|
||||
return text[i : i+j]
|
||||
}
|
||||
|
||||
func parseInstallPowerShellRegistry(t *testing.T, text string) map[string]packagedAgent {
|
||||
t.Helper()
|
||||
block := between(t, text, "$AgentRegistry = @(", ")\n\n# DWS compatibility targets")
|
||||
re := regexp.MustCompile(`Id = "([^"]+)"; Universal = \$(true|false); Dir = (?:"([^"]+)"|\$null)`)
|
||||
result := make(map[string]packagedAgent)
|
||||
for _, match := range re.FindAllStringSubmatch(block, -1) {
|
||||
path := strings.ReplaceAll(match[3], `\`, "/")
|
||||
if path == "" {
|
||||
path = "-"
|
||||
}
|
||||
result[match[1]] = packagedAgent{universal: match[2] == "true", path: path}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parsePipeRegistry(t *testing.T, block string) map[string]packagedAgent {
|
||||
t.Helper()
|
||||
re := regexp.MustCompile(`([a-z0-9-]+)\|([01])\|([.a-z0-9_\\/-]+)`)
|
||||
result := make(map[string]packagedAgent)
|
||||
for _, match := range re.FindAllStringSubmatch(block, -1) {
|
||||
path := strings.ReplaceAll(match[3], `\`, "/")
|
||||
if _, exists := result[match[1]]; exists {
|
||||
t.Fatalf("duplicate agent %q in pipe registry", match[1])
|
||||
}
|
||||
result[match[1]] = packagedAgent{universal: match[2] == "1", path: path}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -305,6 +305,52 @@ func TestChangelogPRContentOnlyAcceptsReleaseFragmentArchival(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseFragmentPolicyAcceptsStablePostBetaArchival(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
changelogGateWrite(t, repo.root, ".changes/1236-stable-followup.md", "---\ncategory: Fixed\n---\n\n- Stable follow-up.\n", 0o644)
|
||||
repo.commit(t, "add post-beta release fragment")
|
||||
sealBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.1] - 2026-07-17
|
||||
|
||||
This release promotes the sealed `+"`v1.0.1-beta.1`"+` contents to stable.
|
||||
|
||||
### Changed
|
||||
|
||||
- Promote the complete beta release to stable.
|
||||
|
||||
### Changes since `+"`v1.0.1-beta.1`"+`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stable follow-up.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
`, 0o644)
|
||||
archiveDir := filepath.Join(repo.root, ".changes", "released", "1.0.1")
|
||||
if err := os.MkdirAll(archiveDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll stable archive: %v", err)
|
||||
}
|
||||
if err := os.Rename(
|
||||
filepath.Join(repo.root, ".changes", "1236-stable-followup.md"),
|
||||
filepath.Join(archiveDir, "1236-stable-followup.md"),
|
||||
); err != nil {
|
||||
t.Fatalf("Rename stable release fragment: %v", err)
|
||||
}
|
||||
repo.commit(t, "seal stable release notes")
|
||||
|
||||
if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil {
|
||||
t.Fatalf("release fragment policy rejected stable seal: %v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseFragmentPolicyRejectsInvalidActiveFragmentAndWrongArchiveVersion(t *testing.T) {
|
||||
t.Run("invalid active fragment", func(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
|
||||
+1268
-27
File diff suppressed because it is too large
Load Diff
+2532
-31
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,10 @@ var expectedPackagedSkillTargets = []string{
|
||||
".augment/skills/dingtalk-shared",
|
||||
".cline/skills/dingtalk-shared",
|
||||
".amp/skills/dingtalk-shared",
|
||||
".copilot/skills/dingtalk-shared",
|
||||
".config/opencode/skills/dingtalk-shared",
|
||||
".config/agents/skills/dingtalk-shared",
|
||||
".codeium/windsurf/skills/dingtalk-shared",
|
||||
".kiro/skills/dingtalk-shared",
|
||||
".trae/skills/dingtalk-shared",
|
||||
".openclaw/skills/dingtalk-shared",
|
||||
@@ -70,9 +74,12 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) {
|
||||
t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"HOME_SPECIFIC_SKILL_BASES=",
|
||||
`$base/dingtalk-shared/SKILL.md`,
|
||||
`$base/dingtalk-misc/SKILL.md`,
|
||||
"UPSTREAM_AGENT_REGISTRY=",
|
||||
"verify_compatible_skill_base",
|
||||
`diff -qr "$canonical/$name" "$target"`,
|
||||
"broken canonical Skill link",
|
||||
"Skill link does not resolve to canonical source",
|
||||
"Skill copy fallback differs from canonical source",
|
||||
"unexpected mono Skill layout",
|
||||
`verify_npm_install "$tarball_path" "specific-agent-roots"`,
|
||||
`verify_npm_install "$tarball_path" "generic-fallback"`,
|
||||
@@ -128,11 +135,18 @@ func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T)
|
||||
}
|
||||
|
||||
verifyCmd := exec.Command("sh", verifierPath, "--npm-only")
|
||||
verifyCmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+distDir)
|
||||
hostXDG := filepath.Join(t.TempDir(), "host-xdg")
|
||||
verifyCmd.Env = replaceTestEnv(os.Environ(),
|
||||
"DWS_PACKAGE_DIST_DIR", distDir,
|
||||
"XDG_CONFIG_HOME", hostXDG,
|
||||
)
|
||||
output, err := verifyCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("verify-package-managers.sh error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if _, err := os.Stat(hostXDG); !os.IsNotExist(err) {
|
||||
t.Fatalf("package verifier touched inherited XDG_CONFIG_HOME %s: %v", hostXDG, err)
|
||||
}
|
||||
for _, scenario := range []string{"specific-agent-roots", "generic-fallback"} {
|
||||
if !strings.Contains(string(output), "verifying npm package install ("+scenario+")") {
|
||||
t.Errorf("verifier output is missing %s scenario:\n%s", scenario, output)
|
||||
@@ -140,6 +154,102 @@ func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageManagerVerifierAcceptsOnlyCorrectLinksOrCopies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-package-managers.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(scriptPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
cut := strings.Index(text, "\nverify_npm_install()")
|
||||
if cut < 0 {
|
||||
t.Fatal("verify-package-managers.sh helper boundary missing")
|
||||
}
|
||||
library := filepath.Join(t.TempDir(), "verifier-lib.sh")
|
||||
mustWriteFile(t, library, data[:cut], 0o755)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
prepare func(t *testing.T, home, base string)
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "correct-link",
|
||||
prepare: func(t *testing.T, _, base string) {
|
||||
t.Helper()
|
||||
// The canonical layout publishes RELATIVE links; the verifier now
|
||||
// rejects absolute targets, so the fixture must match production.
|
||||
if err := os.Symlink(filepath.Join("..", "..", ".agents", "skills", "dingtalk-shared"), filepath.Join(base, "dingtalk-shared")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "complete-copy",
|
||||
prepare: func(t *testing.T, _, base string) {
|
||||
t.Helper()
|
||||
mustWriteFile(t, filepath.Join(base, "dingtalk-shared", "SKILL.md"), []byte("canonical\n"), 0o644)
|
||||
},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "wrong-link",
|
||||
prepare: func(t *testing.T, home, base string) {
|
||||
t.Helper()
|
||||
wrong := filepath.Join(home, "wrong", "dingtalk-shared")
|
||||
mustWriteFile(t, filepath.Join(wrong, "SKILL.md"), []byte("canonical\n"), 0o644)
|
||||
if err := os.Symlink(wrong, filepath.Join(base, "dingtalk-shared")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "broken-link",
|
||||
prepare: func(t *testing.T, home, base string) {
|
||||
t.Helper()
|
||||
if err := os.Symlink(filepath.Join(home, "missing"), filepath.Join(base, "dingtalk-shared")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "different-copy",
|
||||
prepare: func(t *testing.T, _, base string) {
|
||||
t.Helper()
|
||||
mustWriteFile(t, filepath.Join(base, "dingtalk-shared", "SKILL.md"), []byte("different\n"), 0o644)
|
||||
},
|
||||
wantOK: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
base := filepath.Join(home, ".claude", "skills")
|
||||
mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md"), []byte("canonical\n"), 0o644)
|
||||
if err := os.MkdirAll(base, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc.prepare(t, home, base)
|
||||
cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY"; verify_compatible_skill_base "$DWS_TEST_HOME" "$DWS_TEST_BASE"`)
|
||||
cmd.Env = append(os.Environ(), "DWS_TEST_LIBRARY="+library, "DWS_TEST_HOME="+home, "DWS_TEST_BASE="+base)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if tc.wantOK && err != nil {
|
||||
t.Fatalf("verifier rejected valid target: %v\n%s", err, output)
|
||||
}
|
||||
if !tc.wantOK && err == nil {
|
||||
t.Fatalf("verifier accepted invalid target\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func seedDistArchive(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
file, err := os.Create(path)
|
||||
@@ -1193,13 +1303,25 @@ func TestReleaseWorkflowParallelizesSealedValidationWithoutWeakeningPublication(
|
||||
"- e2e",
|
||||
"verify-github-tag-authority.sh",
|
||||
"go test -v -count=1 -timeout=5m ./test/scripts/... -run '^TestRelease'",
|
||||
"check-command-compatibility.sh",
|
||||
`tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh`,
|
||||
`--repo-root "$GITHUB_WORKSPACE"`,
|
||||
`--base-ref HEAD`,
|
||||
`--stable-ref "$PREVIOUS_STABLE"`,
|
||||
`--candidate-ref HEAD`,
|
||||
"test-multi-profile-e2e.sh",
|
||||
} {
|
||||
if !strings.Contains(validation, required) {
|
||||
t.Errorf("parallel release validation is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"./scripts/policy/check-command-compatibility.sh",
|
||||
"./scripts/policy/check-authoritative-schema-compatibility.sh",
|
||||
} {
|
||||
if strings.Contains(validation, forbidden) {
|
||||
t.Errorf("parallel release validation bypasses the shared compatibility runner with %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(build, "test-multi-profile-e2e.sh") {
|
||||
t.Error("multi-profile E2E must not serialize GoReleaser")
|
||||
@@ -1269,7 +1391,7 @@ func TestReleaseWorkflowHidesOnlyVerifiedSealedTagFromCompatibilityBaseline(t *t
|
||||
|
||||
verifiedTag := strings.Index(validation, "verify-github-tag-authority.sh")
|
||||
deleteLocalTag := strings.Index(validation, "git update-ref -d")
|
||||
compatibility := strings.Index(validation, "check-command-compatibility.sh")
|
||||
compatibility := strings.Index(validation, "tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh")
|
||||
if verifiedTag == -1 || deleteLocalTag == -1 || compatibility == -1 ||
|
||||
verifiedTag > deleteLocalTag || deleteLocalTag > compatibility {
|
||||
t.Fatal("release tag authority verification, local candidate removal, and compatibility checking must stay ordered")
|
||||
|
||||
@@ -75,6 +75,7 @@ type releaseTestRepo struct {
|
||||
prepare string
|
||||
render string
|
||||
releaseCmd string
|
||||
compat string
|
||||
lib string
|
||||
verify string
|
||||
}
|
||||
@@ -110,11 +111,206 @@ func newReleaseTestRepo(t *testing.T) *releaseTestRepo {
|
||||
prepare: filepath.Join(sourceRoot, "scripts", "release", "prepare-changelog.sh"),
|
||||
render: filepath.Join(sourceRoot, "scripts", "release", "render-release-fragments.sh"),
|
||||
releaseCmd: filepath.Join(sourceRoot, "scripts", "release", "release.sh"),
|
||||
compat: filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh"),
|
||||
lib: filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh"),
|
||||
verify: filepath.Join(sourceRoot, "scripts", "release", "verify-release-artifacts.sh"),
|
||||
}
|
||||
}
|
||||
|
||||
func seedReleaseCompatibilityRefs(t *testing.T, repo string) (stable, base, candidate string) {
|
||||
t.Helper()
|
||||
mustRun(t, repo, "git", "config", "user.name", "Release Compatibility Test")
|
||||
mustRun(t, repo, "git", "config", "user.email", "release-compatibility-test@example.com")
|
||||
mustWriteFile(t, filepath.Join(repo, "seed.txt"), []byte("stable\n"), 0o644)
|
||||
mustRun(t, repo, "git", "add", "seed.txt")
|
||||
mustRun(t, repo, "git", "commit", "-m", "stable")
|
||||
mustRun(t, repo, "git", "tag", "stable-ref")
|
||||
stable = strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD^{commit}"))
|
||||
|
||||
mustWriteFile(t, filepath.Join(repo, "seed.txt"), []byte("base\n"), 0o644)
|
||||
mustRun(t, repo, "git", "add", "seed.txt")
|
||||
mustRun(t, repo, "git", "commit", "-m", "base")
|
||||
mustRun(t, repo, "git", "branch", "base-ref")
|
||||
base = strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD^{commit}"))
|
||||
|
||||
mustWriteFile(t, filepath.Join(repo, "seed.txt"), []byte("candidate\n"), 0o644)
|
||||
mustRun(t, repo, "git", "add", "seed.txt")
|
||||
mustRun(t, repo, "git", "commit", "-m", "candidate")
|
||||
mustRun(t, repo, "git", "branch", "candidate-ref")
|
||||
candidate = strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD^{commit}"))
|
||||
return stable, base, candidate
|
||||
}
|
||||
|
||||
func TestReleaseCompatibilityCheckRunsCLIAndSchemaWithExactRefs(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||
repo := t.TempDir()
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
stable, base, candidate := seedReleaseCompatibilityRefs(t, repo)
|
||||
|
||||
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||
checker := func(name string) []byte {
|
||||
return []byte("#!/bin/sh\nset -eu\nprintf '" + name + ":%s\\n' \"$*\" >> \"$TRACE\"\n")
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), checker("cli"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), checker("schema"), 0o755)
|
||||
|
||||
cmd := exec.Command(
|
||||
runner,
|
||||
"--repo-root", repo,
|
||||
"--base-ref", "base-ref",
|
||||
"--stable-ref", "stable-ref",
|
||||
"--candidate-ref", "candidate-ref",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), "TRACE="+trace)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("release compatibility check error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
got, err := os.ReadFile(trace)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||
}
|
||||
want := fmt.Sprintf("cli:--base-ref %s --stable-ref %s --candidate-ref %s\n", base, stable, candidate) +
|
||||
fmt.Sprintf("schema:--base-ref %s --stable-ref %s --candidate-ref %s\n", base, stable, candidate)
|
||||
if string(got) != want {
|
||||
t.Fatalf("release compatibility calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCompatibilityCheckFreezesRefsBeforeRunningEitherChecker(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||
repo := t.TempDir()
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
stable, base, candidate := seedReleaseCompatibilityRefs(t, repo)
|
||||
|
||||
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||
resolveAndLog := `
|
||||
base="$2"
|
||||
stable="$4"
|
||||
candidate="$6"
|
||||
printf '%s:%s:%s:%s\n' "$CHECK_NAME" \
|
||||
"$(git -C "$REPO_ROOT_FOR_TEST" rev-parse --verify "${base}^{commit}")" \
|
||||
"$(git -C "$REPO_ROOT_FOR_TEST" rev-parse --verify "${stable}^{commit}")" \
|
||||
"$(git -C "$REPO_ROOT_FOR_TEST" rev-parse --verify "${candidate}^{commit}")" >> "$TRACE"
|
||||
`
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nset -eu\nCHECK_NAME=cli\n"+resolveAndLog+"git -C \"$REPO_ROOT_FOR_TEST\" update-ref refs/heads/base-ref \"$MUTATE_TO\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nCHECK_NAME=schema\n"+resolveAndLog), 0o755)
|
||||
|
||||
cmd := exec.Command(
|
||||
runner,
|
||||
"--repo-root", repo,
|
||||
"--base-ref", "base-ref",
|
||||
"--stable-ref", "stable-ref",
|
||||
"--candidate-ref", "candidate-ref",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), "TRACE="+trace, "REPO_ROOT_FOR_TEST="+repo, "MUTATE_TO="+candidate)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("release compatibility check error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
got, err := os.ReadFile(trace)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||
}
|
||||
want := fmt.Sprintf("cli:%s:%s:%s\nschema:%s:%s:%s\n", base, stable, candidate, base, stable, candidate)
|
||||
if string(got) != want {
|
||||
t.Fatalf("release compatibility resolved commits = %q, want %q", got, want)
|
||||
}
|
||||
if moved := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "base-ref^{commit}")); moved != candidate {
|
||||
t.Fatalf("mutating checker did not move base-ref: got %s, want %s", moved, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCompatibilityCheckStopsBeforeSchemaWhenCLIFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||
repo := t.TempDir()
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
seedReleaseCompatibilityRefs(t, repo)
|
||||
|
||||
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nprintf 'cli\\n' >> \"$TRACE\"\nexit 17\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nprintf 'schema\\n' >> \"$TRACE\"\n"), 0o755)
|
||||
|
||||
cmd := exec.Command(
|
||||
runner,
|
||||
"--repo-root", repo,
|
||||
"--base-ref", "base-ref",
|
||||
"--stable-ref", "stable-ref",
|
||||
"--candidate-ref", "candidate-ref",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), "TRACE="+trace)
|
||||
output, err := cmd.CombinedOutput()
|
||||
exitErr, ok := err.(*exec.ExitError)
|
||||
if !ok || exitErr.ExitCode() != 17 {
|
||||
t.Fatalf("release compatibility CLI failure = %v, want exit 17\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(string(output), "error: authoritative CLI compatibility failed") {
|
||||
t.Fatalf("release compatibility CLI failure has no boundary message:\n%s", output)
|
||||
}
|
||||
got, err := os.ReadFile(trace)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||
}
|
||||
if string(got) != "cli\n" {
|
||||
t.Fatalf("checks after CLI failure = %q, want only CLI", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCompatibilityCheckFailsWhenSchemaFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||
repo := t.TempDir()
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
seedReleaseCompatibilityRefs(t, repo)
|
||||
|
||||
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nprintf 'cli\\n' >> \"$TRACE\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nprintf 'schema\\n' >> \"$TRACE\"\nexit 19\n"), 0o755)
|
||||
|
||||
cmd := exec.Command(
|
||||
runner,
|
||||
"--repo-root", repo,
|
||||
"--base-ref", "base-ref",
|
||||
"--stable-ref", "stable-ref",
|
||||
"--candidate-ref", "candidate-ref",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), "TRACE="+trace)
|
||||
output, err := cmd.CombinedOutput()
|
||||
exitErr, ok := err.(*exec.ExitError)
|
||||
if !ok || exitErr.ExitCode() != 19 {
|
||||
t.Fatalf("release compatibility Schema failure = %v, want exit 19\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(string(output), "error: authoritative Schema compatibility failed") {
|
||||
t.Fatalf("release compatibility Schema failure has no boundary message:\n%s", output)
|
||||
}
|
||||
got, err := os.ReadFile(trace)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||
}
|
||||
if string(got) != "cli\nschema\n" {
|
||||
t.Fatalf("checks before Schema failure = %q, want CLI then Schema", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseVersionOrdering(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
@@ -1699,6 +1895,85 @@ func TestReleasePrepareChangelogRendersAndArchivesPrereleaseFragments(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePrepareChangelogRendersAndArchivesStableFragments(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)
|
||||
releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, ".changes", "1236-stable-followup.md"), []byte("---\ncategory: Fixed\n---\n\n- **Stable follow-up** (#1236) — includes a change merged after the beta.\n"), 0o644)
|
||||
r.commitAndPush(t, "install stable changelog preparation")
|
||||
|
||||
cmd := exec.Command(
|
||||
"sh",
|
||||
filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"),
|
||||
"stable",
|
||||
"v1.0.1",
|
||||
"--from-beta",
|
||||
"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 stable 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)
|
||||
}
|
||||
content := string(changelog)
|
||||
if !strings.Contains(content, "## [1.0.1] - 2026-07-11") ||
|
||||
!strings.Contains(content, "promotes the sealed `v1.0.1-beta.1` contents to stable") ||
|
||||
!strings.Contains(content, "### Changes since `v1.0.1-beta.1`") ||
|
||||
!strings.Contains(content, "### Fixed") ||
|
||||
!strings.Contains(content, "Stable follow-up") {
|
||||
t.Fatalf("prepared stable changelog did not render post-beta fragments:\n%s", changelog)
|
||||
}
|
||||
name := "1236-stable-followup.md"
|
||||
if _, err := os.Stat(filepath.Join(r.root, ".changes", name)); !os.IsNotExist(err) {
|
||||
t.Fatalf("unconsumed stable release fragment %s still present: %v", name, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(r.root, ".changes", "released", "1.0.1", name)); err != nil {
|
||||
t.Fatalf("archived stable release fragment %s missing: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePrepareChangelogStableWithoutFragmentsKeepsTemplate(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)
|
||||
releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755)
|
||||
r.commitAndPush(t, "install stable changelog preparation")
|
||||
|
||||
cmd := exec.Command(
|
||||
"sh",
|
||||
filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"),
|
||||
"stable",
|
||||
"v1.0.1",
|
||||
"--from-beta",
|
||||
"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 stable changelog without fragments 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)
|
||||
}
|
||||
content := string(changelog)
|
||||
if !strings.Contains(content, "## [1.0.1] - 2026-07-11") ||
|
||||
!strings.Contains(content, "TODO: summarize the complete user-visible release") ||
|
||||
strings.Contains(content, "### Changes since") {
|
||||
t.Fatalf("stable changelog without fragments changed template behavior:\n%s", changelog)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(r.root, ".changes", "released", "1.0.1")); !os.IsNotExist(err) {
|
||||
t.Fatalf("stable preparation without fragments created an archive: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePrepareChangelogRejectsInvalidReleaseFragment(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
|
||||
@@ -2631,12 +2906,35 @@ func TestReleaseCommandRejectsDifferentFetchAndPushRepositories(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandRunsCLIAndSchemaCompatibilityInInitialPreflight(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 compatibility fixture")
|
||||
|
||||
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)
|
||||
}
|
||||
head := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "HEAD^{commit}"))
|
||||
stable := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "v1.0.0^{commit}"))
|
||||
for _, want := range []string{
|
||||
fmt.Sprintf("cli-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||
fmt.Sprintf("schema-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Errorf("initial release preflight is missing %q\noutput:\n%s", want, 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@:\nbuild:\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")
|
||||
|
||||
@@ -2646,20 +2944,28 @@ func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) {
|
||||
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)
|
||||
head := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "HEAD^{commit}"))
|
||||
stable := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "v1.0.1^{commit}"))
|
||||
for _, want := range []string{
|
||||
"Stable authority advanced from v1.0.0 to v1.0.1",
|
||||
fmt.Sprintf("cli-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||
fmt.Sprintf("schema-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Errorf("advanced stable authority recheck is missing %q\noutput:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installReleaseCommandFixture(t *testing.T, r *releaseTestRepo) {
|
||||
t.Helper()
|
||||
for _, source := range []string{r.lib, r.contract, r.releaseCmd} {
|
||||
for _, source := range []string{r.lib, r.contract, r.releaseCmd, r.compat} {
|
||||
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, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'cli-compatibility %s\\n' \"$*\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'schema-compatibility %s\\n' \"$*\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// shellPruneScriptTargets are the standalone installers that embed their own
|
||||
// copy of prune_skill_backups. Each must keep every backup stamp the running
|
||||
// process created, mirroring Go's run-root registry and install.js's
|
||||
// currentRunBackupRoots, so a migration retiring more than the keep limit
|
||||
// stays reversible.
|
||||
var shellPruneScriptTargets = []string{
|
||||
"install.sh",
|
||||
"install-skills.sh",
|
||||
"install-event.sh",
|
||||
"install-devapp.sh",
|
||||
}
|
||||
|
||||
// The ownership-marker contract shared with the Go core
|
||||
// (internal/upgrade/paths.go): every stamp root DWS creates carries the
|
||||
// marker, and prune only counts or removes roots with these exact bytes.
|
||||
const (
|
||||
skillBackupMarkerFile = ".dws-skill-backup"
|
||||
skillBackupMarkerBody = "dws skill backup v1\n"
|
||||
)
|
||||
|
||||
// extractShellFunction pulls a top-level `name() { ... }` definition out of a
|
||||
// script so the shipped logic — not a copy — is what the test exercises.
|
||||
func extractShellFunction(t *testing.T, scriptPath, name string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(scriptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", scriptPath, err)
|
||||
}
|
||||
text := string(data)
|
||||
start := strings.Index(text, "\n"+name+"() {")
|
||||
if start < 0 {
|
||||
t.Fatalf("function %s not found in %s", name, scriptPath)
|
||||
}
|
||||
start++
|
||||
end := strings.Index(text[start:], "\n}\n")
|
||||
if end < 0 {
|
||||
t.Fatalf("function %s has no top-level closing brace in %s", name, scriptPath)
|
||||
}
|
||||
return text[start : start+end+3]
|
||||
}
|
||||
|
||||
// mkdirStampWithMarker creates a DWS-owned stamp fixture: the stamp directory
|
||||
// plus the exact ownership marker bytes prune verifies. Without the marker a
|
||||
// stamp-shaped directory is foreign data that prune must neither count nor
|
||||
// remove.
|
||||
func mkdirStampWithMarker(t *testing.T, backupRoot, stamp string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(backupRoot, stamp)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(dir, skillBackupMarkerFile), []byte(skillBackupMarkerBody), 0o644)
|
||||
}
|
||||
|
||||
// runShellPruneScenario builds a sandbox HOME with the given pre-existing
|
||||
// (earlier-run) stamps, registers the given stamps as created by the current
|
||||
// run, optionally also plants stamp-shaped directories without the ownership
|
||||
// marker, runs the script's own prune_skill_backups, and returns the
|
||||
// surviving stamp directories.
|
||||
func runShellPruneScenario(t *testing.T, scriptPath string, foreignStamps, runStamps []string, unmarkedStamps ...string) []string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
backupRoot := filepath.Join(home, ".dws", "skill-backups")
|
||||
for _, stamp := range foreignStamps {
|
||||
mkdirStampWithMarker(t, backupRoot, stamp)
|
||||
}
|
||||
for _, stamp := range unmarkedStamps {
|
||||
if err := os.MkdirAll(filepath.Join(backupRoot, stamp, "payload"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var driver strings.Builder
|
||||
driver.WriteString("#!/bin/sh\n")
|
||||
driver.WriteString("say() { :; }\n")
|
||||
// The keep limit is a script-level variable outside the extracted
|
||||
// functions; set both spellings the four installers use.
|
||||
driver.WriteString("DWS_SKILL_BACKUP_KEEP=5\n")
|
||||
driver.WriteString("SKILL_BACKUP_KEEP=5\n")
|
||||
driver.WriteString(extractShellFunction(t, scriptPath, "is_skill_backup_stamp"))
|
||||
driver.WriteString("\n")
|
||||
driver.WriteString(extractShellFunction(t, scriptPath, "record_current_run_backup_stamp"))
|
||||
driver.WriteString("\n")
|
||||
driver.WriteString(extractShellFunction(t, scriptPath, "prune_skill_backups"))
|
||||
driver.WriteString("\n")
|
||||
for _, stamp := range runStamps {
|
||||
mkdirStampWithMarker(t, backupRoot, stamp)
|
||||
driver.WriteString("record_current_run_backup_stamp \"$HOME/.dws/skill-backups/" + stamp + "\"\n")
|
||||
}
|
||||
driver.WriteString("prune_skill_backups\n")
|
||||
|
||||
driverPath := filepath.Join(t.TempDir(), "prune-driver.sh")
|
||||
if err := os.WriteFile(driverPath, []byte(driver.String()), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command("sh", driverPath)
|
||||
cmd.Env = append(os.Environ(), "HOME="+home)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("prune driver failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(backupRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var surviving []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
surviving = append(surviving, entry.Name())
|
||||
}
|
||||
}
|
||||
return surviving
|
||||
}
|
||||
|
||||
func assertStampsSurvive(t *testing.T, surviving []string, want ...string) {
|
||||
t.Helper()
|
||||
left := make(map[string]bool)
|
||||
for _, stamp := range surviving {
|
||||
left[stamp] = true
|
||||
}
|
||||
for _, stamp := range want {
|
||||
if !left[stamp] {
|
||||
t.Fatalf("stamp %s must survive pruning, surviving=%v", stamp, surviving)
|
||||
}
|
||||
delete(left, stamp)
|
||||
}
|
||||
for stamp := range left {
|
||||
t.Fatalf("stamp %s must have been pruned, surviving=%v", stamp, surviving)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallShellPruneKeepsCurrentRunBackups pins the reversibility contract
|
||||
// the changelog states for every install surface: pruning may only remove
|
||||
// batches from earlier runs. The old shell logic deleted the oldest excess
|
||||
// stamps regardless of origin, so a single migration retiring more than five
|
||||
// batches silently destroyed its own rollback material.
|
||||
func TestInstallShellPruneKeepsCurrentRunBackups(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skipf("shell installer tests do not run on %s", runtime.GOOS)
|
||||
}
|
||||
for _, name := range shellPruneScriptTargets {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
scriptPath := filepath.Join("..", "..", "scripts", name)
|
||||
if _, err := os.Stat(scriptPath); err != nil {
|
||||
t.Fatalf("stat %s: %v", scriptPath, err)
|
||||
}
|
||||
|
||||
t.Run("run that exceeds the keep limit loses nothing", func(t *testing.T) {
|
||||
var runStamps []string
|
||||
for i := 1; i <= 8; i++ {
|
||||
runStamps = append(runStamps, stampName(i))
|
||||
}
|
||||
surviving := runShellPruneScenario(t, scriptPath, nil, runStamps)
|
||||
assertStampsSurvive(t, surviving, runStamps...)
|
||||
})
|
||||
|
||||
t.Run("only earlier-run batches are pruned", func(t *testing.T) {
|
||||
var foreignStamps []string
|
||||
for i := 1; i <= 6; i++ {
|
||||
foreignStamps = append(foreignStamps, oldStampName(i))
|
||||
}
|
||||
runStamps := []string{stampName(1), stampName(2)}
|
||||
surviving := runShellPruneScenario(t, scriptPath, foreignStamps, runStamps)
|
||||
// Total 8, keep 5: three oldest foreign batches go, the
|
||||
// current run keeps both of its own.
|
||||
assertStampsSurvive(t, surviving,
|
||||
oldStampName(4), oldStampName(5), oldStampName(6),
|
||||
stampName(1), stampName(2))
|
||||
})
|
||||
|
||||
t.Run("foreign-only history still prunes to the keep limit", func(t *testing.T) {
|
||||
var foreignStamps []string
|
||||
for i := 1; i <= 7; i++ {
|
||||
foreignStamps = append(foreignStamps, oldStampName(i))
|
||||
}
|
||||
surviving := runShellPruneScenario(t, scriptPath, foreignStamps, nil)
|
||||
assertStampsSurvive(t, surviving,
|
||||
oldStampName(3), oldStampName(4), oldStampName(5),
|
||||
oldStampName(6), oldStampName(7))
|
||||
})
|
||||
|
||||
// A stamp-shaped directory without the ownership marker is foreign
|
||||
// data: it must be preserved untouched, never counted toward the
|
||||
// keep limit, and never treated as prunable excess. Oldest on
|
||||
// purpose — if the marker were not required, this directory would
|
||||
// be the first pruning candidate.
|
||||
t.Run("unmarked stamp-shaped directory is preserved and uncounted", func(t *testing.T) {
|
||||
var foreignStamps []string
|
||||
for i := 1; i <= 7; i++ {
|
||||
foreignStamps = append(foreignStamps, oldStampName(i))
|
||||
}
|
||||
unmarked := "20200101-000000"
|
||||
surviving := runShellPruneScenario(t, scriptPath, foreignStamps, nil, unmarked)
|
||||
assertStampsSurvive(t, surviving,
|
||||
append([]string{unmarked},
|
||||
oldStampName(3), oldStampName(4), oldStampName(5),
|
||||
oldStampName(6), oldStampName(7))...)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func stampName(i int) string {
|
||||
return "20260817-0000" + twoDigits(i)
|
||||
}
|
||||
|
||||
func oldStampName(i int) string {
|
||||
return "20200101-0000" + twoDigits(i)
|
||||
}
|
||||
|
||||
func twoDigits(i int) string {
|
||||
if i < 10 {
|
||||
return "0" + string(rune('0'+i))
|
||||
}
|
||||
return string(rune('0'+i/10)) + string(rune('0'+i%10))
|
||||
}
|
||||
|
||||
// TestInstallShellBackupNeverAdoptsUnprovenStampRoots pins the ownership
|
||||
// contract at creation time: a stamp-shaped directory whose marker does not
|
||||
// verify must never receive the DWS marker or a payload — the backup moves
|
||||
// to a fresh suffixed root instead, and the foreign data survives untouched.
|
||||
func TestInstallShellBackupNeverAdoptsUnprovenStampRoots(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX shell semantics are unavailable")
|
||||
}
|
||||
|
||||
for _, scriptName := range []string{"install.sh", "install-skills.sh", "install-event.sh", "install-devapp.sh"} {
|
||||
scriptName := scriptName
|
||||
t.Run(scriptName, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(scriptPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cut := strings.LastIndex(string(data), "\nmain\n")
|
||||
if cut < 0 {
|
||||
t.Fatalf("%s final main invocation not found", scriptName)
|
||||
}
|
||||
library := filepath.Join(t.TempDir(), scriptName+"-lib.sh")
|
||||
mustWriteFile(t, library, data[:cut], 0o755)
|
||||
|
||||
home := t.TempDir()
|
||||
// install.sh and install-skills.sh name the payload by basename;
|
||||
// the standalone event/devapp publishers mangle the full path.
|
||||
payloadName := "victim"
|
||||
if scriptName == "install-event.sh" || scriptName == "install-devapp.sh" {
|
||||
// Mirror the scripts' own mangling: full path, slashes and
|
||||
// backslashes to dashes, leading dash dropped.
|
||||
payloadName = strings.NewReplacer("/", "-", "\\", "-").Replace(filepath.Join(home, "victim"))
|
||||
payloadName = strings.TrimPrefix(payloadName, "-")
|
||||
}
|
||||
harness := `. "$DWS_TEST_LIBRARY"
|
||||
date() { printf '%s\n' 20260101-120000; }
|
||||
ROOT="$HOME/.dws/skill-backups"
|
||||
mkdir -p "$ROOT/20260101-120000" || exit 9
|
||||
printf 'must-survive\n' > "$ROOT/20260101-120000/user-data.txt" || exit 9
|
||||
mkdir -p "$HOME/victim" || exit 9
|
||||
printf 'payload\n' > "$HOME/victim/SKILL.md" || exit 9
|
||||
if ! backup_fn "$HOME/victim" >/dev/null 2>&1; then
|
||||
if [ -e "$HOME/victim/SKILL.md" ]; then
|
||||
exit 0 # a refused backup that keeps the source is contract-compliant
|
||||
fi
|
||||
exit 9
|
||||
fi
|
||||
[ -f "$ROOT/20260101-120000-1/` + "`printf '%s' \"" + payloadName + "\"`" + `/SKILL.md" ] || exit 3
|
||||
[ ! -e "$HOME/victim" ] || exit 4
|
||||
[ -f "$ROOT/20260101-120000/user-data.txt" ] || exit 5
|
||||
[ "$(cat "$ROOT/20260101-120000/user-data.txt")" = "must-survive" ] || exit 6
|
||||
[ ! -e "$ROOT/20260101-120000/.dws-skill-backup" ] || exit 7
|
||||
[ "$(cat "$ROOT/20260101-120000-1/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || exit 8
|
||||
exit 0
|
||||
`
|
||||
harnessPath := filepath.Join(t.TempDir(), "backup-adoption.sh")
|
||||
mustWriteFile(t, harnessPath, []byte(harness), 0o755)
|
||||
cmd := exec.Command("sh", "-c", "backup_fn() { "+backupFnName(scriptName)+" \"$1\"; }\n. \"$0\"", harnessPath)
|
||||
cmd.Env = append(os.Environ(), "HOME="+home, "DWS_TEST_LIBRARY="+library)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%s must not adopt an unproven stamp root: %v\n%s", scriptName, err, string(output))
|
||||
}
|
||||
if backup := findSkillBackupContent(home, "payload\n"); !strings.Contains(backup, "20260101-120000-1") {
|
||||
t.Fatalf("%s payload must land in the suffixed root, got %s", scriptName, backup)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func backupFnName(scriptName string) string {
|
||||
if scriptName == "install-event.sh" || scriptName == "install-devapp.sh" {
|
||||
return "backup_skill_dir"
|
||||
}
|
||||
return "backup_and_remove_skill_dir"
|
||||
}
|
||||
Reference in New Issue
Block a user