fix(release): add fast guarded recovery path
This commit is contained in:
@@ -17,21 +17,36 @@ var expectedHomeSkillTargets = []string{
|
||||
".cursor/skills/dws",
|
||||
}
|
||||
|
||||
func TestInstallScriptSourceModeInstallsBinary(t *testing.T) {
|
||||
t.Parallel()
|
||||
type installSourceFixture struct {
|
||||
root string
|
||||
scriptPath string
|
||||
stubRoot string
|
||||
fakeHome string
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
installDir := filepath.Join(root, "bin")
|
||||
func newInstallSourceFixture(t *testing.T) *installSourceFixture {
|
||||
t.Helper()
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh"))
|
||||
repoScript, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(install.sh) error = %v", err)
|
||||
}
|
||||
scriptData, err := os.ReadFile(repoScript)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", repoScript, err)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
scriptPath := filepath.Join(root, "scripts", "install.sh")
|
||||
mustWriteFile(t, scriptPath, scriptData, 0o755)
|
||||
// resolve_source_root requires both go.mod and cmd/. The source installer
|
||||
// tests need only that layout plus small representative skill trees.
|
||||
mustWriteFile(t, filepath.Join(root, "go.mod"), []byte("module example.com/dws-install-test\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(root, "cmd", ".keep"), nil, 0o644)
|
||||
mustWriteFile(t, filepath.Join(root, "skills", "mono", "SKILL.md"), []byte("# Test skill\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(root, "skills", "multi", "dingtalk-test", "SKILL.md"), []byte("# Test split skill\n"), 0o644)
|
||||
|
||||
// Stub make: when invoked as "make -C <dir> build", create a fake dws binary
|
||||
// in the project root (the -C target directory).
|
||||
stubRoot := filepath.Join(root, "stubs")
|
||||
repoRoot, _ := filepath.Abs(filepath.Join("..", ".."))
|
||||
makeStub := `#!/bin/sh
|
||||
set -eu
|
||||
dir=""
|
||||
@@ -44,27 +59,48 @@ done
|
||||
[ -n "$dir" ] && printf 'fake-binary\n' > "$dir/dws"
|
||||
`
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "make"), []byte(makeStub), 0o755)
|
||||
// Also need a stub go so need_cmd check passes
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "go"), []byte("#!/bin/sh\ntrue\n"), 0o755)
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"PATH="+stubRoot+":"+os.Getenv("PATH"),
|
||||
return &installSourceFixture{
|
||||
root: root,
|
||||
scriptPath: scriptPath,
|
||||
stubRoot: stubRoot,
|
||||
fakeHome: filepath.Join(root, "home"),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *installSourceFixture) env(extra ...string) []string {
|
||||
env := append(os.Environ(),
|
||||
"HOME="+f.fakeHome,
|
||||
"PATH="+f.stubRoot+":"+os.Getenv("PATH"),
|
||||
"DWS_VERSION=latest",
|
||||
"DWS_SKILLS_ONLY=0",
|
||||
"DWS_SKILL_MODE=mono",
|
||||
)
|
||||
return append(env, extra...)
|
||||
}
|
||||
|
||||
func TestInstallScriptSourceModeInstallsBinary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := newInstallSourceFixture(t)
|
||||
installDir := filepath.Join(fixture.root, "bin")
|
||||
|
||||
cmd := exec.Command("sh", fixture.scriptPath)
|
||||
cmd.Env = fixture.env(
|
||||
"DWS_INSTALL_DIR="+installDir,
|
||||
"DWS_INSTALL_NAME=dws-test",
|
||||
"DWS_NO_SKILLS=1",
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// Clean up the fake binary created in the real repo root
|
||||
_ = os.Remove(filepath.Join(repoRoot, "dws"))
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output))
|
||||
}
|
||||
|
||||
got := string(output)
|
||||
for _, want := range []string{
|
||||
"Installing dws from source checkout",
|
||||
"Installing dws from source checkout: " + fixture.root,
|
||||
"Install dir: " + installDir,
|
||||
"Binary installed:",
|
||||
installDir + "/dws-test",
|
||||
@@ -87,53 +123,27 @@ done
|
||||
func TestInstallScriptSourceModeInstallsSkillsIntoAgentsDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
fakeHome := filepath.Join(root, "home")
|
||||
installDir := filepath.Join(root, "bin")
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(install.sh) error = %v", err)
|
||||
}
|
||||
|
||||
stubRoot := filepath.Join(root, "stubs")
|
||||
repoRoot, _ := filepath.Abs(filepath.Join("..", ".."))
|
||||
makeStub := `#!/bin/sh
|
||||
set -eu
|
||||
dir=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-C) dir="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$dir" ] && printf 'fake-binary\n' > "$dir/dws"
|
||||
`
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "make"), []byte(makeStub), 0o755)
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "go"), []byte("#!/bin/sh\ntrue\n"), 0o755)
|
||||
fixture := newInstallSourceFixture(t)
|
||||
installDir := filepath.Join(fixture.root, "bin")
|
||||
|
||||
// Gate for index>0 agent dirs (matches build/npm/install.js): parent must exist.
|
||||
if err := os.MkdirAll(filepath.Join(fakeHome, ".cursor"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(fixture.fakeHome, ".cursor"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(.cursor) error = %v", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"HOME="+fakeHome,
|
||||
"PATH="+stubRoot+":"+os.Getenv("PATH"),
|
||||
cmd := exec.Command("sh", fixture.scriptPath)
|
||||
cmd.Env = fixture.env(
|
||||
"DWS_INSTALL_DIR="+installDir,
|
||||
"DWS_NO_SKILLS=0",
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// Clean up the fake binary created in the real repo root
|
||||
_ = os.Remove(filepath.Join(repoRoot, "dws"))
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output))
|
||||
}
|
||||
|
||||
for _, rel := range expectedHomeSkillTargets {
|
||||
skillPath := filepath.Join(fakeHome, filepath.FromSlash(rel), "SKILL.md")
|
||||
skillPath := filepath.Join(fixture.fakeHome, filepath.FromSlash(rel), "SKILL.md")
|
||||
if _, err := os.Stat(skillPath); err != nil {
|
||||
t.Fatalf("Stat(%s) error = %v\noutput:\n%s", skillPath, err, string(output))
|
||||
}
|
||||
@@ -694,48 +704,22 @@ func TestInstallScriptsCacheMultiSkills(t *testing.T) {
|
||||
func TestInstallScriptCachesMultiEndToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
fakeHome := filepath.Join(root, "home")
|
||||
installDir := filepath.Join(root, "bin")
|
||||
fixture := newInstallSourceFixture(t)
|
||||
installDir := filepath.Join(fixture.root, "bin")
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(install.sh) error = %v", err)
|
||||
}
|
||||
|
||||
stubRoot := filepath.Join(root, "stubs")
|
||||
repoRoot, _ := filepath.Abs(filepath.Join("..", ".."))
|
||||
makeStub := `#!/bin/sh
|
||||
set -eu
|
||||
dir=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-C) dir="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$dir" ] && printf 'fake-binary\n' > "$dir/dws"
|
||||
`
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "make"), []byte(makeStub), 0o755)
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "go"), []byte("#!/bin/sh\ntrue\n"), 0o755)
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"HOME="+fakeHome,
|
||||
"PATH="+stubRoot+":"+os.Getenv("PATH"),
|
||||
cmd := exec.Command("sh", fixture.scriptPath)
|
||||
cmd.Env = fixture.env(
|
||||
"DWS_INSTALL_DIR="+installDir,
|
||||
"DWS_NO_SKILLS=0",
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// Clean up the fake binary created in the real repo root
|
||||
_ = os.Remove(filepath.Join(repoRoot, "dws"))
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output))
|
||||
}
|
||||
|
||||
// Verify multi cache was populated. We expect dingtalk-* subdirs.
|
||||
multiCache := filepath.Join(fakeHome, ".dws", "skills", "multi")
|
||||
multiCache := filepath.Join(fixture.fakeHome, ".dws", "skills", "multi")
|
||||
entries, err := os.ReadDir(multiCache)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir(%s) error = %v\noutput:\n%s", multiCache, err, string(output))
|
||||
@@ -751,7 +735,7 @@ done
|
||||
}
|
||||
|
||||
// And verify mono cache.
|
||||
monoCacheSkill := filepath.Join(fakeHome, ".dws", "skills", "mono", "SKILL.md")
|
||||
monoCacheSkill := filepath.Join(fixture.fakeHome, ".dws", "skills", "mono", "SKILL.md")
|
||||
if _, err := os.Stat(monoCacheSkill); err != nil {
|
||||
t.Fatalf("missing mono cache SKILL.md at %s: %v", monoCacheSkill, err)
|
||||
}
|
||||
|
||||
@@ -598,6 +598,196 @@ func TestPostGoreleaserSkillsZipLayout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
|
||||
const (
|
||||
checksCall = "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 != 2 {
|
||||
t.Fatalf("release workflow Checks API call count = %d, want one tag check and one preflight 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,
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"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"`,
|
||||
"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{
|
||||
"actions/checkout",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
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'",
|
||||
} {
|
||||
if !strings.Contains(repair, required) {
|
||||
t.Errorf("npm repair dispatch contract is missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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_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",
|
||||
"environment: release-recovery",
|
||||
"prevent_self_review !== true",
|
||||
"protected_branches !== true",
|
||||
"can_admins_bypass !== false",
|
||||
`run.path !== ".github/workflows/release.yml"`,
|
||||
`run.event !== "push"`,
|
||||
`["failure", "cancelled", "timed_out", "startup_failure", "stale"].includes(run.conclusion)`,
|
||||
`run.head_branch !== version`,
|
||||
`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",
|
||||
`ref: process.env.RELEASE_COMMIT`,
|
||||
`path: tmp/trusted-release-tooling`,
|
||||
`ref: ${{ github.sha }}`,
|
||||
`step.name === "Require immutable published GitHub Release"`,
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -762,7 +952,7 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
|
||||
}
|
||||
|
||||
codesign := strings.Index(workflow[verifyJob:publishJob], "codesign --verify --strict --verbose=4")
|
||||
publish := strings.Index(workflow[publishJob:], `gh release edit "$GITHUB_REF_NAME" --draft=false`)
|
||||
publish := strings.Index(workflow[publishJob:], `gh release edit "$RELEASE_VERSION" --draft=false`)
|
||||
if codesign == -1 || publish == -1 {
|
||||
t.Fatal("macOS codesign verification and explicit Draft publication are required")
|
||||
}
|
||||
@@ -848,7 +1038,7 @@ func TestReleaseWorkflowOpensHomebrewPROnlyForOfficialStableTags(t *testing.T) {
|
||||
"./scripts/release/publish-homebrew-formula.sh",
|
||||
"secrets.HOMEBREW_PR_TOKEN",
|
||||
"DWS_TAP_PR_REPOSITORY",
|
||||
"automation/homebrew-${{ github.ref_name }}",
|
||||
"automation/homebrew-${{ needs.release-contract.outputs.release_version }}",
|
||||
} {
|
||||
if !strings.Contains(section, required) {
|
||||
t.Errorf("Homebrew publication step is missing %q", required)
|
||||
@@ -891,7 +1081,7 @@ func TestReleaseWorkflowOpensVersionedHomebrewPRForBetaTags(t *testing.T) {
|
||||
"dist/homebrew/dingtalk-workspace-cli-beta.rb",
|
||||
"Formula/dingtalk-workspace-cli-beta.rb",
|
||||
"secrets.HOMEBREW_PR_TOKEN",
|
||||
"automation/homebrew-beta-${{ github.ref_name }}",
|
||||
"automation/homebrew-beta-${{ needs.release-contract.outputs.release_version }}",
|
||||
} {
|
||||
if !strings.Contains(section, required) {
|
||||
t.Errorf("beta Homebrew PR step is missing %q", required)
|
||||
|
||||
@@ -30,10 +30,24 @@ func newReleaseEntryFixture(t *testing.T) *releaseEntryFixture {
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), fakeDelegate("prepare"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "release.sh"), fakeDelegate("release"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "recover-release.sh"), fakeDelegate("recover"), 0o755)
|
||||
r.commitAndPush(t, "install unified release entry fixture")
|
||||
return &releaseEntryFixture{repo: r, entry: entry, log: filepath.Join(t.TempDir(), "calls.log")}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryRoutesProtectedRecovery(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
f.configureRemote(t, "origin")
|
||||
output, err := f.run(t, nil, "recover", "v1.0.1-beta.1", "--failed-run", "12345")
|
||||
if err != nil {
|
||||
t.Fatalf("dws-release recovery route error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
want := "recover\tv1.0.1-beta.1\t--remote\torigin\t--failed-run\t12345\n"
|
||||
if got := f.callLog(t); got != want {
|
||||
t.Fatalf("recovery delegate calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *releaseEntryFixture) run(t *testing.T, extraEnv []string, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("sh", append([]string{f.entry}, args...)...)
|
||||
|
||||
@@ -392,6 +392,72 @@ esac
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowDeliveryAcceptsOnlySharedProtectedRecovery(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
binDir := t.TempDir()
|
||||
fakeCurl := filepath.Join(binDir, "curl")
|
||||
mustWriteFile(t, fakeCurl, []byte(`#!/bin/sh
|
||||
set -eu
|
||||
for argument in "$@"; do endpoint="$argument"; done
|
||||
case "$endpoint" in
|
||||
*event=push*)
|
||||
printf '{"workflow_runs":[]}\n'
|
||||
;;
|
||||
*event=workflow_dispatch*)
|
||||
printf '{"workflow_runs":[{"id":42,"display_title":"Release recovery %s at %s %s-1700000000-42","event":"workflow_dispatch","status":"completed","conclusion":"success","head_branch":"main","head_sha":"%s","path":".github/workflows/release.yml","repository":{"full_name":"owner/repo"}}]}\n' "$TAG" "$RELEASE_COMMIT" "$RELEASE_COMMIT" "$WORKFLOW_SHA"
|
||||
;;
|
||||
*/compare/*...main)
|
||||
printf '{"status":"ahead"}\n'
|
||||
;;
|
||||
*/actions/runs/42/jobs*)
|
||||
printf '{"jobs":['
|
||||
printf '{"name":"Build signed release artifacts","status":"completed","conclusion":"success","head_sha":"%s"},' "$WORKFLOW_SHA"
|
||||
printf '{"name":"Verify Apple Developer ID signatures","status":"completed","conclusion":"success","head_sha":"%s"},' "$WORKFLOW_SHA"
|
||||
printf '{"name":"Publish immutable GitHub Release","status":"completed","conclusion":"success","head_sha":"%s"}' "$WORKFLOW_SHA"
|
||||
if [ "${MISSING_CHANNELS:-0}" != 1 ]; then
|
||||
printf ',{"name":"Publish npm and mirrors","status":"completed","conclusion":"success","head_sha":"%s"}' "$WORKFLOW_SHA"
|
||||
fi
|
||||
printf ']}\n'
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
`), 0o755)
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "verify-release-workflow-delivery.sh")
|
||||
tag := "v1.2.3-beta.1"
|
||||
commit := strings.Repeat("a", 40)
|
||||
workflowSHA := strings.Repeat("b", 40)
|
||||
run := func(missingChannels bool) (string, error) {
|
||||
cmd := exec.Command("sh", script, tag, commit)
|
||||
missing := "0"
|
||||
if missingChannels {
|
||||
missing = "1"
|
||||
}
|
||||
cmd.Env = []string{
|
||||
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
"HOME=" + t.TempDir(),
|
||||
"DWS_RELEASE_OFFICIAL_REPOSITORY=owner/repo",
|
||||
"TAG=" + tag,
|
||||
"RELEASE_COMMIT=" + commit,
|
||||
"WORKFLOW_SHA=" + workflowSHA,
|
||||
"MISSING_CHANNELS=" + missing,
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
output, err := run(false)
|
||||
if err != nil || !strings.Contains(output, "protected recovery run 42") {
|
||||
t.Fatalf("protected shared recovery was rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
output, err = run(true)
|
||||
if err == nil || !strings.Contains(output, "did not complete the shared release job graph") {
|
||||
t.Fatalf("incomplete recovery job graph passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func releaseChangelog(sections ...string) string {
|
||||
text := "# Changelog\n\n## [Unreleased]\n\n"
|
||||
for _, section := range sections {
|
||||
@@ -1002,6 +1068,178 @@ func TestReleaseCommandValidatesThenPushesAnnotatedTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandReusesExactPreflightProof(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
mustWriteFile(t, filepath.Join(r.root, ".gitignore"), []byte("/dws\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte(`test:
|
||||
@rm -f dws
|
||||
@printf 'test\n' >> "$$(git rev-parse --git-path release-phases)"
|
||||
build:
|
||||
@printf '#!/bin/sh\nexit 0\n' > dws
|
||||
@chmod +x dws
|
||||
@printf 'build\n' >> "$$(git rev-parse --git-path release-phases)"
|
||||
policy:
|
||||
@test -x dws
|
||||
@printf 'policy\n' >> "$$(git rev-parse --git-path release-phases)"
|
||||
package:
|
||||
@printf 'package\n' >> "$$(git rev-parse --git-path release-phases)"
|
||||
`), 0o644)
|
||||
r.commitAndPush(t, "install proof-aware release automation")
|
||||
|
||||
command := filepath.Join(r.root, "scripts", "release", "release.sh")
|
||||
output, err := runReleaseScript(t, r.root, command,
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "Publish within six hours") {
|
||||
t.Fatalf("validation output missing reusable-proof guidance:\n%s", output)
|
||||
}
|
||||
phasePath := filepath.Join(r.root, ".git", "release-phases")
|
||||
phaseData, err := os.ReadFile(phasePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(release phases) error = %v", err)
|
||||
}
|
||||
wantPhases := "test\nbuild\npolicy\npackage\n"
|
||||
if string(phaseData) != wantPhases {
|
||||
t.Fatalf("release phase order = %q, want %q", phaseData, wantPhases)
|
||||
}
|
||||
proofPath := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "--git-path", "dws-release-preflight/v1.0.1-beta.1.proof"))
|
||||
if !filepath.IsAbs(proofPath) {
|
||||
proofPath = filepath.Join(r.root, proofPath)
|
||||
}
|
||||
proofBefore, err := os.ReadFile(proofPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(preflight proof) error = %v", err)
|
||||
}
|
||||
hook := filepath.Join(r.remote, "hooks", "pre-receive")
|
||||
mustWriteFile(t, hook, []byte("#!/bin/sh\nset -eu\nwhile read -r old new ref; do\n case \"$ref\" in refs/tags/*) exit 1 ;; esac\ndone\nexit 0\n"), 0o755)
|
||||
failedOutput, failedErr := runReleaseScript(t, r.root, command,
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes",
|
||||
)
|
||||
if failedErr == nil || !strings.Contains(failedOutput, "new local tag was removed") {
|
||||
t.Fatalf("rejected proof-based publish did not fail safely: err=%v\noutput:\n%s", failedErr, failedOutput)
|
||||
}
|
||||
proofAfterFailure, err := os.ReadFile(proofPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(preflight proof after failure) error = %v", err)
|
||||
}
|
||||
if string(proofAfterFailure) != string(proofBefore) {
|
||||
t.Fatalf("failed publish refreshed reusable proof:\nbefore:\n%s\nafter:\n%s", proofBefore, proofAfterFailure)
|
||||
}
|
||||
if err := os.Remove(hook); err != nil {
|
||||
t.Fatalf("Remove(rejecting hook) error = %v", err)
|
||||
}
|
||||
|
||||
output, err = runReleaseScript(t, r.root, command,
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("release publish error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "Reusing exact preflight proof") {
|
||||
t.Fatalf("publish did not reuse the exact preflight proof:\n%s", output)
|
||||
}
|
||||
phaseData, err = os.ReadFile(phasePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(release phases after publish) error = %v", err)
|
||||
}
|
||||
if string(phaseData) != wantPhases {
|
||||
t.Fatalf("publish reran expensive phases: %q", phaseData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseGovernanceCIDispatchBindsExactRunIdentity(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
binDir := t.TempDir()
|
||||
stateDir := t.TempDir()
|
||||
fakeGH := filepath.Join(binDir, "gh")
|
||||
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
|
||||
set -eu
|
||||
command="$1"
|
||||
shift
|
||||
case "$command" in
|
||||
workflow)
|
||||
test "$1" = run
|
||||
shift
|
||||
printf '%s\n' "$@" > "$GH_STATE/workflow-args"
|
||||
for argument in "$@"; do
|
||||
case "$argument" in
|
||||
governance_preflight_nonce=*)
|
||||
printf 'Release governance preflight %s\n' "${argument#governance_preflight_nonce=}" > "$GH_STATE/title"
|
||||
;;
|
||||
governance_preflight_commit=*)
|
||||
printf '%s\n' "${argument#governance_preflight_commit=}" > "$GH_STATE/commit"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
;;
|
||||
api)
|
||||
for argument in "$@"; do
|
||||
case "$argument" in repos/*/actions/*) endpoint="$argument" ;; esac
|
||||
done
|
||||
case "$endpoint" in
|
||||
*/actions/workflows/release.yml/runs*) printf '42\n' ;;
|
||||
*/actions/runs/42)
|
||||
title="$(cat "$GH_STATE/title")"
|
||||
commit="$(cat "$GH_STATE/commit")"
|
||||
repository=owner/repo
|
||||
if [ "${GH_BAD_STATE:-0}" = 1 ]; then repository=attacker/repo; fi
|
||||
printf '%s\tworkflow_dispatch\tcompleted\tsuccess\tmain\t%s\t.github/workflows/release.yml\t%s\n' "$title" "$commit" "$repository"
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
run)
|
||||
test "$1" = watch
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
`), 0o755)
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "verify-release-governance-ci.sh")
|
||||
commit := strings.Repeat("a", 40)
|
||||
run := func(badState bool) (string, error) {
|
||||
cmd := exec.Command("sh", script, "owner/repo", commit)
|
||||
cmd.Env = []string{
|
||||
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
"HOME=" + t.TempDir(),
|
||||
"GH_STATE=" + stateDir,
|
||||
fmt.Sprintf("GH_BAD_STATE=%d", map[bool]int{false: 0, true: 1}[badState]),
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
output, err := run(false)
|
||||
if err != nil || !strings.Contains(output, "Release governance preflight passed") {
|
||||
t.Fatalf("governance CI preflight error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
args, err := os.ReadFile(filepath.Join(stateDir, "workflow-args"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(workflow args) error = %v", err)
|
||||
}
|
||||
for _, required := range []string{
|
||||
"--ref\nmain\n",
|
||||
"governance_preflight_commit=" + commit,
|
||||
"governance_preflight_nonce=" + commit + "-",
|
||||
} {
|
||||
if !strings.Contains(string(args), required) {
|
||||
t.Errorf("workflow dispatch args are missing %q:\n%s", required, args)
|
||||
}
|
||||
}
|
||||
|
||||
output, err = run(true)
|
||||
if err == nil || !strings.Contains(output, "run identity mismatch") {
|
||||
t.Fatalf("mismatched governance run identity passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandCleansLocalTagWhenPushFails(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
@@ -1048,7 +1286,7 @@ func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) {
|
||||
section := "## [1.0.2-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate a candidate after stable authority advances.\n\n"
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'compatibility %s\\n' \"$*\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644)
|
||||
r.commitAndPush(t, "install advancing release fixture")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
@@ -1071,7 +1309,7 @@ func installReleaseCommandFixture(t *testing.T, r *releaseTestRepo) {
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-package-managers.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-release-artifacts.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644)
|
||||
}
|
||||
|
||||
func releaseCopyFile(t *testing.T, source, target string, mode os.FileMode) {
|
||||
|
||||
Reference in New Issue
Block a user