3259 lines
118 KiB
Go
3259 lines
118 KiB
Go
package scripts_test
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var expectedPackagedSkillTargets = []string{
|
|
".agents/skills/dingtalk-shared",
|
|
".claude/skills/dingtalk-shared",
|
|
".cursor/skills/dingtalk-shared",
|
|
".qoder/skills/dingtalk-shared",
|
|
".qoderwork/skills/dingtalk-shared",
|
|
".gemini/skills/dingtalk-shared",
|
|
".codex/skills/dingtalk-shared",
|
|
".zcode/skills/dingtalk-shared",
|
|
".github/skills/dingtalk-shared",
|
|
".windsurf/skills/dingtalk-shared",
|
|
".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",
|
|
".hermes/skills/dingtalk-shared",
|
|
}
|
|
|
|
var expectedReleaseAdmissionContexts = []string{
|
|
"Lint",
|
|
"Test",
|
|
"Coverage",
|
|
"Policy",
|
|
"Edition",
|
|
"Interface Integrity",
|
|
"AI Behavior",
|
|
"CLI Smoke",
|
|
"Mock MCP",
|
|
}
|
|
|
|
func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-package-managers.sh"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(verify-package-managers.sh) error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(scriptPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", scriptPath, err)
|
|
}
|
|
script := string(data)
|
|
for _, binary := range []string{`"$vendor_bin"`, `"$prefix/bin/dws"`} {
|
|
want := `LC_ALL=C grep -aFq "v$EXPECTED_VERSION" ` + binary
|
|
if !strings.Contains(script, want) {
|
|
t.Errorf("package-manager verifier is missing raw binary marker check %q", want)
|
|
}
|
|
}
|
|
if strings.Contains(script, `strings "$vendor_bin"`) || strings.Contains(script, `strings "$prefix/bin/dws"`) {
|
|
t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line")
|
|
}
|
|
for _, want := range []string{
|
|
"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"`,
|
|
} {
|
|
if !strings.Contains(script, want) {
|
|
t.Errorf("package-manager verifier is missing multi-layout contract %q", want)
|
|
}
|
|
}
|
|
if strings.Contains(script, "HOME_SKILL_TARGETS=") {
|
|
t.Fatal("package-manager verifier still declares the legacy mono target contract")
|
|
}
|
|
}
|
|
|
|
func TestNPMWrapperForwardsSIGTERMToVendor(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("POSIX signal forwarding contract")
|
|
}
|
|
nodePath, err := exec.LookPath("node")
|
|
if err != nil {
|
|
t.Skip("node is required to exercise the npm wrapper")
|
|
}
|
|
|
|
root := t.TempDir()
|
|
binDir := filepath.Join(root, "bin")
|
|
vendorDir := filepath.Join(root, "vendor")
|
|
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(vendorDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wrapperSource := filepath.Join("..", "..", "build", "npm", "bin", "dws.js")
|
|
wrapperData, err := os.ReadFile(wrapperSource)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wrapperPath := filepath.Join(binDir, "dws.js")
|
|
if err := os.WriteFile(wrapperPath, wrapperData, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
pidPath := filepath.Join(root, "child.pid")
|
|
signalPath := filepath.Join(root, "child.signal")
|
|
vendorPath := filepath.Join(vendorDir, "dws")
|
|
vendorScript := "#!/bin/sh\n" +
|
|
"printf '%s' \"$$\" > \"$DWS_TEST_CHILD_PID_FILE\"\n" +
|
|
"trap 'printf TERM > \"$DWS_TEST_SIGNAL_FILE\"; exit 0' TERM\n" +
|
|
"while :; do sleep 0.1; done\n"
|
|
if err := os.WriteFile(vendorPath, []byte(vendorScript), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cmd := exec.Command(nodePath, wrapperPath)
|
|
cmd.Env = append(os.Environ(),
|
|
"DWS_TEST_CHILD_PID_FILE="+pidPath,
|
|
"DWS_TEST_SIGNAL_FILE="+signalPath,
|
|
)
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
childPID := waitForPIDFile(t, pidPath)
|
|
t.Cleanup(func() {
|
|
_ = exec.Command("kill", "-TERM", strconv.Itoa(childPID)).Run()
|
|
})
|
|
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
|
t.Fatalf("signal npm wrapper: %v", err)
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- cmd.Wait() }()
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
t.Fatal("signal-terminated npm wrapper returned success")
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("npm wrapper did not exit after SIGTERM")
|
|
}
|
|
|
|
signalData, err := os.ReadFile(signalPath)
|
|
if err != nil {
|
|
t.Fatalf("vendor did not record SIGTERM: %v", err)
|
|
}
|
|
if got := strings.TrimSpace(string(signalData)); got != "TERM" {
|
|
t.Fatalf("vendor signal = %q, want TERM", got)
|
|
}
|
|
if err := exec.Command("kill", "-0", strconv.Itoa(childPID)).Run(); err == nil {
|
|
t.Fatalf("vendor process %d is still running after npm wrapper exited", childPID)
|
|
}
|
|
}
|
|
|
|
func TestNPMWrapperForwardsForegroundGroupSIGINTToVendorGroupOnce(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("POSIX foreground process-group signal contract")
|
|
}
|
|
nodePath, err := exec.LookPath("node")
|
|
if err != nil {
|
|
t.Skip("node is required to exercise the npm wrapper")
|
|
}
|
|
|
|
root := t.TempDir()
|
|
binDir := filepath.Join(root, "bin")
|
|
vendorDir := filepath.Join(root, "vendor")
|
|
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(vendorDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wrapperData, err := os.ReadFile(filepath.Join("..", "..", "build", "npm", "bin", "dws.js"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wrapperPath := filepath.Join(binDir, "dws.js")
|
|
if err := os.WriteFile(wrapperPath, wrapperData, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
pidPath := filepath.Join(root, "child.pid")
|
|
signalPath := filepath.Join(root, "child.signal")
|
|
descendantPIDPath := filepath.Join(root, "descendant.pid")
|
|
descendantSignalPath := filepath.Join(root, "descendant.signal")
|
|
vendorPath := filepath.Join(vendorDir, "dws")
|
|
vendorScript := `#!/usr/bin/env node
|
|
"use strict";
|
|
const fs = require("fs");
|
|
const childProcess = require("child_process");
|
|
const descendantSource = String.raw` + "`" + `
|
|
"use strict";
|
|
const fs = require("fs");
|
|
let exiting = false;
|
|
process.on("SIGINT", () => {
|
|
fs.appendFileSync(process.env.DWS_TEST_DESCENDANT_SIGNAL_FILE, "INT\n");
|
|
if (!exiting) {
|
|
exiting = true;
|
|
setTimeout(() => process.exit(0), 250);
|
|
}
|
|
});
|
|
fs.writeFileSync(process.env.DWS_TEST_DESCENDANT_PID_FILE, String(process.pid));
|
|
setInterval(() => {}, 1000);
|
|
` + "`" + `;
|
|
childProcess.spawn(process.execPath, ["-e", descendantSource], {
|
|
stdio: "inherit",
|
|
env: process.env,
|
|
});
|
|
let exiting = false;
|
|
process.on("SIGINT", () => {
|
|
fs.appendFileSync(process.env.DWS_TEST_SIGNAL_FILE, "INT\n");
|
|
if (!exiting) {
|
|
exiting = true;
|
|
setTimeout(() => process.exit(0), 250);
|
|
}
|
|
});
|
|
fs.writeFileSync(process.env.DWS_TEST_CHILD_PID_FILE, String(process.pid));
|
|
setInterval(() => {}, 1000);
|
|
`
|
|
if err := os.WriteFile(vendorPath, []byte(vendorScript), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cmd := exec.Command(nodePath, wrapperPath)
|
|
cmd.Env = append(os.Environ(),
|
|
"DWS_TEST_CHILD_PID_FILE="+pidPath,
|
|
"DWS_TEST_SIGNAL_FILE="+signalPath,
|
|
"DWS_TEST_DESCENDANT_PID_FILE="+descendantPIDPath,
|
|
"DWS_TEST_DESCENDANT_SIGNAL_FILE="+descendantSignalPath,
|
|
)
|
|
configureIsolatedProcessGroup(cmd)
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
childPID := waitForPIDFile(t, pidPath)
|
|
descendantPID := waitForPIDFile(t, descendantPIDPath)
|
|
t.Cleanup(func() {
|
|
_ = exec.Command("kill", "-TERM", strconv.Itoa(childPID)).Run()
|
|
_ = exec.Command("kill", "-TERM", strconv.Itoa(descendantPID)).Run()
|
|
})
|
|
wrapperGroup, err := processGroupID(cmd.Process.Pid)
|
|
if err != nil {
|
|
t.Fatalf("wrapper process group: %v", err)
|
|
}
|
|
childGroup, err := processGroupID(childPID)
|
|
if err != nil {
|
|
t.Fatalf("vendor process group: %v", err)
|
|
}
|
|
if childGroup == wrapperGroup {
|
|
t.Fatalf("vendor process group = wrapper process group %d; foreground signal would be duplicated", childGroup)
|
|
}
|
|
descendantGroup, err := processGroupID(descendantPID)
|
|
if err != nil {
|
|
t.Fatalf("descendant process group: %v", err)
|
|
}
|
|
if descendantGroup != childGroup {
|
|
t.Fatalf("descendant process group = %d, want vendor process group %d", descendantGroup, childGroup)
|
|
}
|
|
|
|
if err := signalProcessGroup(wrapperGroup, syscall.SIGINT); err != nil {
|
|
t.Fatalf("signal foreground process group: %v", err)
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- cmd.Wait() }()
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
t.Fatal("signal-terminated npm wrapper returned success")
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("npm wrapper did not exit after foreground SIGINT")
|
|
}
|
|
|
|
signalData, err := os.ReadFile(signalPath)
|
|
if err != nil {
|
|
t.Fatalf("vendor did not record SIGINT: %v", err)
|
|
}
|
|
if got := strings.Fields(string(signalData)); len(got) != 1 || got[0] != "INT" {
|
|
t.Fatalf("vendor signals = %q, want exactly one INT", got)
|
|
}
|
|
descendantSignalData, err := os.ReadFile(descendantSignalPath)
|
|
if err != nil {
|
|
t.Fatalf("vendor descendant did not record SIGINT: %v", err)
|
|
}
|
|
if got := strings.Fields(string(descendantSignalData)); len(got) != 1 || got[0] != "INT" {
|
|
t.Fatalf("vendor descendant signals = %q, want exactly one INT", got)
|
|
}
|
|
waitForProcessExit(t, childPID, "vendor")
|
|
waitForProcessExit(t, descendantPID, "vendor descendant")
|
|
}
|
|
|
|
func TestNPMWrapperPreservesInteractiveTTY(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("POSIX controlling-terminal contract")
|
|
}
|
|
nodePath, err := exec.LookPath("node")
|
|
if err != nil {
|
|
t.Skip("node is required to exercise the npm wrapper")
|
|
}
|
|
|
|
root := t.TempDir()
|
|
binDir := filepath.Join(root, "bin")
|
|
vendorDir := filepath.Join(root, "vendor")
|
|
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(vendorDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wrapperData, err := os.ReadFile(filepath.Join("..", "..", "build", "npm", "bin", "dws.js"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wrapperPath := filepath.Join(binDir, "dws.js")
|
|
if err := os.WriteFile(wrapperPath, wrapperData, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resultPath := filepath.Join(root, "interactive.result")
|
|
readyPath := filepath.Join(root, "interactive.ready")
|
|
vendorPath := filepath.Join(vendorDir, "dws")
|
|
vendorScript := "#!/bin/sh\n" +
|
|
"printf 'DWS prompt: ' > /dev/tty\n" +
|
|
"printf ready > \"$DWS_TEST_INTERACTIVE_READY\"\n" +
|
|
"IFS= read -r answer < /dev/tty\n" +
|
|
"printf '%s' \"$answer\" > \"$DWS_TEST_INTERACTIVE_RESULT\"\n"
|
|
if err := os.WriteFile(vendorPath, []byte(vendorScript), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cmd := exec.Command(nodePath, wrapperPath)
|
|
cmd.Env = append(os.Environ(),
|
|
"DWS_TEST_INTERACTIVE_RESULT="+resultPath,
|
|
"DWS_TEST_INTERACTIVE_READY="+readyPath,
|
|
)
|
|
terminal, err := startWithPTY(cmd)
|
|
if err != nil {
|
|
t.Fatalf("start npm wrapper with PTY: %v", err)
|
|
}
|
|
defer terminal.Close()
|
|
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
|
var output bytes.Buffer
|
|
outputDone := make(chan struct{})
|
|
go func() {
|
|
_, _ = io.Copy(&output, terminal)
|
|
close(outputDone)
|
|
}()
|
|
waitForFile(t, readyPath)
|
|
if _, err := fmt.Fprintln(terminal, "confirmed"); err != nil {
|
|
t.Fatalf("write interactive input: %v", err)
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() { done <- cmd.Wait() }()
|
|
var waitErr error
|
|
select {
|
|
case waitErr = <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("interactive npm wrapper did not exit after terminal input")
|
|
}
|
|
_ = terminal.Close()
|
|
<-outputDone
|
|
if waitErr != nil {
|
|
t.Fatalf("interactive npm wrapper: %v\noutput:\n%s", waitErr, output.String())
|
|
}
|
|
result, err := os.ReadFile(resultPath)
|
|
if err != nil {
|
|
t.Fatalf("interactive vendor did not record terminal input: %v\noutput:\n%s", err, output.String())
|
|
}
|
|
if got := strings.TrimSpace(string(result)); got != "confirmed" {
|
|
t.Fatalf("interactive input = %q, want confirmed\noutput:\n%s", got, output.String())
|
|
}
|
|
}
|
|
|
|
func waitForPIDFile(t *testing.T, path string) int {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
data, err := os.ReadFile(path)
|
|
if err == nil {
|
|
pid, parseErr := strconv.Atoi(strings.TrimSpace(string(data)))
|
|
if parseErr == nil && pid > 0 {
|
|
return pid
|
|
}
|
|
}
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timed out waiting for vendor pid file %s", path)
|
|
return 0
|
|
}
|
|
|
|
func waitForFile(t *testing.T, path string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return
|
|
}
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timed out waiting for file %s", path)
|
|
}
|
|
|
|
func waitForProcessExit(t *testing.T, pid int, label string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if err := exec.Command("kill", "-0", strconv.Itoa(pid)).Run(); err != nil {
|
|
return
|
|
}
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
t.Fatalf("%s process %d is still running after npm wrapper exited", label, pid)
|
|
}
|
|
|
|
func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
postGoreleaserPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "post-goreleaser.sh"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(post-goreleaser.sh) error = %v", err)
|
|
}
|
|
verifierPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-package-managers.sh"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(verify-package-managers.sh) error = %v", err)
|
|
}
|
|
|
|
distDir := filepath.Join(t.TempDir(), "dist")
|
|
targets := []string{
|
|
"dws-darwin-amd64.tar.gz",
|
|
"dws-darwin-arm64.tar.gz",
|
|
"dws-linux-amd64.tar.gz",
|
|
"dws-linux-arm64.tar.gz",
|
|
}
|
|
hostArchive := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz"
|
|
if runtime.GOOS == "windows" {
|
|
hostArchive = "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".zip"
|
|
}
|
|
foundHost := false
|
|
for _, target := range targets {
|
|
if target == hostArchive {
|
|
foundHost = true
|
|
break
|
|
}
|
|
}
|
|
if !foundHost {
|
|
targets = append(targets, hostArchive)
|
|
}
|
|
seedDistArtifacts(t, distDir, targets)
|
|
|
|
packageCmd := exec.Command("sh", postGoreleaserPath)
|
|
packageCmd.Env = postGoreleaserEnv(t, distDir, "v0.0.0-test", "https://downloads.example.com/dws/releases/v0.0.0-test")
|
|
if output, err := packageCmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, output)
|
|
}
|
|
|
|
verifyCmd := exec.Command("sh", verifierPath, "--npm-only")
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
t.Fatalf("Create(%s) error = %v", path, err)
|
|
}
|
|
defer file.Close()
|
|
|
|
content := []byte("#!/bin/sh\nexit 0\n")
|
|
switch {
|
|
case strings.HasSuffix(path, ".tar.gz"):
|
|
gzipWriter := gzip.NewWriter(file)
|
|
tarWriter := tar.NewWriter(gzipWriter)
|
|
if err := tarWriter.WriteHeader(&tar.Header{Name: "dws", Mode: 0o755, Size: int64(len(content))}); err != nil {
|
|
t.Fatalf("WriteHeader(%s) error = %v", path, err)
|
|
}
|
|
if _, err := tarWriter.Write(content); err != nil {
|
|
t.Fatalf("Write(%s) error = %v", path, err)
|
|
}
|
|
if err := tarWriter.Close(); err != nil {
|
|
t.Fatalf("Close tar(%s) error = %v", path, err)
|
|
}
|
|
if err := gzipWriter.Close(); err != nil {
|
|
t.Fatalf("Close gzip(%s) error = %v", path, err)
|
|
}
|
|
case strings.HasSuffix(path, ".zip"):
|
|
zipWriter := zip.NewWriter(file)
|
|
header := &zip.FileHeader{Name: "dws.exe", Method: zip.Store}
|
|
header.SetMode(0o755)
|
|
entry, err := zipWriter.CreateHeader(header)
|
|
if err != nil {
|
|
t.Fatalf("CreateHeader(%s) error = %v", path, err)
|
|
}
|
|
if _, err := entry.Write(content); err != nil {
|
|
t.Fatalf("Write(%s) error = %v", path, err)
|
|
}
|
|
if err := zipWriter.Close(); err != nil {
|
|
t.Fatalf("Close zip(%s) error = %v", path, err)
|
|
}
|
|
default:
|
|
t.Fatalf("unsupported archive path %s", path)
|
|
}
|
|
}
|
|
|
|
// seedDistArtifacts creates minimal goreleaser output archives and a
|
|
// checksums.txt stub so post-goreleaser.sh can run without a real build.
|
|
// Every archive is valid so the packaging tests exercise extraction for all
|
|
// platforms; Darwin archives are additionally processed by the signing path.
|
|
func seedDistArtifacts(t *testing.T, distDir string, targets []string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(distDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll(%s) error = %v", distDir, err)
|
|
}
|
|
|
|
for _, target := range targets {
|
|
p := filepath.Join(distDir, target)
|
|
seedDistArchive(t, p)
|
|
}
|
|
|
|
// Create empty checksums.txt (goreleaser creates this)
|
|
checksums := filepath.Join(distDir, "checksums.txt")
|
|
var lines []string
|
|
for _, target := range targets {
|
|
lines = append(lines, "deadbeef00000000000000000000000000000000000000000000000000000000 "+target)
|
|
}
|
|
if err := os.WriteFile(checksums, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(%s) error = %v", checksums, err)
|
|
}
|
|
}
|
|
|
|
func postGoreleaserEnv(t *testing.T, distDir, version, releaseBaseURL string) []string {
|
|
t.Helper()
|
|
|
|
binDir := t.TempDir()
|
|
fakeCodesign := filepath.Join(binDir, "codesign")
|
|
if err := os.WriteFile(fakeCodesign, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile(fake codesign) error = %v", err)
|
|
}
|
|
|
|
return append(os.Environ(),
|
|
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
|
|
"DWS_PACKAGE_VERSION="+version,
|
|
"DWS_PACKAGE_DIST_DIR="+distDir,
|
|
"DWS_RELEASE_BASE_URL="+releaseBaseURL,
|
|
)
|
|
}
|
|
|
|
func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "post-goreleaser.sh"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(post-goreleaser.sh) error = %v", err)
|
|
}
|
|
|
|
root := t.TempDir()
|
|
distDir := filepath.Join(root, "dist")
|
|
|
|
hostOS := runtime.GOOS
|
|
hostArch := runtime.GOARCH
|
|
archiveName := "dws-" + hostOS + "-" + hostArch + ".tar.gz"
|
|
if hostOS == "windows" {
|
|
archiveName = "dws-" + hostOS + "-" + hostArch + ".zip"
|
|
}
|
|
|
|
// Seed every archive referenced by the public multi-platform Homebrew formula.
|
|
// The local verification formula still selects the current host archive.
|
|
targets := []string{
|
|
"dws-darwin-amd64.tar.gz",
|
|
"dws-darwin-arm64.tar.gz",
|
|
"dws-linux-amd64.tar.gz",
|
|
"dws-linux-arm64.tar.gz",
|
|
}
|
|
foundHost := false
|
|
for _, target := range targets {
|
|
if target == archiveName {
|
|
foundHost = true
|
|
break
|
|
}
|
|
}
|
|
if !foundHost {
|
|
targets = append(targets, archiveName)
|
|
}
|
|
seedDistArtifacts(t, distDir, targets)
|
|
|
|
cmd := exec.Command("sh", scriptPath)
|
|
cmd.Env = postGoreleaserEnv(t, distDir, "v1.2.3", "https://downloads.example.com/dws/releases/v1.2.3")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output))
|
|
}
|
|
|
|
for _, rel := range []string{
|
|
"dws-skills.zip",
|
|
"checksums.txt",
|
|
filepath.Join("npm", "dingtalk-workspace-cli", "package.json"),
|
|
filepath.Join("homebrew", "dingtalk-workspace-cli.rb"),
|
|
filepath.Join("homebrew", "dingtalk-workspace-cli-local.rb"),
|
|
} {
|
|
full := filepath.Join(distDir, rel)
|
|
if _, err := os.Stat(full); err != nil {
|
|
t.Fatalf("Stat(%s) error = %v\noutput:\n%s", full, err, string(output))
|
|
}
|
|
}
|
|
|
|
formulaPath := filepath.Join(distDir, "homebrew", "dingtalk-workspace-cli-local.rb")
|
|
formulaData, err := os.ReadFile(formulaPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", formulaPath, err)
|
|
}
|
|
formulaText := string(formulaData)
|
|
for _, want := range []string{
|
|
"class DingtalkWorkspaceCliLocal < Formula",
|
|
"resource \"skills\" do",
|
|
"Install locally built DingTalk workspace CLI artifacts for verification",
|
|
"Agent Skills are bundled in #{pkgshare}/skills/dws",
|
|
} {
|
|
if !strings.Contains(formulaText, want) {
|
|
t.Fatalf("formula missing %q:\n%s", want, formulaText)
|
|
}
|
|
}
|
|
|
|
releaseFormulaPath := filepath.Join(distDir, "homebrew", "dingtalk-workspace-cli.rb")
|
|
releaseFormulaData, err := os.ReadFile(releaseFormulaPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", releaseFormulaPath, err)
|
|
}
|
|
releaseFormulaText := string(releaseFormulaData)
|
|
for _, want := range []string{
|
|
"class DingtalkWorkspaceCli < Formula",
|
|
`desc "Automate DingTalk workspace tasks from the terminal"`,
|
|
`version "1.2.3"`,
|
|
"on_macos do",
|
|
"on_linux do",
|
|
"https://downloads.example.com/dws/releases/v1.2.3/dws-darwin-amd64.tar.gz",
|
|
"https://downloads.example.com/dws/releases/v1.2.3/dws-darwin-arm64.tar.gz",
|
|
"https://downloads.example.com/dws/releases/v1.2.3/dws-linux-amd64.tar.gz",
|
|
"https://downloads.example.com/dws/releases/v1.2.3/dws-linux-arm64.tar.gz",
|
|
"https://downloads.example.com/dws/releases/v1.2.3/dws-skills.zip",
|
|
} {
|
|
if !strings.Contains(releaseFormulaText, want) {
|
|
t.Fatalf("release formula missing %q:\n%s", want, releaseFormulaText)
|
|
}
|
|
}
|
|
|
|
packageJSONPath := filepath.Join(distDir, "npm", "dingtalk-workspace-cli", "package.json")
|
|
packageJSON, err := os.ReadFile(packageJSONPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", packageJSONPath, err)
|
|
}
|
|
for _, want := range []string{
|
|
"\"name\": \"dingtalk-workspace-cli\"",
|
|
"DingTalk Workspace CLI",
|
|
"\"postinstall\": \"node install.js\"",
|
|
} {
|
|
if !strings.Contains(string(packageJSON), want) {
|
|
t.Fatalf("package.json missing %q:\n%s", want, string(packageJSON))
|
|
}
|
|
}
|
|
|
|
npmInstallPath := filepath.Join(distDir, "npm", "dingtalk-workspace-cli", "install.js")
|
|
npmInstallData, err := os.ReadFile(npmInstallPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", npmInstallPath, err)
|
|
}
|
|
npmInstallText := string(npmInstallData)
|
|
for _, target := range expectedPackagedSkillTargets {
|
|
agentDir := strings.TrimSuffix(target, "/dingtalk-shared")
|
|
if !strings.Contains(npmInstallText, agentDir) {
|
|
t.Fatalf("npm install.js missing %q:\n%s", agentDir, npmInstallText)
|
|
}
|
|
}
|
|
|
|
for _, want := range []string{"Agent Skills are bundled", "dws skill setup"} {
|
|
if !strings.Contains(releaseFormulaText, want) {
|
|
t.Fatalf("release formula missing caveat %q:\n%s", want, releaseFormulaText)
|
|
}
|
|
}
|
|
if strings.Contains(releaseFormulaText, "Dir.home") {
|
|
t.Fatalf("release formula must not mutate the user's home directory:\n%s", releaseFormulaText)
|
|
}
|
|
for _, forbidden := range []string{`require "fileutils"`, "FileUtils.", "__DESCRIPTION__"} {
|
|
if strings.Contains(releaseFormulaText, forbidden) {
|
|
t.Fatalf("release formula contains forbidden text %q:\n%s", forbidden, releaseFormulaText)
|
|
}
|
|
}
|
|
|
|
// Re-running post packaging must replace, not duplicate, the skills checksum.
|
|
cmd = exec.Command("sh", scriptPath)
|
|
cmd.Env = postGoreleaserEnv(t, distDir, "v1.2.3", "https://downloads.example.com/dws/releases/v1.2.3")
|
|
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 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)
|
|
}
|
|
}
|
|
|
|
func TestCheckedInHomebrewFormulaIsStableAndSideEffectFree(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
formulaPath := filepath.Join("..", "..", "Formula", "dingtalk-workspace-cli.rb")
|
|
data, err := os.ReadFile(formulaPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", formulaPath, err)
|
|
}
|
|
formula := string(data)
|
|
versionPrefix := `version "`
|
|
versionStart := strings.Index(formula, versionPrefix)
|
|
if versionStart == -1 {
|
|
t.Fatal("checked-in Homebrew formula has no explicit version")
|
|
}
|
|
versionStart += len(versionPrefix)
|
|
versionEnd := strings.Index(formula[versionStart:], `"`)
|
|
if versionEnd == -1 {
|
|
t.Fatal("checked-in Homebrew formula has an invalid version declaration")
|
|
}
|
|
version := formula[versionStart : versionStart+versionEnd]
|
|
if strings.Contains(version, "-") {
|
|
t.Fatalf("checked-in Homebrew formula must be stable, got version %q", version)
|
|
}
|
|
releaseBase := "releases/download/v" + version + "/"
|
|
for _, required := range []string{
|
|
releaseBase + "dws-darwin-amd64.tar.gz",
|
|
releaseBase + "dws-darwin-arm64.tar.gz",
|
|
releaseBase + "dws-linux-amd64.tar.gz",
|
|
releaseBase + "dws-linux-arm64.tar.gz",
|
|
releaseBase + "dws-skills.zip",
|
|
"dws skill setup",
|
|
} {
|
|
if !strings.Contains(formula, required) {
|
|
t.Errorf("checked-in Homebrew formula is missing %q", required)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{"-beta.", "Dir.home", "def post_install", `require "fileutils"`, "FileUtils."} {
|
|
if strings.Contains(formula, forbidden) {
|
|
t.Errorf("checked-in Homebrew formula contains forbidden text %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckedInHomebrewBetaFormulaIsSeparateAndKegOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
formulaPath := filepath.Join("..", "..", "Formula", "dingtalk-workspace-cli-beta.rb")
|
|
data, err := os.ReadFile(formulaPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", formulaPath, err)
|
|
}
|
|
formula := string(data)
|
|
versionPrefix := `version "`
|
|
versionStart := strings.Index(formula, versionPrefix)
|
|
if versionStart == -1 {
|
|
t.Fatal("checked-in Homebrew beta formula is missing a version declaration")
|
|
}
|
|
versionStart += len(versionPrefix)
|
|
versionEnd := strings.Index(formula[versionStart:], `"`)
|
|
if versionEnd == -1 {
|
|
t.Fatal("checked-in Homebrew beta formula has an invalid version declaration")
|
|
}
|
|
version := formula[versionStart : versionStart+versionEnd]
|
|
if !strings.Contains(version, "-") {
|
|
t.Fatalf("checked-in Homebrew beta formula must be a prerelease, got version %q", version)
|
|
}
|
|
releaseBase := "releases/download/v" + version + "/"
|
|
for _, required := range []string{
|
|
"class DingtalkWorkspaceCliBeta < Formula",
|
|
`desc "Automate DingTalk workspace tasks from the terminal (beta channel)"`,
|
|
`keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"`,
|
|
releaseBase + "dws-darwin-amd64.tar.gz",
|
|
releaseBase + "dws-darwin-arm64.tar.gz",
|
|
releaseBase + "dws-linux-amd64.tar.gz",
|
|
releaseBase + "dws-linux-arm64.tar.gz",
|
|
releaseBase + "dws-skills.zip",
|
|
"This beta is keg-only",
|
|
} {
|
|
if !strings.Contains(formula, required) {
|
|
t.Errorf("checked-in Homebrew beta formula is missing %q", required)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{"Dir.home", "def post_install", `require "fileutils"`, "FileUtils."} {
|
|
if strings.Contains(formula, forbidden) {
|
|
t.Errorf("checked-in Homebrew beta formula contains forbidden text %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPostGoreleaserBuildsVersionedBetaFormula(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "post-goreleaser.sh"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(post-goreleaser.sh) error = %v", err)
|
|
}
|
|
distDir := filepath.Join(t.TempDir(), "dist")
|
|
seedDistArtifacts(t, distDir, []string{
|
|
"dws-darwin-amd64.tar.gz",
|
|
"dws-darwin-arm64.tar.gz",
|
|
"dws-linux-amd64.tar.gz",
|
|
"dws-linux-arm64.tar.gz",
|
|
})
|
|
env := postGoreleaserEnv(t, distDir, "v1.2.3-beta.4", "https://downloads.example.com/dws/releases/v1.2.3-beta.4")
|
|
for i, value := range env {
|
|
if strings.HasPrefix(value, "DWS_PACKAGE_VERSION=") {
|
|
env[i] = "DWS_PACKAGE_VERSION=v1.2.3-beta.4"
|
|
}
|
|
}
|
|
cmd := exec.Command("sh", scriptPath)
|
|
cmd.Env = env
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, output)
|
|
}
|
|
|
|
formulaPath := filepath.Join(distDir, "homebrew", "dingtalk-workspace-cli-beta.rb")
|
|
data, err := os.ReadFile(formulaPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", formulaPath, err)
|
|
}
|
|
formula := string(data)
|
|
for _, required := range []string{
|
|
"class DingtalkWorkspaceCliBeta < Formula",
|
|
`desc "Automate DingTalk workspace tasks from the terminal (beta channel)"`,
|
|
`version "1.2.3-beta.4"`,
|
|
`keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"`,
|
|
"This beta is keg-only",
|
|
} {
|
|
if !strings.Contains(formula, required) {
|
|
t.Errorf("generated beta formula is missing %q", required)
|
|
}
|
|
}
|
|
if strings.Contains(formula, "__") {
|
|
t.Fatalf("generated beta formula contains an unresolved placeholder:\n%s", formula)
|
|
}
|
|
for _, forbidden := range []string{`require "fileutils"`, "FileUtils."} {
|
|
if strings.Contains(formula, forbidden) {
|
|
t.Fatalf("generated beta formula contains forbidden text %q:\n%s", forbidden, formula)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPostGoreleaserAllPlatformNpmAssets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "post-goreleaser.sh"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(post-goreleaser.sh) error = %v", err)
|
|
}
|
|
|
|
root := t.TempDir()
|
|
distDir := filepath.Join(root, "dist")
|
|
|
|
allArchives := []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",
|
|
}
|
|
|
|
// Seed dist/ with all platform archives (simulate goreleaser --target all)
|
|
seedDistArtifacts(t, distDir, allArchives)
|
|
|
|
cmd := exec.Command("sh", scriptPath)
|
|
cmd.Env = postGoreleaserEnv(t, distDir, "v9.9.9", "https://downloads.example.com/dws/releases/v9.9.9")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output))
|
|
}
|
|
|
|
for _, rel := range append(allArchives, "dws-skills.zip", "checksums.txt") {
|
|
full := filepath.Join(distDir, rel)
|
|
if _, err := os.Stat(full); err != nil {
|
|
t.Fatalf("Stat(%s) error = %v\noutput:\n%s", full, err, string(output))
|
|
}
|
|
}
|
|
|
|
packageAssetsDir := filepath.Join(distDir, "npm", "dingtalk-workspace-cli", "assets")
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPostGoreleaserUsesFlattenedSkillsSourceRoot(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "post-goreleaser.sh"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(post-goreleaser.sh) error = %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(scriptPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", scriptPath, err)
|
|
}
|
|
|
|
text := string(data)
|
|
// The new layout copies skills/mono/ to staging root + staging/mono/, so we
|
|
// no longer have a single `cd "$ROOT/skills/mono"`. Instead verify the
|
|
// staging-based create_skills_zip references both source trees explicitly.
|
|
for _, want := range []string{
|
|
`cp -R "$ROOT/skills/mono/." "$staging/"`,
|
|
`cp -R "$ROOT/skills/mono/." "$staging/mono/"`,
|
|
`cp -R "$ROOT/skills/multi/." "$staging/multi/"`,
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("post-goreleaser.sh missing skills layout line %q:\n%s", want, text)
|
|
}
|
|
}
|
|
if strings.Contains(text, `cd "$ROOT/skills/dws"`) {
|
|
t.Fatalf("post-goreleaser.sh still references legacy nested skills root:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// TestPostGoreleaserSkillsZipLayout exercises create_skills_zip end-to-end:
|
|
// runs post-goreleaser.sh against a tempdir, unzips dws-skills.zip, and
|
|
// verifies that the new zip layout contains (a) mono content at the root for
|
|
// backward compatibility, (b) an explicit mono/ subtree, and (c) a multi/
|
|
// subtree carrying per-product skills.
|
|
func TestPostGoreleaserSkillsZipLayout(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "post-goreleaser.sh"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(post-goreleaser.sh) error = %v", err)
|
|
}
|
|
|
|
root := t.TempDir()
|
|
distDir := filepath.Join(root, "dist")
|
|
|
|
hostOS := runtime.GOOS
|
|
hostArch := runtime.GOARCH
|
|
archiveName := "dws-" + hostOS + "-" + hostArch + ".tar.gz"
|
|
if hostOS == "windows" {
|
|
archiveName = "dws-" + hostOS + "-" + hostArch + ".zip"
|
|
}
|
|
targets := []string{
|
|
"dws-darwin-amd64.tar.gz",
|
|
"dws-darwin-arm64.tar.gz",
|
|
"dws-linux-amd64.tar.gz",
|
|
"dws-linux-arm64.tar.gz",
|
|
}
|
|
foundHost := false
|
|
for _, target := range targets {
|
|
if target == archiveName {
|
|
foundHost = true
|
|
break
|
|
}
|
|
}
|
|
if !foundHost {
|
|
targets = append(targets, archiveName)
|
|
}
|
|
seedDistArtifacts(t, distDir, targets)
|
|
|
|
cmd := exec.Command("sh", scriptPath)
|
|
cmd.Env = postGoreleaserEnv(t, distDir, "v0.0.0-test", "https://downloads.example.com/dws/releases/v0.0.0")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output))
|
|
}
|
|
|
|
skillsZip := filepath.Join(distDir, "dws-skills.zip")
|
|
extractDir := filepath.Join(root, "skills-extract")
|
|
if err := os.MkdirAll(extractDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll(%s) = %v", extractDir, err)
|
|
}
|
|
if out, err := exec.Command("unzip", "-q", skillsZip, "-d", extractDir).CombinedOutput(); err != nil {
|
|
t.Fatalf("unzip dws-skills.zip error = %v: %s", err, string(out))
|
|
}
|
|
|
|
// Backward compat: zip root must carry mono content.
|
|
for _, rel := range []string{"SKILL.md", "references", "scripts"} {
|
|
if _, err := os.Stat(filepath.Join(extractDir, rel)); err != nil {
|
|
t.Fatalf("zip root missing %s (backward compat broken): %v", rel, err)
|
|
}
|
|
}
|
|
// Explicit mono/ subdir.
|
|
if _, err := os.Stat(filepath.Join(extractDir, "mono", "SKILL.md")); err != nil {
|
|
t.Fatalf("zip missing mono/SKILL.md: %v", err)
|
|
}
|
|
// Schema hints are shared build-only inputs, not mono Skill content. They
|
|
// must not leak into either backward-compatible copy of the mono bundle.
|
|
for _, rel := range []string{
|
|
"schema-hints",
|
|
filepath.Join("mono", "schema-hints"),
|
|
filepath.Join("multi", "schema-hints"),
|
|
} {
|
|
if _, err := os.Stat(filepath.Join(extractDir, rel)); err == nil {
|
|
t.Fatalf("zip unexpectedly contains build-only %s", rel)
|
|
} else if !os.IsNotExist(err) {
|
|
t.Fatalf("Stat(%s) error = %v", rel, err)
|
|
}
|
|
}
|
|
// multi/ subtree with at least one per-product skill.
|
|
multiEntries, err := os.ReadDir(filepath.Join(extractDir, "multi"))
|
|
if err != nil {
|
|
t.Fatalf("ReadDir multi/ error = %v", err)
|
|
}
|
|
if len(multiEntries) == 0 {
|
|
t.Fatalf("multi/ is empty; expected per-product skills")
|
|
}
|
|
foundDingtalk := false
|
|
for _, e := range multiEntries {
|
|
if e.IsDir() && strings.HasPrefix(e.Name(), "dingtalk-") {
|
|
foundDingtalk = true
|
|
skillFile := filepath.Join(extractDir, "multi", e.Name(), "SKILL.md")
|
|
if _, err := os.Stat(skillFile); err != nil {
|
|
t.Fatalf("missing %s: %v", skillFile, err)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if !foundDingtalk {
|
|
t.Fatalf("multi/ does not contain any dingtalk-* skill: %v", multiEntries)
|
|
}
|
|
|
|
// Personal IM/OA events are owned by the standalone dingtalk-event Skill.
|
|
// Keep the release archive layout explicit so a future refactor cannot
|
|
// silently fold the Event entry point back into dingtalk-misc.
|
|
for _, rel := range []string{
|
|
filepath.Join("multi", "dingtalk-event", "SKILL.md"),
|
|
filepath.Join("multi", "dingtalk-event", "references", "event-oa.md"),
|
|
} {
|
|
if _, err := os.Stat(filepath.Join(extractDir, rel)); err != nil {
|
|
t.Fatalf("zip missing %s: %v", rel, err)
|
|
}
|
|
}
|
|
for _, rel := range []string{
|
|
filepath.Join("multi", "dingtalk-misc", "references", "event.md"),
|
|
filepath.Join("multi", "dingtalk-misc", "references", "event-im.md"),
|
|
filepath.Join("multi", "dingtalk-misc", "references", "event-im-keys.md"),
|
|
filepath.Join("multi", "dingtalk-misc", "references", "event-im-lifecycle.md"),
|
|
filepath.Join("multi", "dingtalk-misc", "references", "event-im-operations.md"),
|
|
filepath.Join("multi", "dingtalk-misc", "references", "event-im-output.md"),
|
|
filepath.Join("multi", "dingtalk-misc", "references", "event-oa.md"),
|
|
} {
|
|
if _, err := os.Stat(filepath.Join(extractDir, rel)); err == nil {
|
|
t.Fatalf("zip unexpectedly contains retired misc Event reference %s", rel)
|
|
} else if !os.IsNotExist(err) {
|
|
t.Fatalf("Stat(%s) error = %v", rel, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func readReleaseWorkflow(t *testing.T) string {
|
|
t.Helper()
|
|
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(release.yml) error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(workflowPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func releaseWorkflowSection(t *testing.T, workflow, startMarker, endMarker string) string {
|
|
t.Helper()
|
|
start := strings.Index(workflow, startMarker)
|
|
if start == -1 {
|
|
t.Fatalf("release workflow is missing section marker %q", startMarker)
|
|
}
|
|
end := strings.Index(workflow[start+len(startMarker):], endMarker)
|
|
if end == -1 {
|
|
t.Fatalf("release workflow section %q is missing end marker %q", startMarker, endMarker)
|
|
}
|
|
return workflow[start : start+len(startMarker)+end]
|
|
}
|
|
|
|
func releaseWorkflowRunScript(t *testing.T, workflow, stepName, nextStepName string) string {
|
|
t.Helper()
|
|
section := releaseWorkflowSection(
|
|
t,
|
|
workflow,
|
|
" - name: "+stepName+"\n",
|
|
"\n - name: "+nextStepName+"\n",
|
|
)
|
|
const runMarker = " run: |\n"
|
|
start := strings.Index(section, runMarker)
|
|
if start == -1 {
|
|
t.Fatalf("release workflow step %q is missing a run block", stepName)
|
|
}
|
|
|
|
lines := strings.Split(section[start+len(runMarker):], "\n")
|
|
for i, line := range lines {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(line, " ") {
|
|
t.Fatalf("release workflow step %q has an unexpected run indentation: %q", stepName, line)
|
|
}
|
|
lines[i] = strings.TrimPrefix(line, " ")
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) {
|
|
t.Parallel()
|
|
workflow := readReleaseWorkflow(t)
|
|
|
|
const (
|
|
checksCall = "github.rest.checks.listForRef"
|
|
paginatedCall = "github.paginate(github.rest.checks.listForRef"
|
|
immutableCall = `"GET /repos/{owner}/{repo}/immutable-releases"`
|
|
governanceID = `github-token: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}`
|
|
)
|
|
if got := strings.Count(workflow, checksCall); got != 3 {
|
|
t.Fatalf("release workflow Checks API call count = %d, want one tag check, one preflight check, and one Formula-parent check", got)
|
|
}
|
|
if got := strings.Count(workflow, paginatedCall); got != 3 {
|
|
t.Fatalf("release workflow paginated Checks API call count = %d, want one tag check, one preflight check, and one Formula-parent check", got)
|
|
}
|
|
if got := strings.Count(workflow, immutableCall); got != 2 {
|
|
t.Fatalf("release workflow immutable governance call count = %d, want one tag check and one preflight check", got)
|
|
}
|
|
if got := strings.Count(workflow, governanceID); got != 2 {
|
|
t.Fatalf("release workflow dedicated governance identity count = %d, want one per immutable check", got)
|
|
}
|
|
|
|
sections := map[string]string{
|
|
"preflight": releaseWorkflowSection(t, workflow, " governance-preflight:\n", "\n release-contract:\n"),
|
|
"tag": releaseWorkflowSection(t, workflow, " release-contract:\n", "\n release:\n"),
|
|
}
|
|
for name, section := range sections {
|
|
for _, required := range []string{
|
|
"checks: read",
|
|
checksCall,
|
|
paginatedCall,
|
|
"run.head_sha !== sha",
|
|
"const missing = requiredContexts.filter",
|
|
"const nonSuccess = requiredContexts.flatMap",
|
|
"missing:",
|
|
"non-success:",
|
|
immutableCall,
|
|
governanceID,
|
|
"RELEASE_GOVERNANCE_TOKEN with repository Administration read permission is required",
|
|
} {
|
|
if !strings.Contains(section, required) {
|
|
t.Errorf("%s governance path is missing %q", name, required)
|
|
}
|
|
}
|
|
for _, context := range expectedReleaseAdmissionContexts {
|
|
if !strings.Contains(section, fmt.Sprintf("%q", context)) {
|
|
t.Errorf("%s governance path is missing exact Code Admission context %q", name, context)
|
|
}
|
|
}
|
|
if strings.Contains(section, "check_name:") {
|
|
t.Errorf("%s governance path must fetch all check runs in one exact-SHA query", name)
|
|
}
|
|
if strings.Contains(section, "contents: write") {
|
|
t.Errorf("%s governance path must not grant contents write permission", name)
|
|
}
|
|
if strings.Contains(section, `github-token: ${{ secrets.GITHUB_TOKEN }}`) {
|
|
t.Errorf("%s immutable governance path must not fall back to GITHUB_TOKEN", name)
|
|
}
|
|
}
|
|
|
|
tagChecks := releaseWorkflowSection(
|
|
t,
|
|
sections["tag"],
|
|
" - name: Require successful Code Admission contexts on the sealed commit\n",
|
|
"\n - name: Require delivered previous stable baseline\n",
|
|
)
|
|
tagGovernanceToken := releaseWorkflowSection(
|
|
t,
|
|
sections["tag"],
|
|
" - name: Require release governance token\n",
|
|
"\n - name: Require immutable releases governance\n",
|
|
)
|
|
tagImmutableGovernance := releaseWorkflowSection(
|
|
t,
|
|
sections["tag"],
|
|
" - name: Require immutable releases governance\n",
|
|
"\n - name: Require successful beta delivery before stable promotion\n",
|
|
)
|
|
for name, section := range map[string]string{
|
|
"sealed Code Admission recheck": tagChecks,
|
|
"sealed governance token check": tagGovernanceToken,
|
|
"sealed immutable governance recheck": tagImmutableGovernance,
|
|
} {
|
|
if strings.Contains(section, "\n if:") {
|
|
t.Errorf("%s must run for push, recovery, and cloud create modes", name)
|
|
}
|
|
}
|
|
if strings.Contains(workflow, "CI"+" Gate") {
|
|
t.Error("release workflow must not retain the retired aggregate gate name")
|
|
}
|
|
}
|
|
|
|
func TestReleaseScriptRequiresExactCodeAdmissionContexts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "release.sh"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(release.sh) error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(scriptPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", scriptPath, err)
|
|
}
|
|
script := string(data)
|
|
|
|
const checksQuery = `commits/$sealed_commit/check-runs?filter=latest&per_page=100`
|
|
if got := strings.Count(script, checksQuery); got != 1 {
|
|
t.Fatalf("release script exact-SHA Checks API query count = %d, want 1", got)
|
|
}
|
|
for _, required := range []string{
|
|
`group_by(.name) | map(max_by(.id))`,
|
|
`missing_contexts=""`,
|
|
`non_success_contexts=""`,
|
|
`"$context_state" != "success"`,
|
|
"Code Admission contexts are not all successful for sealed commit",
|
|
"missing: %s; non-success: %s",
|
|
} {
|
|
if !strings.Contains(script, required) {
|
|
t.Errorf("release script Code Admission gate is missing %q", required)
|
|
}
|
|
}
|
|
for _, context := range expectedReleaseAdmissionContexts {
|
|
if !strings.Contains(script, "\n"+context+"\n") {
|
|
t.Errorf("release script is missing exact Code Admission context %q", context)
|
|
}
|
|
}
|
|
if strings.Contains(script, "check_name=") {
|
|
t.Error("release script must fetch all check runs in one exact-SHA query")
|
|
}
|
|
if strings.Contains(script, "CI"+" Gate") {
|
|
t.Error("release script must not retain the retired aggregate gate name")
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
|
|
t.Parallel()
|
|
workflow := readReleaseWorkflow(t)
|
|
preflight := releaseWorkflowSection(t, workflow, " governance-preflight:\n", "\n release-contract:\n")
|
|
|
|
for _, required := range []string{
|
|
"governance_preflight_commit:",
|
|
"governance_preflight_nonce:",
|
|
`format('Release governance preflight {0}', inputs.governance_preflight_nonce)`,
|
|
"name: Release governance preflight",
|
|
"name: Check out trusted preflight tooling",
|
|
"github.event_name == 'workflow_dispatch'",
|
|
"EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli",
|
|
`DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}`,
|
|
`test "$PREFLIGHT_COMMIT" = "$GITHUB_SHA"`,
|
|
`ref: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }}`,
|
|
"persist-credentials: false",
|
|
"governance preflight cannot be combined with npm repair",
|
|
} {
|
|
if !strings.Contains(workflow, required) {
|
|
t.Errorf("governance preflight contract is missing %q", required)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{
|
|
"contents: write",
|
|
"goreleaser",
|
|
"gh release",
|
|
"npm publish",
|
|
"sync-to-oss",
|
|
"sync-to-gitee",
|
|
} {
|
|
if strings.Contains(preflight, forbidden) {
|
|
t.Errorf("governance preflight must not contain publishing behavior %q", forbidden)
|
|
}
|
|
}
|
|
admission := strings.Index(preflight, "Require successful Code Admission contexts on the preflight commit")
|
|
immutableGovernance := strings.Index(preflight, "Require immutable releases governance")
|
|
if admission == -1 || immutableGovernance == -1 || admission > immutableGovernance {
|
|
t.Error("governance preflight must validate all exact Code Admission contexts before immutable-release governance")
|
|
}
|
|
for _, forbidden := range []string{
|
|
"HOMEBREW_PR_TOKEN",
|
|
"verify-homebrew-pr-token.sh",
|
|
"DWS_TAP_PR_",
|
|
} {
|
|
if strings.Contains(preflight, forbidden) {
|
|
t.Errorf("governance preflight must not retain obsolete Homebrew PR machinery %q", forbidden)
|
|
}
|
|
}
|
|
|
|
mirror := releaseWorkflowSection(t, workflow, " mirror-gitee-release:\n", "\n repair-npm:\n")
|
|
if !strings.Contains(mirror, "needs: [release-contract, release, publish-channels]") {
|
|
t.Error("Gitee publication must remain downstream of the verified release target and channel jobs")
|
|
}
|
|
repair := workflow[strings.Index(workflow, " repair-npm:\n"):]
|
|
for _, required := range []string{
|
|
"needs: dispatch-contract",
|
|
"needs.dispatch-contract.outputs.mode == 'repair_npm'",
|
|
`ref: ` + "`tags/withdrawn/${version}`",
|
|
"was withdrawn and cannot be repaired",
|
|
"verify-release-workflow-delivery.sh --npm-repair",
|
|
} {
|
|
if !strings.Contains(repair, required) {
|
|
t.Errorf("npm repair dispatch contract is missing %q", required)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowPlansAndSealsCurrentMainInTheCloud(t *testing.T) {
|
|
t.Parallel()
|
|
workflow := readReleaseWorkflow(t)
|
|
planStart := strings.Index(workflow, " release-plan:\n")
|
|
sealStart := strings.Index(workflow, " seal-release:\n")
|
|
if planStart == -1 || sealStart == -1 || planStart >= sealStart {
|
|
t.Fatal("cloud release plan and seal jobs are missing or out of order")
|
|
}
|
|
plan := workflow[planStart:sealStart]
|
|
seal := workflow[sealStart:]
|
|
|
|
for _, required := range []string{
|
|
"release_operation:",
|
|
"- none",
|
|
"- plan",
|
|
"- publish",
|
|
"release_channel:",
|
|
"release_bump:",
|
|
`release_flow + npm_repair + gitee_repair + oss_repair + governance + recovery`,
|
|
`echo "mode=plan_release"`,
|
|
`echo "mode=create_release"`,
|
|
"name: Authorize repository release requester",
|
|
`if: ${{ steps.mode.outputs.mode == 'plan_release' || steps.mode.outputs.mode == 'create_release' }}`,
|
|
`REQUESTED_BY: ${{ github.actor }}`,
|
|
`TRIGGERED_BY: ${{ github.triggering_actor }}`,
|
|
"github.rest.repos.getCollaboratorPermissionLevel",
|
|
`!["write", "admin"].includes(permission)`,
|
|
`needs.dispatch-contract.outputs.mode == 'plan_release'`,
|
|
`needs.governance-preflight.result == 'success'`,
|
|
"actions: read",
|
|
"contents: read",
|
|
`github.event.repository.default_branch`,
|
|
`GITHUB_REPOSITORY" = "$EXPECTED_REPOSITORY`,
|
|
`GITHUB_REF" = "refs/heads/$DEFAULT_BRANCH`,
|
|
`ref: ${{ github.sha }}`,
|
|
"persist-credentials: false",
|
|
`refs/remotes/origin/main)" = "$GITHUB_SHA`,
|
|
"next-release-version.sh",
|
|
`'refs/tags/v*' 'refs/tags/withdrawn/v*'`,
|
|
"release ref manifest is empty after fetching allocated tags",
|
|
"refs_fingerprint",
|
|
"Validate the candidate release contract before sealing",
|
|
"release-contract.sh",
|
|
"Require delivered previous stable baseline before sealing",
|
|
"Require delivered beta before sealing stable",
|
|
} {
|
|
if !strings.Contains(workflow, required) {
|
|
t.Errorf("cloud release contract is missing %q", required)
|
|
}
|
|
}
|
|
if strings.Contains(plan, "contents: write") {
|
|
t.Error("cloud release planning must remain read-only")
|
|
}
|
|
if !strings.Contains(plan, "needs: [dispatch-contract]") {
|
|
t.Error("cloud release planning must start after dispatch validation without serializing behind governance")
|
|
}
|
|
if strings.Contains(plan, "needs: [dispatch-contract, governance-preflight]") ||
|
|
strings.Contains(plan, "needs.governance-preflight.result") {
|
|
t.Error("read-only cloud planning must run in parallel with governance preflight")
|
|
}
|
|
if strings.Contains(plan, "refs/tags/v refs/tags/withdrawn/v") {
|
|
t.Error("cloud release planning must use wildcard ref patterns that match the seal API prefixes")
|
|
}
|
|
workflowHeader := workflow[:strings.Index(workflow, "\njobs:\n")]
|
|
if strings.Contains(workflowHeader, "\n push:\n") {
|
|
t.Error("new releases must not bypass cloud authorization through a tag-push trigger")
|
|
}
|
|
if regexp.MustCompile(`(?m)^ release_confirmation:$`).MatchString(workflowHeader) ||
|
|
strings.Contains(workflow, "release_confirmation must be exactly: PUBLISH") {
|
|
t.Error("cloud publication must not retain the redundant typed confirmation gate")
|
|
}
|
|
|
|
for _, required := range []string{
|
|
"name: Seal cloud release tag",
|
|
`name: ${{ inputs.release_channel == 'stable' && 'release-stable' || 'release-beta' }}`,
|
|
"actions: read",
|
|
"contents: write",
|
|
"name: Verify release environment governance before sealing",
|
|
`"GET /repos/{owner}/{repo}/environments/{environment_name}"`,
|
|
`environment.can_admins_bypass !== false`,
|
|
`branchPolicy?.protected_branches !== true`,
|
|
`branchPolicy?.custom_branch_policies !== false`,
|
|
`reviewerRules.length !== 0`,
|
|
`reviewerRules[0].prevent_self_review !== true`,
|
|
`permission.data.permission !== "admin"`,
|
|
"name: Create one immutable annotated release tag",
|
|
`branch.data.commit.sha !== commit`,
|
|
`actualFingerprint !== expectedFingerprint`,
|
|
`github.rest.git.createTag`,
|
|
`github.rest.git.createRef`,
|
|
`ref: ` + "`refs/tags/${version}`",
|
|
"`Release-Run: ${context.runId}`",
|
|
"`Requested-By: ${context.actor}`",
|
|
"`Requested-By-ID: ${context.payload.sender?.id || \"\"}`",
|
|
"`Sealed-Commit: ${commit}`",
|
|
"`Workflow-Commit: ${context.sha}`",
|
|
"`Allocation-Fingerprint: ${expectedFingerprint}`",
|
|
} {
|
|
if !strings.Contains(seal, required) {
|
|
t.Errorf("cloud release seal is missing %q", required)
|
|
}
|
|
}
|
|
createRef := strings.Index(seal, "github.rest.git.createRef")
|
|
environmentPolicy := strings.Index(seal, "Verify release environment governance before sealing")
|
|
createTagStep := strings.Index(seal, "Create one immutable annotated release tag")
|
|
if environmentPolicy == -1 || createTagStep == -1 || environmentPolicy > createTagStep {
|
|
t.Fatal("release Environment policy must be verified before the first tag write")
|
|
}
|
|
visibilityRead := strings.LastIndex(seal, "github.rest.git.getRef")
|
|
if createRef == -1 || visibilityRead == -1 || visibilityRead < createRef {
|
|
t.Fatal("cloud release seal must confirm tag-ref visibility after creating the ref")
|
|
}
|
|
postCreate := seal[createRef:]
|
|
for _, required := range []string{
|
|
"error.status === 404",
|
|
"error.status === 429",
|
|
"error.status >= 500",
|
|
"setTimeout",
|
|
`.data.object.type !== "tag"`,
|
|
"!== expectedTagObject",
|
|
".data.tag !== version",
|
|
".data.object.sha !== commit",
|
|
".data.message !==",
|
|
} {
|
|
if !strings.Contains(seal, required) {
|
|
t.Errorf("post-create tag verification is missing %q", required)
|
|
}
|
|
}
|
|
if strings.Count(postCreate, "github.rest.git.getRef") < 2 {
|
|
t.Error("post-create tag handling must both adopt an exact uncertain write and confirm final ref visibility")
|
|
}
|
|
if !regexp.MustCompile(`(?m)const \w*[Rr]etry\w*[Dd]elays\w* = \[(?:0,?\s*){1}[\d,\s]+\];`).MatchString(seal) {
|
|
t.Error("post-create tag verification must use a bounded retry policy")
|
|
}
|
|
for _, required := range []string{
|
|
"`${version} tag ref`",
|
|
"() => github.rest.git.getRef",
|
|
} {
|
|
if !strings.Contains(postCreate, required) {
|
|
t.Errorf("the bounded visibility retry must be used after createRef: missing %q", required)
|
|
}
|
|
}
|
|
retryLoop := strings.LastIndex(seal[:createRef], "for (const delayMs of")
|
|
retrySleep := strings.LastIndex(seal[:createRef], "await sleep(delayMs)")
|
|
if retryLoop == -1 || retrySleep == -1 || retryLoop > retrySleep {
|
|
t.Error("the bounded visibility retry must be used after createRef, not only declared")
|
|
}
|
|
for _, forbidden := range []string{
|
|
"actions/checkout",
|
|
"github.rest.git.updateRef",
|
|
"github.rest.git.deleteRef",
|
|
"git push",
|
|
"--force",
|
|
} {
|
|
if strings.Contains(seal, forbidden) {
|
|
t.Errorf("write-capable cloud seal must not contain %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowParallelizesSealedValidationWithoutWeakeningPublication(t *testing.T) {
|
|
t.Parallel()
|
|
workflow := readReleaseWorkflow(t)
|
|
releaseContract := releaseWorkflowSection(t, workflow, " release-contract:\n", "\n release:\n")
|
|
build := releaseWorkflowSection(t, workflow, " release:\n", "\n verify-darwin-signatures:\n")
|
|
validation := releaseWorkflowSection(t, workflow, " release-validation:\n", "\n release-plan:\n")
|
|
publish := releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n")
|
|
plan := releaseWorkflowSection(t, workflow, " release-plan:\n", "\n seal-release:\n")
|
|
|
|
for _, required := range []string{
|
|
"id: candidate",
|
|
"previous_stable: ${{ steps.candidate.outputs.previous_stable }}",
|
|
"previous_stable_commit: ${{ steps.candidate.outputs.previous_stable_commit }}",
|
|
"from_beta_commit: ${{ steps.candidate.outputs.from_beta_commit }}",
|
|
`printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT"`,
|
|
} {
|
|
if !strings.Contains(plan, required) {
|
|
t.Errorf("cloud release plan does not export sealed contract evidence %q", required)
|
|
}
|
|
}
|
|
|
|
for _, required := range []string{
|
|
"channel: ${{ steps.metadata.outputs.channel }}",
|
|
"previous_stable: ${{ steps.metadata.outputs.previous_stable }}",
|
|
"name: Resolve validated release metadata",
|
|
`if test "$DISPATCH_MODE" = create_release`,
|
|
`test -n "$previous_stable_commit"`,
|
|
`test -n "$from_beta_commit"`,
|
|
`unsupported validated release channel`,
|
|
`if: ${{ needs.dispatch-contract.outputs.mode != 'create_release' }}`,
|
|
} {
|
|
if !strings.Contains(releaseContract, required) {
|
|
t.Errorf("release contract cloud fast path is missing %q", required)
|
|
}
|
|
}
|
|
|
|
for _, required := range []string{
|
|
"name: Validate sealed release (${{ matrix.check }})",
|
|
"needs: [release-contract]",
|
|
"fail-fast: false",
|
|
"- automation",
|
|
"- compatibility",
|
|
"- e2e",
|
|
"verify-github-tag-authority.sh",
|
|
"go test -v -count=1 -timeout=5m ./test/scripts/... -run '^TestRelease'",
|
|
`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")
|
|
}
|
|
goreleaser := strings.Index(build, "name: Build release artifacts without publishing")
|
|
setupNode := strings.Index(build, "name: Setup Node.js")
|
|
archiveTools := strings.Index(build, "name: Install archive tooling")
|
|
if goreleaser == -1 || setupNode == -1 || archiveTools == -1 ||
|
|
goreleaser > setupNode || goreleaser > archiveTools {
|
|
t.Error("post-processing-only Node and archive tooling must be installed after GoReleaser")
|
|
}
|
|
|
|
for _, required := range []string{
|
|
"needs: [release-contract, release-validation, release, verify-darwin-signatures]",
|
|
"needs.release-validation.result == 'success'",
|
|
} {
|
|
if !strings.Contains(publish, required) {
|
|
t.Errorf("immutable publication is not blocked by parallel validation %q", required)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowHidesOnlyVerifiedSealedTagFromCompatibilityBaseline(t *testing.T) {
|
|
t.Parallel()
|
|
workflow := readReleaseWorkflow(t)
|
|
validation := releaseWorkflowSection(t, workflow, " release-validation:\n", "\n release-plan:\n")
|
|
preparedView := releaseWorkflowSection(
|
|
t,
|
|
validation,
|
|
" - name: Prepare delivered-stable compatibility ref view\n",
|
|
"\n - name: Set up Go\n",
|
|
)
|
|
script := releaseWorkflowRunScript(
|
|
t,
|
|
validation,
|
|
"Prepare delivered-stable compatibility ref view",
|
|
"Set up Go",
|
|
)
|
|
|
|
if !strings.Contains(preparedView, "if: ${{ matrix.check == 'compatibility' }}") {
|
|
t.Fatal("delivered-stable ref view must run only for the compatibility matrix leg")
|
|
}
|
|
for _, required := range []string{
|
|
"RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}",
|
|
"RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}",
|
|
"RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}",
|
|
"PREVIOUS_STABLE: ${{ needs.release-contract.outputs.previous_stable }}",
|
|
"PREVIOUS_STABLE_COMMIT: ${{ needs.release-contract.outputs.previous_stable_commit }}",
|
|
} {
|
|
if !strings.Contains(preparedView, required) {
|
|
t.Errorf("delivered-stable compatibility view is missing %q", required)
|
|
}
|
|
}
|
|
for _, required := range []string{
|
|
`test "$RELEASE_VERSION" != "$PREVIOUS_STABLE"`,
|
|
`test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"`,
|
|
`test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}")" = "$RELEASE_TAG_OBJECT"`,
|
|
`test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}^{commit}")" = "$RELEASE_COMMIT"`,
|
|
`test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"`,
|
|
`git update-ref -d "refs/tags/${RELEASE_VERSION}" "$RELEASE_TAG_OBJECT"`,
|
|
`git show-ref --verify --quiet "refs/tags/${RELEASE_VERSION}"`,
|
|
} {
|
|
if !strings.Contains(script, required) {
|
|
t.Errorf("delivered-stable compatibility view is missing %q", required)
|
|
}
|
|
}
|
|
|
|
verifiedTag := strings.Index(validation, "verify-github-tag-authority.sh")
|
|
deleteLocalTag := strings.Index(validation, "git update-ref -d")
|
|
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")
|
|
}
|
|
for _, forbidden := range []string{"git push --delete", "github.rest.git.deleteRef"} {
|
|
if strings.Contains(workflow, forbidden) {
|
|
t.Errorf("release workflow must not delete a remote ref: found %q", forbidden)
|
|
}
|
|
}
|
|
|
|
repo, err := os.MkdirTemp(".", ".release-compatibility-test-")
|
|
if err != nil {
|
|
t.Fatalf("MkdirTemp(release compatibility test repository) error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := os.RemoveAll(repo); err != nil {
|
|
t.Errorf("RemoveAll(%s) error = %v", repo, err)
|
|
}
|
|
})
|
|
mustRun(t, repo, "git", "init", "-b", "main")
|
|
mustRun(t, repo, "git", "config", "user.name", "Release Compatibility Test")
|
|
mustRun(t, repo, "git", "config", "user.email", "release-compatibility@example.com")
|
|
mustWriteFile(t, filepath.Join(repo, "tracked"), []byte("stable\n"), 0o644)
|
|
mustRun(t, repo, "git", "add", "tracked")
|
|
mustRun(t, repo, "git", "commit", "-m", "stable release")
|
|
previousStableCommit := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD"))
|
|
mustRun(t, repo, "git", "tag", "-a", "v1.0.57", "-m", "v1.0.57")
|
|
mustWriteFile(t, filepath.Join(repo, "tracked"), []byte("sealed\n"), 0o644)
|
|
mustRun(t, repo, "git", "commit", "-am", "sealed release")
|
|
releaseCommit := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD"))
|
|
mustRun(t, repo, "git", "tag", "-a", "v1.0.58", "-m", "v1.0.58")
|
|
releaseTagObject := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/v1.0.58"))
|
|
|
|
run := func(tagObject string) ([]byte, error) {
|
|
cmd := exec.Command("sh", "-c", script)
|
|
cmd.Dir = repo
|
|
cmd.Env = make([]string, 0, len(os.Environ())+5)
|
|
for _, entry := range os.Environ() {
|
|
if strings.HasPrefix(entry, "RELEASE_VERSION=") ||
|
|
strings.HasPrefix(entry, "RELEASE_COMMIT=") ||
|
|
strings.HasPrefix(entry, "RELEASE_TAG_OBJECT=") ||
|
|
strings.HasPrefix(entry, "PREVIOUS_STABLE=") ||
|
|
strings.HasPrefix(entry, "PREVIOUS_STABLE_COMMIT=") {
|
|
continue
|
|
}
|
|
cmd.Env = append(cmd.Env, entry)
|
|
}
|
|
cmd.Env = append(cmd.Env,
|
|
"RELEASE_VERSION=v1.0.58",
|
|
"RELEASE_COMMIT="+releaseCommit,
|
|
"RELEASE_TAG_OBJECT="+tagObject,
|
|
"PREVIOUS_STABLE=v1.0.57",
|
|
"PREVIOUS_STABLE_COMMIT="+previousStableCommit,
|
|
)
|
|
return cmd.CombinedOutput()
|
|
}
|
|
|
|
if output, err := run(releaseTagObject); err != nil {
|
|
t.Fatalf("prepared compatibility ref view failed: %v\noutput:\n%s", err, output)
|
|
}
|
|
candidateTag := exec.Command("git", "rev-parse", "--verify", "refs/tags/v1.0.58")
|
|
candidateTag.Dir = repo
|
|
if _, err := candidateTag.CombinedOutput(); err == nil {
|
|
t.Fatal("prepared compatibility ref view left the sealed candidate tag visible")
|
|
}
|
|
mustRun(t, repo, "git", "cat-file", "-e", releaseTagObject+"^{tag}")
|
|
if got := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/v1.0.57^{commit}")); got != previousStableCommit {
|
|
t.Fatalf("previous stable tag commit = %s, want %s", got, previousStableCommit)
|
|
}
|
|
if got := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD")); got != releaseCommit {
|
|
t.Fatalf("HEAD after prepared compatibility ref view = %s, want %s", got, releaseCommit)
|
|
}
|
|
|
|
mustRun(t, repo, "git", "update-ref", "refs/tags/v1.0.58", releaseTagObject)
|
|
if output, err := run(strings.Repeat("0", 40)); err == nil {
|
|
t.Fatalf("prepared compatibility ref view unexpectedly accepted a mismatched tag object\noutput:\n%s", output)
|
|
}
|
|
if got := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/v1.0.58")); got != releaseTagObject {
|
|
t.Fatalf("mismatched tag object removed or changed the local sealed tag: got %s, want %s", got, releaseTagObject)
|
|
}
|
|
}
|
|
|
|
func TestReleaseFingerprintRefPatternsMatchAllAllocatedTags(t *testing.T) {
|
|
t.Parallel()
|
|
repo := t.TempDir()
|
|
mustRun(t, repo, "git", "init", "-b", "main")
|
|
mustRun(t, repo, "git", "config", "user.name", "Release Fingerprint Test")
|
|
mustRun(t, repo, "git", "config", "user.email", "release-fingerprint@example.com")
|
|
mustWriteFile(t, filepath.Join(repo, "tracked"), []byte("fixture\n"), 0o644)
|
|
mustRun(t, repo, "git", "add", "tracked")
|
|
mustRun(t, repo, "git", "commit", "-m", "fixture")
|
|
|
|
allocatedTags := []string{
|
|
"v1.0.52",
|
|
"v1.0.53-beta.5",
|
|
"withdrawn/v1.0.51",
|
|
}
|
|
for _, tag := range allocatedTags {
|
|
mustRun(t, repo, "git", "tag", "-a", tag, "-m", "Release "+tag)
|
|
}
|
|
mustRun(t, repo, "git", "tag", "-a", "release/v1.0.52", "-m", "unrelated namespace")
|
|
legacy := exec.Command(
|
|
"git", "for-each-ref", "--format=%(refname)=%(objectname)",
|
|
"refs/tags/v", "refs/tags/withdrawn/v",
|
|
)
|
|
legacy.Dir = repo
|
|
legacyOutput, err := legacy.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("legacy git for-each-ref error = %v\noutput:\n%s", err, legacyOutput)
|
|
}
|
|
if strings.TrimSpace(string(legacyOutput)) != "" {
|
|
t.Fatalf("legacy component patterns unexpectedly matched flat release refs:\n%s", legacyOutput)
|
|
}
|
|
|
|
cmd := exec.Command(
|
|
"git", "for-each-ref", "--format=%(refname)=%(objectname)",
|
|
"refs/tags/v*", "refs/tags/withdrawn/v*",
|
|
)
|
|
cmd.Dir = repo
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git for-each-ref error = %v\noutput:\n%s", err, output)
|
|
}
|
|
got := strings.Split(strings.TrimSpace(string(output)), "\n")
|
|
sort.Strings(got)
|
|
|
|
want := make([]string, 0, len(allocatedTags))
|
|
for _, tag := range allocatedTags {
|
|
object := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/"+tag))
|
|
want = append(want, "refs/tags/"+tag+"="+object)
|
|
}
|
|
sort.Strings(want)
|
|
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
|
t.Fatalf("release ref set differs from the seal API set\ngot:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
|
}
|
|
workflowDigest := sha256.Sum256([]byte(strings.Join(got, "\n") + "\n"))
|
|
sealDigest := sha256.Sum256([]byte(strings.Join(want, "\n") + "\n"))
|
|
if workflowDigest != sealDigest {
|
|
t.Fatalf("release ref fingerprint differs from seal fingerprint: workflow=%x seal=%x", workflowDigest, sealDigest)
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowAcceptsGuardedLocalTagMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
workflow := readReleaseWorkflow(t)
|
|
|
|
for _, required := range []string{
|
|
`const cloudOnlyKeys = [`,
|
|
`const cloudKeys = ["Channel", ...cloudOnlyKeys];`,
|
|
`const hasAnyCloudMetadata = cloudOnlyKeys.some((key) => tagFields.has(key));`,
|
|
`const isCloudSeal = cloudKeys.every((key) => tagFields.has(key));`,
|
|
} {
|
|
if !strings.Contains(workflow, required) {
|
|
t.Errorf("local tag metadata compatibility is missing %q", required)
|
|
}
|
|
}
|
|
if strings.Contains(workflow, `const hasAnyCloudMetadata = cloudKeys.some((key) => tagFields.has(key));`) {
|
|
t.Error("Channel-only guarded local tags must not be classified as partial cloud seals")
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowRequiresOSSOnlyWhenMirrorIsEnabled(t *testing.T) {
|
|
t.Parallel()
|
|
workflow := readReleaseWorkflow(t)
|
|
releaseContract := releaseWorkflowSection(t, workflow, " release-contract:\n", "\n release:\n")
|
|
targetAuthority := releaseWorkflowSection(
|
|
t,
|
|
releaseContract,
|
|
" - name: Resolve and verify exact release target\n",
|
|
"\n - name: Check out repository\n",
|
|
)
|
|
ossStep := releaseWorkflowSection(
|
|
t,
|
|
workflow,
|
|
" - name: Sync release artifacts to China OSS mirror\n",
|
|
"\n mirror-gitee-release:\n",
|
|
)
|
|
|
|
for _, required := range []string{
|
|
`if: ${{ needs.release-contract.outputs.oss_mirror == 'enabled' }}`,
|
|
`run: ./scripts/release/sync-to-oss.sh`,
|
|
`DWS_REQUIRE_OSS: "1"`,
|
|
} {
|
|
if !strings.Contains(ossStep, required) {
|
|
t.Errorf("opt-in OSS publication is missing %q", required)
|
|
}
|
|
}
|
|
for _, required := range []string{
|
|
`OSS_MIRROR: ${{ vars.ENABLE_OSS_MIRROR == 'true' && 'enabled' || 'deferred' }}`,
|
|
`OSS-Mirror: ${ossMirror}`,
|
|
`core.setOutput("oss_mirror", ossMirror);`,
|
|
} {
|
|
if !strings.Contains(workflow, required) {
|
|
t.Errorf("immutable OSS release policy is missing %q", required)
|
|
}
|
|
}
|
|
if strings.Contains(ossStep, "vars.ENABLE_OSS_MIRROR") {
|
|
t.Error("channel publication must use the immutable tag policy, not the current repository variable")
|
|
}
|
|
for _, required := range []string{
|
|
`const ossMirror = tagFields.has("OSS-Mirror")`,
|
|
`? tagFields.get("OSS-Mirror")`,
|
|
`: "enabled";`,
|
|
`!["enabled", "deferred"].includes(ossMirror)`,
|
|
`core.setOutput("oss_mirror", ossMirror);`,
|
|
} {
|
|
if !strings.Contains(targetAuthority, required) {
|
|
t.Errorf("release target OSS policy authority is missing %q", required)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
|
|
t.Parallel()
|
|
workflow := readReleaseWorkflow(t)
|
|
dispatch := releaseWorkflowSection(t, workflow, " dispatch-contract:\n", "\n governance-preflight:\n")
|
|
start := strings.Index(workflow, " repair-channel:\n")
|
|
if start == -1 {
|
|
t.Fatal("release workflow is missing the channel repair job")
|
|
}
|
|
end := strings.Index(workflow[start:], "\n release-plan:\n")
|
|
if end == -1 {
|
|
t.Fatal("release workflow channel repair job is missing its end marker")
|
|
}
|
|
repair := workflow[start : start+end]
|
|
authority := releaseWorkflowSection(
|
|
t,
|
|
repair,
|
|
" - name: Verify immutable release authority\n",
|
|
"\n - name: Require sealed OSS policy for repair\n",
|
|
)
|
|
tagAuthority := releaseWorkflowSection(
|
|
t,
|
|
repair,
|
|
" - name: Fetch and verify sealed release tag\n",
|
|
"\n - name: Require successful Release workflow delivery\n",
|
|
)
|
|
|
|
for _, required := range []string{
|
|
"repair_gitee_version:",
|
|
`format('Release Gitee repair {0}', inputs.repair_gitee_version)`,
|
|
`REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }}`,
|
|
"repair_oss_version:",
|
|
`format('Release OSS repair {0}', inputs.repair_oss_version)`,
|
|
`REPAIR_OSS_VERSION: ${{ inputs.repair_oss_version }}`,
|
|
"gitee_repair=0",
|
|
"oss_repair=0",
|
|
`test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1`,
|
|
`test -z "$REPAIR_OSS_VERSION" || oss_repair=1`,
|
|
"release_flow + npm_repair + gitee_repair + oss_repair + governance + recovery",
|
|
`echo "mode=repair_gitee" >> "$GITHUB_OUTPUT"`,
|
|
`echo "mode=repair_oss" >> "$GITHUB_OUTPUT"`,
|
|
} {
|
|
if !strings.Contains(dispatch, required) && !strings.Contains(workflow, required) {
|
|
t.Errorf("channel repair dispatch contract is missing %q", required)
|
|
}
|
|
}
|
|
|
|
for _, required := range []string{
|
|
"needs: dispatch-contract",
|
|
`if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && (needs.dispatch-contract.outputs.mode == 'repair_gitee' || needs.dispatch-contract.outputs.mode == 'repair_oss') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }}`,
|
|
`github.ref == format('refs/heads/{0}', github.event.repository.default_branch)`,
|
|
`github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'`,
|
|
"actions: read",
|
|
"contents: read",
|
|
`ref: ${{ github.sha }}`,
|
|
"path: tooling",
|
|
"persist-credentials: false",
|
|
"release_is_stable_version",
|
|
"release_is_prerelease_version",
|
|
`ref: ` + "`tags/withdrawn/${version}`",
|
|
"was withdrawn and cannot be repaired",
|
|
`ref: ` + "`tags/${version}`",
|
|
`["ahead", "identical"].includes(comparison.data.status)`,
|
|
"!release.data.immutable",
|
|
`release.data.prerelease !== expectedPrerelease`,
|
|
"assetNames.length !== expectedAssets.size",
|
|
"new Set(assetNames).size !== expectedAssets.size",
|
|
`core.setOutput("tag_object", ref.data.object.sha)`,
|
|
"Require sealed OSS policy for repair",
|
|
"OSS repair is unavailable because this immutable release deferred the OSS channel.",
|
|
`ref: ${{ steps.authority.outputs.commit_sha }}`,
|
|
"path: release-source",
|
|
"verify-github-tag-authority.sh",
|
|
`GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`,
|
|
"verify-release-workflow-delivery.sh",
|
|
`REPAIR_MODE: ${{ needs.dispatch-contract.outputs.mode }}`,
|
|
"repair_gitee) target=gitee",
|
|
"repair_oss) target=oss",
|
|
`--channel-repair "$target"`,
|
|
`DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`,
|
|
"verify-release-artifacts.sh",
|
|
`--repo "$GITHUB_REPOSITORY"`,
|
|
`if: ${{ needs.dispatch-contract.outputs.mode == 'repair_gitee' }}`,
|
|
`DWS_REQUIRE_GITEE: "1"`,
|
|
`if: ${{ needs.dispatch-contract.outputs.mode == 'repair_oss' }}`,
|
|
`DWS_REQUIRE_OSS: "1"`,
|
|
`OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}`,
|
|
`OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}`,
|
|
`OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}`,
|
|
`OSS_BUCKET: ${{ secrets.OSS_BUCKET }}`,
|
|
`OSS_PREFIX: ${{ secrets.OSS_PREFIX }}`,
|
|
"working-directory: release-source",
|
|
"working-directory: tooling\n run: |\n " +
|
|
`"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"`,
|
|
`"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-oss.sh"`,
|
|
} {
|
|
if !strings.Contains(repair, required) {
|
|
t.Errorf("channel repair authority is missing %q", required)
|
|
}
|
|
}
|
|
for _, required := range []string{
|
|
`const tagFields = new Map();`,
|
|
`const ossMirror = tagFields.has("OSS-Mirror")`,
|
|
`? tagFields.get("OSS-Mirror")`,
|
|
`: "enabled";`,
|
|
`!["enabled", "deferred"].includes(ossMirror)`,
|
|
`core.setOutput("oss_mirror", ossMirror);`,
|
|
} {
|
|
if !strings.Contains(authority, required) {
|
|
t.Errorf("channel repair tag policy authority is missing %q", required)
|
|
}
|
|
}
|
|
npmRepair := releaseWorkflowSection(t, workflow, " repair-npm:\n", "\n release-delivery-gate:\n")
|
|
if strings.Contains(npmRepair, `const ossMirror`) || strings.Contains(npmRepair, `core.setOutput("oss_mirror"`) {
|
|
t.Error("npm repair must not parse or export the channel-only OSS policy")
|
|
}
|
|
if strings.Contains(repair, "ENABLE_OSS_MIRROR") {
|
|
t.Error("OSS repair must use the immutable tag policy, not the current repository variable")
|
|
}
|
|
for _, asset := range []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",
|
|
"dws-skills.zip",
|
|
"checksums.txt",
|
|
} {
|
|
if strings.Count(repair, `"`+asset+`"`) != 1 {
|
|
t.Errorf("channel repair must require exactly one %s asset declaration", asset)
|
|
}
|
|
}
|
|
if strings.Contains(repair, "contents: write") {
|
|
t.Error("channel repair must not grant contents write permission")
|
|
}
|
|
for _, forbidden := range []string{
|
|
"RELEASE_GOVERNANCE_TOKEN",
|
|
"HOMEBREW_PR_TOKEN",
|
|
"NPM_TOKEN",
|
|
} {
|
|
if strings.Contains(repair, forbidden) {
|
|
t.Errorf("channel repair must not expose unrelated credential %s", forbidden)
|
|
}
|
|
}
|
|
if strings.Contains(repair, "ref: ${{ github.event.repository.default_branch }}") {
|
|
t.Error("channel repair must not check out a floating default branch")
|
|
}
|
|
if !strings.Contains(tagAuthority, `GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`) {
|
|
t.Error("remote annotated-tag authority step must receive the read-only GitHub token")
|
|
}
|
|
if got := strings.Count(repair, "working-directory: tooling"); got < 4 {
|
|
t.Errorf("channel repair trusted tooling working-directory count = %d, want at least 4", got)
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
|
|
t.Parallel()
|
|
workflow := readReleaseWorkflow(t)
|
|
|
|
for _, required := range []string{
|
|
"recover_release_version:",
|
|
"recover_release_tag_object:",
|
|
"recover_release_commit:",
|
|
"recover_failed_run_id:",
|
|
"recover_failed_run_attempt:",
|
|
"recover_release_nonce:",
|
|
"recover_release_confirmation:",
|
|
`format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce)`,
|
|
"workflow_dispatch must select exactly one release mode",
|
|
"release recovery confirmation must equal the exact version",
|
|
"recover_release_nonce must be bound to the release commit",
|
|
`run.path !== ".github/workflows/release.yml"`,
|
|
`const expectedEvent = failedByCloud ? "workflow_dispatch" : "push"`,
|
|
`run.event !== expectedEvent`,
|
|
`tagFields.get("Release-Run") !== failedRunId`,
|
|
`"GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}"`,
|
|
"attempt_number: Number(failedRunAttempt)",
|
|
"run.run_attempt !== Number(failedRunAttempt)",
|
|
`["failure", "cancelled", "timed_out", "startup_failure", "stale"].includes(run.conclusion)`,
|
|
`run.head_branch !== expectedBranch`,
|
|
`run.head_sha !== commit`,
|
|
`tagObject !== expectedTagObject`,
|
|
`["ahead", "identical"].includes(comparison.data.status)`,
|
|
"already has a public release; use a channel repair instead",
|
|
"Bind recovery publication to this workflow run",
|
|
"dws-release-recovery run=%s tag-object=%s commit=%s",
|
|
"Public release is not bound to this exact recovery run.",
|
|
"Public recovery asset differs from this run's sealed artifact",
|
|
`const sha = process.env.RELEASE_COMMIT`,
|
|
`ref: sha`,
|
|
`path: tmp/trusted-release-tooling`,
|
|
`ref: ${{ github.sha }}`,
|
|
"verify-release-workflow-delivery.sh",
|
|
"Require a clean sealed source before GoReleaser",
|
|
`git status --porcelain --untracked-files=all`,
|
|
} {
|
|
if !strings.Contains(workflow, required) {
|
|
t.Errorf("release recovery contract is missing %q", required)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{
|
|
"authorize-recovery:",
|
|
"environment: release-recovery",
|
|
"needs.authorize-recovery",
|
|
"AUTHORIZE_RECOVERY_RESULT",
|
|
} {
|
|
if strings.Contains(workflow, forbidden) {
|
|
t.Errorf("machine-verified release recovery must not wait on manual approval %q", forbidden)
|
|
}
|
|
}
|
|
|
|
sections := map[string]string{
|
|
"release": releaseWorkflowSection(t, workflow, " release:\n", "\n verify-darwin-signatures:\n"),
|
|
"publish-release": releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n"),
|
|
"publish-channels": releaseWorkflowSection(t, workflow, " publish-channels:\n", "\n mirror-gitee-release:\n"),
|
|
}
|
|
for name, section := range sections {
|
|
for _, required := range []string{
|
|
"needs.release-contract.outputs.release_version",
|
|
"ref: ${{ needs.release-contract.outputs.release_commit }}",
|
|
"persist-credentials: false",
|
|
`tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh`,
|
|
} {
|
|
if !strings.Contains(section, required) {
|
|
t.Errorf("%s does not consume the verified recovery target %q", name, required)
|
|
}
|
|
}
|
|
if strings.Contains(section, "github.event_name == 'workflow_dispatch'") {
|
|
t.Errorf("%s must not fork into a recovery-specific publisher", name)
|
|
}
|
|
}
|
|
if strings.Count(workflow, "name: Build signed release artifacts") != 1 ||
|
|
strings.Count(workflow, "name: Verify Apple Developer ID signatures") != 1 ||
|
|
strings.Count(workflow, "name: Publish immutable GitHub Release") != 1 ||
|
|
strings.Count(workflow, "name: Publish npm and mirrors") != 1 {
|
|
t.Fatal("normal and recovery publication must share one build/sign/publish job graph")
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowRerunAdoptsOnlyItsExactSeal(t *testing.T) {
|
|
t.Parallel()
|
|
workflow := readReleaseWorkflow(t)
|
|
sealStart := strings.Index(workflow, " seal-release:\n")
|
|
if sealStart == -1 {
|
|
t.Fatal("release workflow is missing the cloud seal job")
|
|
}
|
|
seal := workflow[sealStart:]
|
|
|
|
for _, required := range []string{
|
|
`const currentAttempt = Number(process.env.GITHUB_RUN_ATTEMPT)`,
|
|
`Release-Run: ${context.runId}`,
|
|
`Release-Run-Attempt: ${attempt}`,
|
|
`Requested-By-ID: ${context.payload.sender?.id || ""}`,
|
|
`Workflow-Commit: ${context.sha}`,
|
|
`Allocation-Fingerprint: ${expectedFingerprint}`,
|
|
`ref.ref === ` + "`refs/tags/${version}`",
|
|
`existingRef = await github.rest.git.getRef`,
|
|
`sealedAttempt > currentAttempt`,
|
|
`.data.message !== messageForAttempt(sealedAttempt)`,
|
|
`if (branchMoved)`,
|
|
`if (!existingRef)`,
|
|
`basehead: ` + "`${commit}...${currentBranchCommit}`",
|
|
`["ahead", "identical"].includes(comparison.data.status)`,
|
|
`if (existingRef)`,
|
|
`continuing failed jobs without recovery approval`,
|
|
} {
|
|
if !strings.Contains(seal, required) {
|
|
t.Errorf("same-run release rerun exact-seal contract is missing %q", required)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{
|
|
"github.rest.git.updateRef",
|
|
"github.rest.git.deleteRef",
|
|
"environment: release-recovery",
|
|
"needs.authorize-recovery",
|
|
} {
|
|
if strings.Contains(seal, forbidden) {
|
|
t.Errorf("same-run exact seal reuse must not mutate the tag or require approval: found %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowDraftLifecycleUsesOneReleaseID(t *testing.T) {
|
|
t.Parallel()
|
|
workflow := readReleaseWorkflow(t)
|
|
publishJob := releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n")
|
|
publishStep := releaseWorkflowSection(
|
|
t,
|
|
publishJob,
|
|
" - name: Publish or reuse immutable GitHub Release\n",
|
|
"\n - name: Require immutable published GitHub Release\n",
|
|
)
|
|
|
|
for _, required := range []string{
|
|
"id: publish",
|
|
"--json databaseId",
|
|
`"repos/$GITHUB_REPOSITORY/releases/$release_id"`,
|
|
`uploaded_release_id="$(`,
|
|
`test "$uploaded_release_id" = "$release_id"`,
|
|
"Draft GitHub Release ID $release_id targets",
|
|
"Draft GitHub Release notes differ from the sealed CHANGELOG.",
|
|
"Draft GitHub Release is not bound to this exact recovery run.",
|
|
`DWS_GITHUB_RELEASE_ID="$release_id"`,
|
|
`tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh`,
|
|
`tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh`,
|
|
`cmp -s "$local_asset" "$remote_asset"`,
|
|
"-F draft=false",
|
|
`echo "release_id=$release_id" >> "$GITHUB_OUTPUT"`,
|
|
} {
|
|
if !strings.Contains(publishStep, required) {
|
|
t.Errorf("Draft release lifecycle is missing %q", required)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{
|
|
`gh release download "$RELEASE_VERSION"`,
|
|
`gh release edit "$RELEASE_VERSION" --draft=false`,
|
|
`"repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION"`,
|
|
} {
|
|
if strings.Contains(publishStep, forbidden) {
|
|
t.Errorf("Draft release lifecycle must not switch back from the locked release ID via %q", forbidden)
|
|
}
|
|
}
|
|
|
|
tagGuard := strings.Index(publishStep, "Draft GitHub Release ID $release_id targets")
|
|
draftPatch := strings.Index(publishStep, "-F draft=true")
|
|
bodyVerify := strings.Index(publishStep, "Draft GitHub Release notes differ from the sealed CHANGELOG.")
|
|
markerVerify := strings.Index(publishStep, "Draft GitHub Release is not bound to this exact recovery run.")
|
|
upload := strings.Index(publishStep, `gh release upload "$RELEASE_VERSION"`)
|
|
idRecheck := strings.Index(publishStep, `test "$uploaded_release_id" = "$release_id"`)
|
|
verify := strings.Index(publishStep, "tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh")
|
|
download := strings.LastIndex(publishStep, "tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh")
|
|
byteCompare := strings.Index(publishStep, `cmp -s "$local_asset" "$remote_asset"`)
|
|
publish := strings.Index(publishStep, "-F draft=false")
|
|
if tagGuard == -1 || draftPatch == -1 || bodyVerify == -1 || markerVerify == -1 ||
|
|
upload == -1 || idRecheck == -1 || verify == -1 || download == -1 || byteCompare == -1 || publish == -1 ||
|
|
!(tagGuard < draftPatch && draftPatch < bodyVerify && bodyVerify < markerVerify && markerVerify < upload &&
|
|
upload < idRecheck && idRecheck < verify && verify < download && download < byteCompare && byteCompare < publish) {
|
|
t.Fatal("Draft must retain one release ID through upload, exact verification, download, byte comparison, and publication")
|
|
}
|
|
|
|
terminalStep := publishJob[strings.Index(publishJob, " - name: Require immutable published GitHub Release\n"):]
|
|
for _, required := range []string{
|
|
`RELEASE_ID: ${{ steps.publish.outputs.release_id }}`,
|
|
`RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}`,
|
|
`"repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID"`,
|
|
`DWS_GITHUB_RELEASE_ID="$RELEASE_ID"`,
|
|
`tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh`,
|
|
`tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh`,
|
|
`[.tag_name, .draft, .prerelease, .immutable] | @tsv`,
|
|
`printf '%s\tfalse\t%s\ttrue' "$RELEASE_VERSION" "$expected_prerelease"`,
|
|
"Immutable GitHub Release notes differ from the sealed CHANGELOG.",
|
|
"Immutable GitHub Release is not bound to this exact recovery run.",
|
|
`DWS_PACKAGE_DIST_DIR="$immutable_dir"`,
|
|
`cmp -s "$sealed_asset" "$immutable_asset"`,
|
|
} {
|
|
if !strings.Contains(terminalStep, required) {
|
|
t.Errorf("terminal immutable release gate is missing %q", required)
|
|
}
|
|
}
|
|
immutableState := strings.Index(terminalStep, `[.tag_name, .draft, .prerelease, .immutable] | @tsv`)
|
|
immutableBody := strings.Index(terminalStep, "Immutable GitHub Release notes differ from the sealed CHANGELOG.")
|
|
immutableDownload := strings.Index(terminalStep, "tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh")
|
|
immutableVerify := strings.Index(terminalStep, `DWS_PACKAGE_DIST_DIR="$immutable_dir"`)
|
|
immutableCompare := strings.Index(terminalStep, `cmp -s "$sealed_asset" "$immutable_asset"`)
|
|
if immutableState == -1 || immutableBody == -1 || immutableDownload == -1 || immutableVerify == -1 || immutableCompare == -1 ||
|
|
!(immutableState < immutableBody && immutableBody < immutableDownload && immutableDownload < immutableVerify && immutableVerify < immutableCompare) {
|
|
t.Fatal("terminal gate must reverify immutable notes and exact sealed bytes on the locked release ID")
|
|
}
|
|
}
|
|
|
|
func TestRecoverReleaseBindsOneFailedRunAttempt(t *testing.T) {
|
|
t.Parallel()
|
|
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "recover-release.sh"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(recover-release.sh) error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(scriptPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", scriptPath, err)
|
|
}
|
|
script := string(data)
|
|
|
|
for _, required := range []string{
|
|
"--failed-attempt <attempt>",
|
|
"--failed-attempt requires --failed-run",
|
|
"find_latest_failed_attempt",
|
|
`while [ "$find_attempt" -ge 1 ]`,
|
|
"actions/runs/$find_run_id/attempts/$find_attempt",
|
|
`select(.head_sha == \"$commit\" and .head_branch == \"$VERSION\")`,
|
|
"Release run %s has no failed attempt",
|
|
`[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha, .actor.login, .actor.id] | @tsv`,
|
|
`Release-Run`,
|
|
`Release-Run-Attempt`,
|
|
`expected_attempt_event="workflow_dispatch"`,
|
|
`is not bound by the cloud seal`,
|
|
"actions/runs/%s/attempts/%s",
|
|
`-f "recover_failed_run_attempt=$FAILED_RUN_ATTEMPT"`,
|
|
} {
|
|
if !strings.Contains(script, required) {
|
|
t.Errorf("release recovery script is missing attempt binding %q", required)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowPublicationBypassesSkippedDispatchButStopsOnCancellation(t *testing.T) {
|
|
t.Parallel()
|
|
workflow := readReleaseWorkflow(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
start string
|
|
end string
|
|
condition string
|
|
}{
|
|
{
|
|
name: "release contract",
|
|
start: " release-contract:\n",
|
|
end: "\n release:\n",
|
|
condition: `if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }}`,
|
|
},
|
|
{
|
|
name: "build",
|
|
start: " release:\n",
|
|
end: "\n verify-darwin-signatures:\n",
|
|
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' }}`,
|
|
},
|
|
{
|
|
name: "Darwin verification",
|
|
start: " verify-darwin-signatures:\n",
|
|
end: "\n publish-release:\n",
|
|
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.release.result == 'success' }}`,
|
|
},
|
|
{
|
|
name: "GitHub publication",
|
|
start: " publish-release:\n",
|
|
end: "\n publish-channels:\n",
|
|
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.release-validation.result == 'success' && needs.release.result == 'success' && needs.verify-darwin-signatures.result == 'success' }}`,
|
|
},
|
|
{
|
|
name: "channel publication",
|
|
start: " publish-channels:\n",
|
|
end: "\n mirror-gitee-release:\n",
|
|
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.publish-release.result == 'success' }}`,
|
|
},
|
|
{
|
|
name: "Gitee mirror",
|
|
start: " mirror-gitee-release:\n",
|
|
end: "\n repair-npm:\n",
|
|
condition: `if: ${{ !cancelled() && vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.publish-channels.result == 'success' }}`,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
section := releaseWorkflowSection(t, workflow, test.start, test.end)
|
|
if !strings.Contains(section, test.condition) {
|
|
t.Errorf("%s must override skipped dispatch ancestors while preserving cancellation and dependency gates", test.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
|
t.Parallel()
|
|
workflow := readReleaseWorkflow(t)
|
|
gate := releaseWorkflowSection(t, workflow, " release-delivery-gate:\n", "\n repair-channel:\n")
|
|
|
|
for _, required := range []string{
|
|
"name: Release delivery gate",
|
|
`if: ${{ !cancelled() }}`,
|
|
"- dispatch-contract",
|
|
"- governance-preflight",
|
|
"- release-contract",
|
|
"- release-validation",
|
|
"- release",
|
|
"- verify-darwin-signatures",
|
|
"- publish-release",
|
|
"- publish-channels",
|
|
"- coverage-baseline-confirmation",
|
|
"- mirror-gitee-release",
|
|
"- repair-npm",
|
|
"- repair-channel",
|
|
"- release-plan",
|
|
"- seal-release",
|
|
`REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }}`,
|
|
`RELEASE_VALIDATION_RESULT: ${{ needs.release-validation.result }}`,
|
|
`RELEASE_PLAN_RESULT: ${{ needs.release-plan.result }}`,
|
|
`SEAL_RELEASE_RESULT: ${{ needs.seal-release.result }}`,
|
|
`COVERAGE_BASELINE_CONFIRMATION_RESULT: ${{ needs.coverage-baseline-confirmation.result }}`,
|
|
"require_publication",
|
|
`require_result release-contract "$RELEASE_CONTRACT_RESULT" success`,
|
|
`require_result release-validation "$RELEASE_VALIDATION_RESULT" success`,
|
|
`require_result release "$RELEASE_RESULT" success`,
|
|
`require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" success`,
|
|
`require_result publish-release "$PUBLISH_RELEASE_RESULT" success`,
|
|
`require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success`,
|
|
`require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" success`,
|
|
`require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped`,
|
|
"workflow_dispatch:recover_release",
|
|
"workflow_dispatch:create_release",
|
|
"workflow_dispatch:plan_release",
|
|
"workflow_dispatch:governance_preflight",
|
|
"workflow_dispatch:repair_npm",
|
|
"workflow_dispatch:repair_gitee",
|
|
"workflow_dispatch:repair_oss",
|
|
`require_result repair-channel "$REPAIR_CHANNEL_RESULT" success`,
|
|
`require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped`,
|
|
"unsupported release mode",
|
|
} {
|
|
if !strings.Contains(gate, required) {
|
|
t.Errorf("release delivery gate is missing %q", required)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{
|
|
"- authorize-recovery",
|
|
"AUTHORIZE_RECOVERY_RESULT",
|
|
"needs.authorize-recovery",
|
|
"require_result authorize-recovery",
|
|
} {
|
|
if strings.Contains(gate, forbidden) {
|
|
t.Errorf("release delivery gate must not retain recovery approval dependency %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(release.yml) error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(workflowPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
|
|
}
|
|
workflow := string(data)
|
|
|
|
build := strings.Index(workflow, "Build release artifacts without publishing")
|
|
postProcess := strings.Index(workflow, "./scripts/release/post-goreleaser.sh")
|
|
preserve := strings.Index(workflow, "Preserve finalized distribution files")
|
|
verifyJob := strings.Index(workflow, "verify-darwin-signatures:")
|
|
publishJob := strings.Index(workflow, "publish-release:")
|
|
if build == -1 || postProcess == -1 || preserve == -1 || verifyJob == -1 || publishJob == -1 ||
|
|
!(build < postProcess && postProcess < preserve && preserve < verifyJob && verifyJob < publishJob) {
|
|
t.Fatalf("post-processed assets must be preserved, Apple-verified, and only then published")
|
|
}
|
|
|
|
buildSection := workflow[build:verifyJob]
|
|
for _, required := range []string{
|
|
"--skip=publish",
|
|
"actions/upload-artifact@v4",
|
|
"finalized-release-dist",
|
|
} {
|
|
if !strings.Contains(buildSection, required) {
|
|
t.Errorf("signed build stage is missing %q", required)
|
|
}
|
|
}
|
|
|
|
publishSection := workflow[publishJob:]
|
|
for _, required := range []string{
|
|
"actions/download-artifact@v4",
|
|
"dist/dws-*.tar.gz",
|
|
"dist/dws-windows-*.zip",
|
|
"checksums.txt",
|
|
"dws-skills.zip",
|
|
"gh release upload",
|
|
"--clobber",
|
|
"verify-release-artifacts.sh",
|
|
} {
|
|
if !strings.Contains(publishSection, required) {
|
|
t.Errorf("immutable publication stage is missing %q", required)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowConfiguresDeveloperIDSigning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(release.yml) error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(workflowPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
|
|
}
|
|
workflow := string(data)
|
|
|
|
prepare := strings.Index(workflow, "Prepare Apple Developer ID certificate")
|
|
goReleaser := strings.Index(workflow, "Build release artifacts without publishing")
|
|
postProcess := strings.Index(workflow, "./scripts/release/post-goreleaser.sh")
|
|
cleanup := strings.Index(workflow, "Remove Apple Developer ID certificate")
|
|
if prepare == -1 || goReleaser == -1 || postProcess == -1 || cleanup == -1 ||
|
|
prepare > goReleaser || goReleaser > postProcess || cleanup < postProcess {
|
|
t.Fatalf("Developer ID material must be validated before GoReleaser and removed after post-processing")
|
|
}
|
|
|
|
for _, required := range []string{
|
|
`RCS_VERSION="0.29.0"`,
|
|
"secrets.APPLE_CERTIFICATE_P12_BASE64",
|
|
"secrets.APPLE_CERTIFICATE_PASSWORD",
|
|
"base64 --decode",
|
|
"openssl pkcs12 -legacy",
|
|
"DWS_APPLE_CERTIFICATE_P12",
|
|
"DWS_APPLE_CERTIFICATE_PASSWORD_FILE",
|
|
"DWS_REQUIRE_DEVELOPER_ID_SIGNING",
|
|
`GITHUB_REPOSITORY_OWNER" = "DingTalk-Real-AI`,
|
|
} {
|
|
if !strings.Contains(workflow, required) {
|
|
t.Errorf("release workflow is missing Developer ID configuration %q", required)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPostGoreleaserSupportsDeveloperIDSigning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "post-goreleaser.sh"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(post-goreleaser.sh) error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(scriptPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", scriptPath, err)
|
|
}
|
|
script := string(data)
|
|
|
|
for _, required := range []string{
|
|
`APPLE_CERTIFICATE_P12="${DWS_APPLE_CERTIFICATE_P12:-}"`,
|
|
`APPLE_CERTIFICATE_PASSWORD_FILE="${DWS_APPLE_CERTIFICATE_PASSWORD_FILE:-}"`,
|
|
`REQUIRE_DEVELOPER_ID_SIGNING="${DWS_REQUIRE_DEVELOPER_ID_SIGNING:-false}"`,
|
|
`--p12-file "$APPLE_CERTIFICATE_P12"`,
|
|
`--p12-password-file "$APPLE_CERTIFICATE_PASSWORD_FILE"`,
|
|
"--for-notarization",
|
|
} {
|
|
if !strings.Contains(script, required) {
|
|
t.Errorf("post-goreleaser.sh is missing Developer ID signing behavior %q", required)
|
|
}
|
|
}
|
|
if strings.Contains(script, `rcodesign verify "$bin"`) {
|
|
t.Fatal("rcodesign verify must not be treated as authoritative Apple signature validation")
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowVerifiesRcodesignArchiveChecksum(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(release.yml) error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(workflowPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
|
|
}
|
|
workflow := string(data)
|
|
|
|
hash := strings.Index(workflow, `RCS_ARCHIVE_SHA256="dbe85cedd8ee4217b64e9a0e4c2aef92ab8bcaaa41f20bde99781ff02e600002"`)
|
|
checksum := strings.Index(workflow, "sha256sum --check --strict")
|
|
extract := strings.Index(workflow, "tar -xzf /tmp/rcodesign.tar.gz")
|
|
execute := strings.Index(workflow, "rcodesign --version")
|
|
if hash == -1 || checksum == -1 || extract == -1 || execute == -1 ||
|
|
!(hash < checksum && checksum < extract && extract < execute) {
|
|
t.Fatal("rcodesign archive must match the pinned SHA-256 before extraction or execution")
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(release.yml) error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(workflowPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
|
|
}
|
|
workflow := string(data)
|
|
|
|
preserve := strings.Index(workflow, "Preserve finalized distribution files")
|
|
verifyJob := strings.Index(workflow, "verify-darwin-signatures:")
|
|
publishJob := strings.Index(workflow, "publish-release:")
|
|
if preserve == -1 || verifyJob == -1 || publishJob == -1 || !(preserve < verifyJob && verifyJob < publishJob) {
|
|
t.Fatal("finalized artifacts must be preserved, Apple-verified, and only then published")
|
|
}
|
|
|
|
codesign := strings.Index(workflow[verifyJob:publishJob], "codesign --verify --strict --verbose=4")
|
|
publish := strings.Index(workflow[publishJob:], "-F draft=false")
|
|
if codesign == -1 || publish == -1 {
|
|
t.Fatal("macOS codesign verification and explicit Draft publication are required")
|
|
}
|
|
|
|
buildSection := workflow[preserve:verifyJob]
|
|
for _, required := range []string{
|
|
"actions/upload-artifact@v4",
|
|
"finalized-release-dist",
|
|
} {
|
|
if !strings.Contains(buildSection, required) {
|
|
t.Errorf("signed build stage is missing %q", required)
|
|
}
|
|
}
|
|
|
|
verifySection := workflow[verifyJob:publishJob]
|
|
for _, required := range []string{
|
|
"runs-on: macos-latest",
|
|
"actions/download-artifact@v4",
|
|
"finalized-release-dist",
|
|
`dws-darwin-${arch}.tar.gz`,
|
|
"codesign --verify --strict --verbose=4",
|
|
} {
|
|
if !strings.Contains(verifySection, required) {
|
|
t.Errorf("Apple verification stage is missing %q", required)
|
|
}
|
|
}
|
|
|
|
publishSection := workflow[publishJob:]
|
|
for _, required := range []string{
|
|
"verify-darwin-signatures",
|
|
"actions/download-artifact@v4",
|
|
"Publish or reuse immutable GitHub Release",
|
|
"gh release upload",
|
|
"Publish missing version to npm channel",
|
|
"Publish stable Homebrew formula",
|
|
"Publish beta Homebrew formula",
|
|
"DingTalk-Real-AI/dingtalk-workspace-cli.git",
|
|
"secrets.GITHUB_TOKEN",
|
|
} {
|
|
if !strings.Contains(publishSection, required) {
|
|
t.Errorf("post-verification publication stage is missing %q", required)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowPublishesStableHomebrewFormulaDirectly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(release.yml) error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(workflowPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
|
|
}
|
|
workflow := string(data)
|
|
for _, forbidden := range []string{
|
|
"Verify Homebrew PR automation permission",
|
|
"verify-homebrew-pr-token.sh",
|
|
"DWS_TAP_PR_REPOSITORY",
|
|
"DWS_TAP_PR_BRANCH",
|
|
"Open stable Homebrew formula PR",
|
|
"Open beta Homebrew formula PR",
|
|
} {
|
|
if strings.Contains(workflow, forbidden) {
|
|
t.Errorf("release workflow must not retain Homebrew PR machinery %q", forbidden)
|
|
}
|
|
}
|
|
|
|
start := strings.Index(workflow, "- name: Publish stable Homebrew formula")
|
|
if start == -1 {
|
|
t.Fatal("release workflow is missing direct stable Homebrew publication")
|
|
}
|
|
end := strings.Index(workflow[start:], "- name: Publish beta Homebrew formula")
|
|
if end == -1 {
|
|
t.Fatal("release workflow is missing direct beta Homebrew publication after the stable step")
|
|
}
|
|
section := workflow[start : start+end]
|
|
for _, required := range []string{
|
|
"github.repository_owner == 'DingTalk-Real-AI'",
|
|
"needs.release-contract.outputs.channel == 'stable'",
|
|
"scripts/release/publish-homebrew-formula.sh",
|
|
"secrets.HOMEBREW_PR_TOKEN",
|
|
} {
|
|
if !strings.Contains(section, required) {
|
|
t.Errorf("direct stable Homebrew publication is missing %q", required)
|
|
}
|
|
}
|
|
stableNPM := strings.Index(workflow, "- name: Publish missing version to npm channel")
|
|
if stableNPM == -1 || start > stableNPM {
|
|
t.Fatal("Homebrew publication must run before npm so a failure is safely rerunnable")
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowPublishesBetaHomebrewFormulaDirectly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(release.yml) error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(workflowPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
|
|
}
|
|
workflow := string(data)
|
|
|
|
start := strings.Index(workflow, "- name: Publish beta Homebrew formula")
|
|
if start == -1 {
|
|
t.Fatal("release workflow is missing direct beta Homebrew publication")
|
|
}
|
|
end := strings.Index(workflow[start:], "- name: Reverify exact immutable npm package")
|
|
if end == -1 {
|
|
t.Fatal("release workflow is missing the post-Homebrew npm verification step")
|
|
}
|
|
section := workflow[start : start+end]
|
|
for _, required := range []string{
|
|
"github.repository_owner == 'DingTalk-Real-AI'",
|
|
"needs.release-contract.outputs.channel == 'prerelease'",
|
|
"dist/homebrew/dingtalk-workspace-cli-beta.rb",
|
|
"Formula/dingtalk-workspace-cli-beta.rb",
|
|
"secrets.HOMEBREW_PR_TOKEN",
|
|
} {
|
|
if !strings.Contains(section, required) {
|
|
t.Errorf("direct beta Homebrew publication is missing %q", required)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{
|
|
"DWS_TAP_PR_REPOSITORY",
|
|
"DWS_TAP_PR_BRANCH",
|
|
"automation/homebrew-beta-",
|
|
} {
|
|
if strings.Contains(section, forbidden) {
|
|
t.Errorf("direct beta Homebrew publication must not open a PR: found %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) {
|
|
t.Parallel()
|
|
workflow := readReleaseWorkflow(t)
|
|
publishJob := releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n")
|
|
if !strings.Contains(publishJob, "checks: write") {
|
|
t.Fatal("Formula-only delivery must receive checks: write in the already write-capable publication job")
|
|
}
|
|
|
|
for _, required := range []string{
|
|
"timeout-minutes: 30",
|
|
`coverage_baseline_required: ${{ steps.seal-formula.outputs.coverage_baseline_required }}`,
|
|
`coverage_baseline_commit: ${{ steps.seal-formula.outputs.coverage_baseline_commit }}`,
|
|
"id: homebrew-stable",
|
|
"id: homebrew-beta",
|
|
"id: seal-formula",
|
|
`FORMULA_CHANGED: ${{ steps.homebrew-stable.outputs.formula_changed || steps.homebrew-beta.outputs.formula_changed }}`,
|
|
`FORMULA_COMMIT: ${{ steps.homebrew-stable.outputs.published_commit || steps.homebrew-beta.outputs.published_commit }}`,
|
|
} {
|
|
if !strings.Contains(publishJob, required) {
|
|
t.Errorf("Homebrew publisher output wiring is missing %q", required)
|
|
}
|
|
}
|
|
|
|
seal := releaseWorkflowSection(
|
|
t,
|
|
publishJob,
|
|
" - name: Seal Formula-only Code Admission contexts\n",
|
|
"\n - name: Reverify exact immutable npm package\n",
|
|
)
|
|
for _, required := range []string{
|
|
`const commit = process.env.FORMULA_COMMIT`,
|
|
`commitResponse.data.sha === commit`,
|
|
`commitResponse.data.parents.length === 1`,
|
|
`commitResponse.data.commit.message === expectedMessage`,
|
|
`commitResponse.data.author?.login === "github-actions[bot]"`,
|
|
`commitResponse.data.committer?.login === "github-actions[bot]"`,
|
|
`files.length === 1`,
|
|
`files[0].filename === formulaPath`,
|
|
`["added", "modified"].includes(files[0].status)`,
|
|
`const parent = commitResponse.data.parents[0].sha`,
|
|
`github.rest.checks.listForRef`,
|
|
`ref: parent`,
|
|
`run.head_sha !== parent`,
|
|
`run.app?.slug !== "github-actions"`,
|
|
`run.conclusion !== "success"`,
|
|
`github.rest.repos.getContent`,
|
|
`path: formulaPath`,
|
|
`ref: commit`,
|
|
`actualFormula.equals(expectedFormula)`,
|
|
`github.rest.repos.compareCommitsWithBasehead`,
|
|
`basehead: ` + "`${commit}...${branch.data.commit.sha}`",
|
|
`["ahead", "identical"].includes(comparison.data.status)`,
|
|
`github.rest.checks.create`,
|
|
`head_sha: commit`,
|
|
`status: "completed"`,
|
|
`conclusion: "success"`,
|
|
`core.setOutput("coverage_baseline_required", "true")`,
|
|
`core.setOutput("coverage_baseline_commit", commit)`,
|
|
} {
|
|
if !strings.Contains(seal, required) {
|
|
t.Errorf("Formula-only Code Admission sealing is missing %q", required)
|
|
}
|
|
}
|
|
for _, context := range expectedReleaseAdmissionContexts {
|
|
if strings.Count(seal, fmt.Sprintf("%q", context)) != 1 {
|
|
t.Errorf("Formula-only Code Admission must derive exactly one %q context from the reviewed list", context)
|
|
}
|
|
}
|
|
if strings.Count(seal, "github.rest.checks.create") != 1 {
|
|
t.Error("Formula sealing must write only the reviewed Code Admission contexts")
|
|
}
|
|
for _, forbidden := range []string{
|
|
"head_sha: context.sha",
|
|
"head_sha: parent",
|
|
"head_sha: branch.data.commit.sha",
|
|
"github.rest.checks.get",
|
|
"promotionComplete",
|
|
"setTimeout(resolve, 10000)",
|
|
"Coverage Baseline Cache",
|
|
"github.rest.repos.createDispatchEvent",
|
|
} {
|
|
if strings.Contains(seal, forbidden) {
|
|
t.Errorf("Formula-only Code Admission must not mark an unverified head green: found %q", forbidden)
|
|
}
|
|
}
|
|
|
|
createCheck := strings.Index(seal, "github.rest.checks.create")
|
|
if createCheck == -1 {
|
|
t.Error("Formula sealing must create the verified Code Admission contexts")
|
|
}
|
|
for name, marker := range map[string]string{
|
|
"single-parent Formula-only identity": "const exactFormulaCommit",
|
|
"successful parent contexts": "invalidParentContexts.length > 0",
|
|
"byte-for-byte Formula content": "actualFormula.equals(expectedFormula)",
|
|
"default-branch containment": `["ahead", "identical"].includes(comparison.data.status)`,
|
|
} {
|
|
index := strings.Index(seal, marker)
|
|
if index == -1 || createCheck == -1 || index > createCheck {
|
|
t.Errorf("%s must be verified before any success check is created", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowConfirmsFormulaCacheWithoutBlockingChannels(t *testing.T) {
|
|
t.Parallel()
|
|
workflow := readReleaseWorkflow(t)
|
|
publishJob := releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n")
|
|
channelsJob := releaseWorkflowSection(t, workflow, " publish-channels:\n", "\n mirror-gitee-release:\n")
|
|
confirmation := releaseWorkflowSection(
|
|
t,
|
|
workflow,
|
|
" coverage-baseline-confirmation:\n",
|
|
"\n release-delivery-gate:\n",
|
|
)
|
|
|
|
for _, required := range []string{
|
|
`if: ${{ !cancelled() && (needs.publish-release.result == 'success' || needs.publish-release.outputs.coverage_baseline_required == 'true') }}`,
|
|
"needs: publish-release",
|
|
"timeout-minutes: 35",
|
|
"checks: write",
|
|
"contents: write",
|
|
`BASELINE_REQUIRED: ${{ needs.publish-release.outputs.coverage_baseline_required }}`,
|
|
`FORMULA_COMMIT: ${{ needs.publish-release.outputs.coverage_baseline_commit }}`,
|
|
"if (!['true', 'false'].includes(rawRequired))",
|
|
"Formula baseline requirement is invalid",
|
|
"github.rest.checks.create",
|
|
"name: 'Coverage Baseline Cache'",
|
|
"status: 'queued'",
|
|
"external_id: expectedExternalId",
|
|
"github.rest.repos.createDispatchEvent",
|
|
"event_type: 'coverage-baseline-promote'",
|
|
"source_run_id: String(context.runId)",
|
|
"check_run_id: String(promotionCheck.id)",
|
|
"github.rest.checks.update",
|
|
"conclusion: 'failure'",
|
|
"github.rest.checks.get",
|
|
"currentCheck.head_sha !== targetSha",
|
|
"currentCheck.external_id !== expectedExternalId",
|
|
"currentCheck.app?.slug !== 'github-actions'",
|
|
"for (let attempt = 1; attempt <= 180; attempt += 1)",
|
|
"currentCheck.conclusion !== 'success'",
|
|
"await new Promise(resolve => setTimeout(resolve, 10000))",
|
|
"Formula baseline promotion timed out",
|
|
} {
|
|
if !strings.Contains(confirmation, required) {
|
|
t.Errorf("Formula baseline confirmation missing %q", required)
|
|
}
|
|
}
|
|
if strings.Contains(channelsJob, "coverage-baseline-confirmation") ||
|
|
strings.Contains(channelsJob, "needs.coverage-baseline-confirmation") {
|
|
t.Error("cache acknowledgement must not block npm or mirror publication")
|
|
}
|
|
for _, forbidden := range []string{
|
|
"Coverage Baseline Cache",
|
|
"github.rest.repos.createDispatchEvent",
|
|
"github.rest.checks.get",
|
|
"promotionComplete",
|
|
} {
|
|
if strings.Contains(publishJob, forbidden) {
|
|
t.Errorf("irreversible publication job must not own cache lifecycle marker %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowWaitsForNPMDistTagPropagation(t *testing.T) {
|
|
workflow := readReleaseWorkflow(t)
|
|
script := releaseWorkflowRunScript(
|
|
t,
|
|
workflow,
|
|
"Verify npm channel delivery",
|
|
"Sync release artifacts to China OSS mirror",
|
|
)
|
|
|
|
tests := []struct {
|
|
name string
|
|
sequence string
|
|
wantSuccess bool
|
|
wantCalls int
|
|
wantSleeps int
|
|
wantOutput string
|
|
}{
|
|
{
|
|
name: "stale beta converges to target",
|
|
sequence: "1.0.53-beta.6\n1.0.53-beta.6\n1.0.53-beta.7\n",
|
|
wantSuccess: true,
|
|
wantCalls: 3,
|
|
wantSleeps: 2,
|
|
},
|
|
{
|
|
name: "stale beta never converges",
|
|
sequence: "1.0.53-beta.6\n",
|
|
wantCalls: 60,
|
|
wantSleeps: 59,
|
|
wantOutput: "still reports older v1.0.53-beta.6 after 60 attempts",
|
|
},
|
|
{
|
|
name: "permanent registry error fails immediately",
|
|
sequence: "__NPM_ERROR__\n",
|
|
wantCalls: 1,
|
|
wantSleeps: 0,
|
|
wantOutput: "permanent npm registry error",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
fakeBin := filepath.Join(root, "bin")
|
|
if err := os.MkdirAll(fakeBin, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll(%s) error = %v", fakeBin, err)
|
|
}
|
|
sequencePath := filepath.Join(root, "sequence")
|
|
statePath := filepath.Join(root, "state")
|
|
npmLogPath := filepath.Join(root, "npm.log")
|
|
sleepLogPath := filepath.Join(root, "sleep.log")
|
|
mustWriteFile(t, sequencePath, []byte(test.sequence), 0o644)
|
|
mustWriteFile(t, filepath.Join(fakeBin, "npm"), []byte(`#!/bin/sh
|
|
set -eu
|
|
printf '%s\n' "$*" >> "$NPM_CALL_LOG"
|
|
test "$*" = "view dingtalk-workspace-cli dist-tags.beta --registry=https://registry.npmjs.org --prefer-online" || {
|
|
echo "unexpected npm mutation: $*" >&2
|
|
exit 97
|
|
}
|
|
call=0
|
|
if test -f "$NPM_STATE"; then call="$(cat "$NPM_STATE")"; fi
|
|
call=$((call + 1))
|
|
printf '%s\n' "$call" > "$NPM_STATE"
|
|
value="$(sed -n "${call}p" "$NPM_SEQUENCE")"
|
|
if test -z "$value"; then value="$(tail -n 1 "$NPM_SEQUENCE")"; fi
|
|
if test "$value" = "__NPM_ERROR__"; then
|
|
echo "permanent npm registry error" >&2
|
|
exit 42
|
|
fi
|
|
printf '%s\n' "$value"
|
|
`), 0o755)
|
|
mustWriteFile(t, filepath.Join(fakeBin, "sleep"), []byte(`#!/bin/sh
|
|
set -eu
|
|
printf '%s\n' "$*" >> "$SLEEP_CALL_LOG"
|
|
`), 0o755)
|
|
|
|
repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
|
if err != nil {
|
|
t.Fatalf("Abs(repository root) error = %v", err)
|
|
}
|
|
cmd := exec.Command("sh", "-c", script)
|
|
cmd.Dir = repoRoot
|
|
cmd.Env = append(os.Environ(),
|
|
"PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"),
|
|
"NPM_TAG=beta",
|
|
"SEMVER=1.0.53-beta.7",
|
|
"NPM_SEQUENCE="+sequencePath,
|
|
"NPM_STATE="+statePath,
|
|
"NPM_CALL_LOG="+npmLogPath,
|
|
"SLEEP_CALL_LOG="+sleepLogPath,
|
|
)
|
|
output, runErr := cmd.CombinedOutput()
|
|
if test.wantSuccess && runErr != nil {
|
|
t.Fatalf("npm delivery verification error = %v\noutput:\n%s", runErr, output)
|
|
}
|
|
if !test.wantSuccess && runErr == nil {
|
|
t.Fatalf("npm delivery verification unexpectedly succeeded\noutput:\n%s", output)
|
|
}
|
|
if test.wantOutput != "" && !strings.Contains(string(output), test.wantOutput) {
|
|
t.Errorf("npm delivery verification output is missing %q:\n%s", test.wantOutput, output)
|
|
}
|
|
|
|
npmLog, err := os.ReadFile(npmLogPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", npmLogPath, err)
|
|
}
|
|
npmCalls := strings.Split(strings.TrimSpace(string(npmLog)), "\n")
|
|
if got := len(npmCalls); got != test.wantCalls {
|
|
t.Errorf("npm view call count = %d, want %d; log:\n%s", got, test.wantCalls, npmLog)
|
|
}
|
|
if strings.Contains(string(npmLog), "dist-tag add") || strings.Contains(string(npmLog), "publish") {
|
|
t.Errorf("delivery verification must remain read-only; log:\n%s", npmLog)
|
|
}
|
|
|
|
sleepCalls := 0
|
|
if sleepLog, err := os.ReadFile(sleepLogPath); err == nil {
|
|
sleepCalls = len(strings.Fields(string(sleepLog)))
|
|
} else if !os.IsNotExist(err) {
|
|
t.Fatalf("ReadFile(%s) error = %v", sleepLogPath, err)
|
|
}
|
|
if sleepCalls != test.wantSleeps {
|
|
t.Errorf("sleep call count = %d, want %d", sleepCalls, test.wantSleeps)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReleaseStaysDraftUntilFinalizedAssetDigestsMatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
goreleaserPath, err := filepath.Abs(filepath.Join("..", "..", ".goreleaser.yaml"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(.goreleaser.yaml) error = %v", err)
|
|
}
|
|
goreleaserData, err := os.ReadFile(goreleaserPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", goreleaserPath, err)
|
|
}
|
|
if !strings.Contains(string(goreleaserData), "draft: true") {
|
|
t.Fatal("GoReleaser must keep the release as Draft during post-processing")
|
|
}
|
|
|
|
finalizePath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "finalize-github-release.sh"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(finalize-github-release.sh) error = %v", err)
|
|
}
|
|
finalizeData, err := os.ReadFile(finalizePath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", finalizePath, err)
|
|
}
|
|
finalize := string(finalizeData)
|
|
|
|
upload := strings.Index(finalize, "gh release upload")
|
|
digestFailure := strings.Index(finalize, "release asset digest mismatch")
|
|
publish := strings.Index(finalize, "gh release edit")
|
|
if upload == -1 || digestFailure == -1 || publish == -1 || !(upload < digestFailure && digestFailure < publish) {
|
|
t.Fatal("Draft publication must happen after finalized asset upload and digest verification")
|
|
}
|
|
}
|
|
|
|
func TestFinalizeGitHubReleaseDoesNotPublishAfterUploadFailure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "finalize-github-release.sh"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(finalize-github-release.sh) error = %v", err)
|
|
}
|
|
|
|
root := t.TempDir()
|
|
distDir := filepath.Join(root, "dist")
|
|
if err := os.MkdirAll(distDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll(%s) error = %v", distDir, err)
|
|
}
|
|
for _, name := range []string{
|
|
"dws-darwin-amd64.tar.gz",
|
|
"dws-darwin-arm64.tar.gz",
|
|
"checksums.txt",
|
|
"dws-skills.zip",
|
|
} {
|
|
if err := os.WriteFile(filepath.Join(distDir, name), []byte("finalized"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(%s) error = %v", name, err)
|
|
}
|
|
}
|
|
|
|
binDir := filepath.Join(root, "bin")
|
|
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll(%s) error = %v", binDir, err)
|
|
}
|
|
logPath := filepath.Join(root, "gh.log")
|
|
fakeGH := `#!/bin/sh
|
|
printf '%s\n' "$*" >> "$FAKE_GH_LOG"
|
|
if [ "$1" = "release" ] && [ "$2" = "upload" ]; then
|
|
exit 42
|
|
fi
|
|
if [ "$1" = "release" ] && [ "$2" = "edit" ]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
`
|
|
if err := os.WriteFile(filepath.Join(binDir, "gh"), []byte(fakeGH), 0o755); err != nil {
|
|
t.Fatalf("WriteFile(fake gh) error = %v", err)
|
|
}
|
|
|
|
cmd := exec.Command("sh", scriptPath)
|
|
cmd.Env = append(os.Environ(),
|
|
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
|
|
"FAKE_GH_LOG="+logPath,
|
|
"GITHUB_REF_NAME=v-test",
|
|
"GITHUB_REPOSITORY=example/dws",
|
|
"DWS_PACKAGE_DIST_DIR="+distDir,
|
|
)
|
|
output, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
t.Fatalf("finalize-github-release.sh unexpectedly succeeded after upload failure:\n%s", output)
|
|
}
|
|
|
|
logData, readErr := os.ReadFile(logPath)
|
|
if readErr != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", logPath, readErr)
|
|
}
|
|
logText := string(logData)
|
|
if !strings.Contains(logText, "release upload") {
|
|
t.Fatalf("fake gh did not observe release upload:\n%s", logText)
|
|
}
|
|
if strings.Contains(logText, "release edit") {
|
|
t.Fatalf("Draft release was published after upload failure:\n%s", logText)
|
|
}
|
|
}
|
|
|
|
func TestFinalizeGitHubReleaseCanVerifyWithoutPublishing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "finalize-github-release.sh"))
|
|
if err != nil {
|
|
t.Fatalf("Abs(finalize-github-release.sh) error = %v", err)
|
|
}
|
|
|
|
root := t.TempDir()
|
|
distDir := filepath.Join(root, "dist")
|
|
if err := os.MkdirAll(distDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll(%s) error = %v", distDir, err)
|
|
}
|
|
assetContent := []byte("finalized")
|
|
for _, name := range []string{
|
|
"dws-darwin-amd64.tar.gz",
|
|
"dws-darwin-arm64.tar.gz",
|
|
"checksums.txt",
|
|
"dws-skills.zip",
|
|
} {
|
|
if err := os.WriteFile(filepath.Join(distDir, name), assetContent, 0o644); err != nil {
|
|
t.Fatalf("WriteFile(%s) error = %v", name, err)
|
|
}
|
|
}
|
|
|
|
binDir := filepath.Join(root, "bin")
|
|
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll(%s) error = %v", binDir, err)
|
|
}
|
|
logPath := filepath.Join(root, "gh.log")
|
|
fakeGH := `#!/bin/sh
|
|
printf '%s\n' "$*" >> "$FAKE_GH_LOG"
|
|
if [ "$1" = "release" ] && [ "$2" = "upload" ]; then
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "release" ] && [ "$2" = "view" ]; then
|
|
printf '%s\n' "$FAKE_REMOTE_DIGEST"
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "release" ] && [ "$2" = "edit" ]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
`
|
|
if err := os.WriteFile(filepath.Join(binDir, "gh"), []byte(fakeGH), 0o755); err != nil {
|
|
t.Fatalf("WriteFile(fake gh) error = %v", err)
|
|
}
|
|
|
|
digest := sha256.Sum256(assetContent)
|
|
cmd := exec.Command("sh", scriptPath)
|
|
cmd.Env = append(os.Environ(),
|
|
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
|
|
"FAKE_GH_LOG="+logPath,
|
|
"FAKE_REMOTE_DIGEST="+fmt.Sprintf("sha256:%x", digest),
|
|
"GITHUB_REF_NAME=v-test",
|
|
"GITHUB_REPOSITORY=example/dws",
|
|
"DWS_PACKAGE_DIST_DIR="+distDir,
|
|
"DWS_PUBLISH_RELEASE=false",
|
|
"DWS_RELEASE_DIGEST_ATTEMPTS=1",
|
|
"DWS_RELEASE_DIGEST_RETRY_DELAY=0",
|
|
)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("finalize-github-release.sh error = %v\noutput:\n%s", err, output)
|
|
}
|
|
if !strings.Contains(string(output), "keeping release v-test as Draft") {
|
|
t.Fatalf("finalizer did not report preserved Draft:\n%s", output)
|
|
}
|
|
|
|
logData, err := os.ReadFile(logPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%s) error = %v", logPath, err)
|
|
}
|
|
logText := string(logData)
|
|
if !strings.Contains(logText, "release upload") || !strings.Contains(logText, "release view") {
|
|
t.Fatalf("finalizer did not upload and verify assets:\n%s", logText)
|
|
}
|
|
if strings.Contains(logText, "release edit") {
|
|
t.Fatalf("finalizer published a release configured to remain Draft:\n%s", logText)
|
|
}
|
|
}
|