package scripts_test import ( "encoding/json" "os" "os/exec" "path/filepath" "strings" "testing" ) func TestWithdrawReleaseWorkflowIsProtectedAndFailClosed(t *testing.T) { t.Parallel() workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "withdraw-release.yml")) if err != nil { t.Fatalf("Abs(withdraw workflow) error = %v", err) } content, err := os.ReadFile(workflowPath) if err != nil { t.Fatalf("ReadFile(%s) error = %v", workflowPath, err) } workflow := string(content) for _, want := range []string{ "workflow_dispatch:", "version:", "reason:", "confirmation:", "group: dws-release-publication", "cancel-in-progress: false", "environment: release-withdrawal", "prevent_self_review !== true", "deployment_branch_policy?.protected_branches !== true", "can_admins_bypass !== false", `const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli"`, "context.ref !== `refs/heads/${defaultBranch}`", "branch.data.object.sha !== context.sha", "contents: write", "persist-credentials: false", "NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}", "DWS_GITEE_ENABLED:", "HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}", "./scripts/release/withdraw-release.sh", "already-installed clients cannot be remotely downgraded.", "this run remains failed until that PR is independently reviewed", } { if !strings.Contains(workflow, want) { t.Errorf("withdraw workflow missing %q", want) } } for _, forbidden := range []string{ "\n push:", "\n schedule:", "npm unpublish", "cancel-in-progress: true", } { if strings.Contains(workflow, forbidden) { t.Errorf("withdraw workflow contains forbidden trigger/action %q", forbidden) } } } func TestWithdrawReleaseScriptDeletesProblemReleaseLastAndUsesPermanentTombstone(t *testing.T) { t.Parallel() scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "withdraw-release.sh")) if err != nil { t.Fatalf("Abs(withdraw script) error = %v", err) } content, err := os.ReadFile(scriptPath) if err != nil { t.Fatalf("ReadFile(%s) error = %v", scriptPath, err) } script := string(content) for _, want := range []string{ `TOMBSTONE="withdrawn/${VERSION}"`, `github_api --method POST "repos/${OFFICIAL_REPOSITORY}/git/tags"`, `github_api --method POST "repos/${OFFICIAL_REPOSITORY}/git/refs"`, "existing tombstone $TOMBSTONE has different immutable withdrawal metadata", `github_api --method PATCH`, `npm deprecate "${PACKAGE_NAME}@${VERSION#v}"`, `npm dist-tag add "${PACKAGE_NAME}@${ROLLBACK_VERSION#v}"`, `TARGET_OSS_MODE="$("$SCRIPT_DIR/release-tag-oss-mode.sh" "$VERSION")"`, `printf 'OSS-Mirror: %s\n' "$TARGET_OSS_MODE"`, `oss_mode = fields.get("OSS-Mirror", "enabled")`, `if [ "$OSS_ENABLED" = true ]; then`, `*) err "could not resolve immutable OSS policy for $VERSION" ;;`, `"$OSSUTIL" rm -rf`, `curl -fsS -X DELETE`, `git push "$GITEE_GIT_REMOTE" ":refs/tags/${VERSION}"`, `)" || return 1`, `err "could not verify Gitee tag deletion for $VERSION"`, `disabled_gitee_release_id="$(gitee_release_id "$VERSION")"`, `DWS_TAP_PR_TITLE="revert: withdraw ${VERSION} and restore ${ROLLBACK_VERSION}"`, "Homebrew rollback PR requires independent review and merge", `"repos/${OFFICIAL_REPOSITORY}/releases/${TARGET_RELEASE_ID}"`, `"repos/${OFFICIAL_REPOSITORY}/git/refs/tags/${VERSION}"`, `github_expect_404 "repos/${OFFICIAL_REPOSITORY}/releases/tags/${VERSION}"`, `github_expect_404 "repos/${OFFICIAL_REPOSITORY}/git/ref/tags/${VERSION}"`, "Already-installed clients cannot be remotely downgraded", } { if !strings.Contains(script, want) { t.Errorf("withdraw script missing %q", want) } } for _, forbidden := range []string{ "npm unpublish", `"repos/${OFFICIAL_REPOSITORY}/git/refs/tags/${TOMBSTONE}"`, `--force "refs/tags/${TOMBSTONE}`, `make_latest`, } { if strings.Contains(script, forbidden) { t.Errorf("withdraw script contains evidence-destroying operation %q", forbidden) } } tombstone := strings.LastIndex(script, "\ncreate_tombstone\n") githubMutation := strings.LastIndex(script, "\nupdate_github_release\n") npmMutation := strings.LastIndex(script, "\nupdate_npm_channel\n") ossMutation := strings.LastIndex(script, `update_oss_channel "$OSS_POINTER_NAME"`) homebrewGate := strings.LastIndex(script, "\nupdate_homebrew\n") githubDelete := strings.LastIndex(script, "\ndelete_github_release_and_tag\n") if tombstone < 0 || githubMutation < 0 || npmMutation < 0 || ossMutation < 0 || homebrewGate < 0 || githubDelete < 0 { t.Fatalf("could not locate withdrawal mutation sequence") } if !(tombstone < homebrewGate && homebrewGate < githubMutation && githubMutation < npmMutation && npmMutation < ossMutation && ossMutation < githubDelete) { t.Fatalf("unsafe withdrawal order: tombstone=%d github-mark=%d npm=%d oss=%d homebrew=%d github-delete=%d", tombstone, githubMutation, npmMutation, ossMutation, homebrewGate, githubDelete) } } func TestReleaseTagOSSModeIsImmutableAndFailClosed(t *testing.T) { t.Parallel() scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "release-tag-oss-mode.sh")) if err != nil { t.Fatalf("Abs(OSS mode script) error = %v", err) } repo := t.TempDir() mustRun(t, repo, "git", "init", "-b", "main") mustRun(t, repo, "git", "config", "user.name", "OSS Mode Test") mustRun(t, repo, "git", "config", "user.email", "oss-mode@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") tag := func(version, message string) { t.Helper() messagePath := filepath.Join(repo, version+".message") mustWriteFile(t, messagePath, []byte(message), 0o644) mustRun(t, repo, "git", "tag", "-a", version, "-F", messagePath) } tag("v1.0.1", "Release v1.0.1\n") tag("v1.0.2", "Release v1.0.2\n\nOSS-Mirror: enabled\n") tag("v1.0.3", "Release v1.0.3\n\nOSS-Mirror: deferred\n") tag("v1.0.4", "Release v1.0.4\n\nOSS-Mirror: invalid\n") tag("v1.0.5", "Release v1.0.5\n\nOSS-Mirror: enabled\nOSS-Mirror: deferred\n") mustRun(t, repo, "git", "tag", "v1.0.6") rawTag := func(version, message string) { t.Helper() commit := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD")) payload := strings.Join([]string{ "object " + commit, "type commit", "tag " + version, "tagger OSS Mode Test 1700000000 +0000", "", message, }, "\n") cmd := exec.Command("git", "mktag") cmd.Dir = repo cmd.Stdin = strings.NewReader(payload) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git mktag %s error = %v\noutput:\n%s", version, err, output) } mustRun(t, repo, "git", "update-ref", "refs/tags/"+version, strings.TrimSpace(string(output))) } rawTag("v1.0.7", "Release v1.0.7\n\nOSS-Mirror: \n") rawTag("v1.0.8", "Release v1.0.8\r\n\r\nOSS-Mirror: deferred\r\n") for _, test := range []struct { version string want string ok bool }{ {version: "v1.0.1", want: "enabled", ok: true}, {version: "v1.0.2", want: "enabled", ok: true}, {version: "v1.0.3", want: "deferred", ok: true}, {version: "v1.0.4", want: "invalid OSS-Mirror metadata", ok: false}, {version: "v1.0.5", want: "duplicate OSS-Mirror metadata", ok: false}, {version: "v1.0.6", want: "must be annotated", ok: false}, {version: "v1.0.7", want: "invalid OSS-Mirror metadata", ok: false}, {version: "v1.0.8", want: "deferred", ok: true}, } { t.Run(test.version, func(t *testing.T) { cmd := exec.Command(scriptPath, test.version) cmd.Dir = repo output, err := cmd.CombinedOutput() if test.ok && err != nil { t.Fatalf("release-tag-oss-mode.sh error = %v\noutput:\n%s", err, output) } if !test.ok && err == nil { t.Fatalf("release-tag-oss-mode.sh unexpectedly accepted %s", test.version) } if !strings.Contains(string(output), test.want) { t.Fatalf("release-tag-oss-mode.sh output missing %q:\n%s", test.want, output) } }) } } func TestWithdrawReleaseDefersOSSRequirementUntilImmutablePolicyResolution(t *testing.T) { t.Parallel() scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "withdraw-release.sh")) if err != nil { t.Fatalf("Abs(withdraw script) error = %v", err) } cmd := exec.Command("bash", scriptPath, "v1.2.3", "critical startup regression", "WITHDRAW v1.2.3", ) cmd.Env = append(os.Environ(), "GITHUB_TOKEN=test-github-token", "NODE_AUTH_TOKEN=test-npm-token", "GITHUB_REPOSITORY=DingTalk-Real-AI/dingtalk-workspace-cli", "GITHUB_REF_NAME=main", "GITHUB_SHA="+strings.Repeat("a", 40), "GITHUB_RUN_ID=1", "GITHUB_ACTOR=test-operator", "GITHUB_EVENT_DEFAULT_BRANCH=main", "GITHUB_ACTIONS=false", ) output, err := cmd.CombinedOutput() if err == nil { t.Fatalf("withdraw-release.sh unexpectedly ran outside GitHub Actions:\n%s", output) } if !strings.Contains(string(output), "withdrawal may run only inside GitHub Actions") { t.Fatalf("withdrawal did not reach the protected execution gate before resolving OSS policy:\n%s", output) } if strings.Contains(string(output), "missing required environment variable OSS_") { t.Fatalf("withdrawal required OSS credentials before resolving immutable tag policy:\n%s", output) } } func TestWithdrawReleaseRejectsInvalidInputsBeforeMutation(t *testing.T) { t.Parallel() scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "withdraw-release.sh")) if err != nil { t.Fatalf("Abs(withdraw script) error = %v", err) } tests := []struct { name string version string reason string confirmation string want string }{ { name: "invalid version", version: "1.2.3", reason: "critical startup regression", confirmation: "WITHDRAW 1.2.3", want: "version must be exactly", }, { name: "wrong confirmation", version: "v1.2.3", reason: "critical startup regression", confirmation: "v1.2.3", want: "confirmation must be exactly: WITHDRAW v1.2.3", }, { name: "multiline reason", version: "v1.2.3", reason: "critical\nregression", confirmation: "WITHDRAW v1.2.3", want: "reason must be a trimmed, printable single line", }, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { cmd := exec.Command("bash", scriptPath, test.version, test.reason, test.confirmation) output, err := cmd.CombinedOutput() if err == nil { t.Fatalf("withdraw-release.sh unexpectedly accepted invalid input:\n%s", string(output)) } if !strings.Contains(string(output), test.want) { t.Fatalf("withdraw-release.sh output missing %q:\n%s", test.want, string(output)) } }) } } func TestWithdrawReleaseRollsBackConfiguredChannelsAndStopsForHomebrewReview(t *testing.T) { for _, test := range []struct { name string ossDeferred bool }{ {name: "legacy tag defaults OSS to enabled"}, {name: "deferred OSS survives tombstone retry", ossDeferred: true}, } { t.Run(test.name, func(t *testing.T) { testWithdrawReleaseRollsBackConfiguredChannelsAndStopsForHomebrewReview(t, test.ossDeferred) }) } } func testWithdrawReleaseRollsBackConfiguredChannelsAndStopsForHomebrewReview(t *testing.T, ossDeferred bool) { root := t.TempDir() sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) if err != nil { t.Fatalf("Abs(source root) error = %v", err) } for _, rel := range []string{ "scripts/release/withdraw-release.sh", "scripts/release/release-tag-oss-mode.sh", "scripts/release/release-lib.sh", "build/homebrew-release.rb.tmpl", } { copyTestFile(t, filepath.Join(sourceRoot, rel), filepath.Join(root, rel), 0o755) } mustWriteFile(t, filepath.Join(root, "scripts", "release", "verify-delivery"), []byte(`#!/bin/sh printf 'delivery %s %s\n' "$1" "$2" >> "$CALL_LOG" exit 0 `), 0o755) mustWriteFile(t, filepath.Join(root, "scripts", "release", "download-release-assets"), []byte(`#!/bin/sh set -eu version="$1" dist="$2" mkdir -p "$dist" for asset in \ 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; do printf '%s %s\n' "$version" "$asset" > "$dist/$asset" done { for asset in \ 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; do printf '%064d %s\n' 0 "$asset" done } > "$dist/checksums.txt" printf 'download-assets %s\n' "$version" >> "$CALL_LOG" `), 0o755) mustWriteFile(t, filepath.Join(root, "scripts", "release", "verify-release-assets"), []byte(`#!/bin/sh set -eu test -f "$DWS_PACKAGE_DIST_DIR/checksums.txt" printf 'verify-assets %s\n' "$1" >> "$CALL_LOG" `), 0o755) mustWriteFile(t, filepath.Join(root, "scripts", "release", "sync-gitee"), []byte(`#!/bin/sh set -eu git push "$GITEE_GIT_REMOTE" "refs/tags/$VERSION:refs/tags/$VERSION" >/dev/null printf '%s\n' "$VERSION" > "$MOCK_STATE/gitee-release-$VERSION" printf 'gitee-sync %s\n' "$VERSION" >> "$CALL_LOG" `), 0o755) mustWriteFile(t, filepath.Join(root, "scripts", "release", "publish-homebrew-formula.sh"), []byte(`#!/bin/sh cp "$DWS_FORMULA_SOURCE" "$MOCK_STATE/homebrew-formula" printf 'homebrew-pr %s %s\n' "$DWS_TAP_PR_TITLE" "$DWS_TAP_PR_BRANCH" >> "$CALL_LOG" exit 0 `), 0o755) mustWriteFile(t, filepath.Join(root, "scripts", "release", "unexpected-ossutil"), []byte(`#!/bin/sh printf 'unexpected-oss-call\n' >> "$CALL_LOG" exit 99 `), 0o755) stateDir := filepath.Join(root, "state") fakeBin := filepath.Join(root, "bin") if err := os.MkdirAll(stateDir, 0o755); err != nil { t.Fatalf("MkdirAll(state) error = %v", err) } callLog := filepath.Join(stateDir, "calls.log") writeWithdrawMocks(t, fakeBin) mustWriteFile(t, filepath.Join(root, "Formula", "dingtalk-workspace-cli.rb"), []byte(`class DingtalkWorkspaceCli < Formula version "1.0.51" end `), 0o644) mustRun(t, root, "git", "init", "-b", "main") mustRun(t, root, "git", "config", "user.name", "Withdrawal Test") mustRun(t, root, "git", "config", "user.email", "withdrawal@example.com") mustRun(t, root, "git", "add", ".") mustRun(t, root, "git", "commit", "-m", "candidate") mustRun(t, root, "git", "tag", "-a", "v1.0.51", "-m", "Release v1.0.51") mustWriteFile(t, filepath.Join(root, "Formula", "dingtalk-workspace-cli.rb"), []byte(`class DingtalkWorkspaceCli < Formula version "1.0.52" end `), 0o644) mustRun(t, root, "git", "add", "Formula/dingtalk-workspace-cli.rb") mustRun(t, root, "git", "commit", "-m", "target") tagArgs := []string{"tag", "-a", "v1.0.52", "-m", "Release v1.0.52"} if ossDeferred { tagArgs = append(tagArgs, "-m", "OSS-Mirror: deferred") } mustRun(t, root, "git", tagArgs...) targetCommit := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "HEAD")) targetTagObject := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "refs/tags/v1.0.52")) origin := filepath.Join(root, "origin.git") gitee := filepath.Join(root, "gitee.git") mustRun(t, root, "git", "init", "--bare", origin) mustRun(t, root, "git", "init", "--bare", gitee) mustRun(t, root, "git", "remote", "add", "origin", origin) mustRun(t, root, "git", "push", "origin", "main", "v1.0.51", "v1.0.52") mustRun(t, root, "git", "push", gitee, "v1.0.52") writeJSONFile(t, filepath.Join(stateDir, "release-v1.0.51.json"), map[string]any{ "id": 51, "tag_name": "v1.0.51", "name": "v1.0.51", "body": "candidate", "draft": false, "prerelease": false, "immutable": true, }) writeJSONFile(t, filepath.Join(stateDir, "release-v1.0.52.json"), map[string]any{ "id": 52, "tag_name": "v1.0.52", "name": "v1.0.52", "body": "target", "draft": false, "prerelease": false, "immutable": true, }) mustWriteFile(t, filepath.Join(stateDir, "latest"), []byte("v1.0.52\n"), 0o644) mustWriteFile(t, filepath.Join(stateDir, "npm-latest"), []byte("1.0.52\n"), 0o644) mustWriteFile(t, filepath.Join(stateDir, "oss-latest"), []byte("v1.0.52\n"), 0o644) mustWriteFile(t, filepath.Join(stateDir, "gitee-release-v1.0.52"), []byte("752\n"), 0o644) mustWriteFile(t, filepath.Join(stateDir, "github-tag-v1.0.52"), []byte(targetTagObject+"\n"), 0o644) scriptPath := filepath.Join(root, "scripts", "release", "withdraw-release.sh") cmd := exec.Command("bash", scriptPath, "v1.0.52", "critical startup regression", "WITHDRAW v1.0.52", ) cmd.Dir = root withdrawEnv := append(os.Environ(), "PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"), "MOCK_STATE="+stateDir, "CALL_LOG="+callLog, "GITHUB_ACTIONS=true", "GITHUB_REPOSITORY=DingTalk-Real-AI/dingtalk-workspace-cli", "GITHUB_REF_NAME=main", "GITHUB_SHA="+targetCommit, "GITHUB_RUN_ID=12345", "GITHUB_ACTOR=release-operator", "GITHUB_EVENT_DEFAULT_BRANCH=main", "GITHUB_TOKEN=github-token", "NODE_AUTH_TOKEN=npm-token", "DWS_GITEE_ENABLED=true", "GITEE_TOKEN=gitee-token", "GITEE_USER=gitee-user", "GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli", "GITEE_GIT_REMOTE="+gitee, "GITEE_PUBLIC_GIT_REMOTE="+gitee, "HOMEBREW_PR_TOKEN=homebrew-token", "DWS_DELIVERY_VERIFIER="+filepath.Join(root, "scripts", "release", "verify-delivery"), "DWS_GITHUB_DOWNLOAD_HELPER="+filepath.Join(root, "scripts", "release", "download-release-assets"), "DWS_ARTIFACT_VERIFY_HELPER="+filepath.Join(root, "scripts", "release", "verify-release-assets"), "DWS_GITEE_SYNC_HELPER="+filepath.Join(root, "scripts", "release", "sync-gitee"), "ORIGIN_GIT="+origin, ) if ossDeferred { withdrawEnv = append(withdrawEnv, "OSS_ACCESS_KEY_ID=", "OSS_ACCESS_KEY_SECRET=", "OSS_ENDPOINT=", "OSS_BUCKET=", "OSSUTIL="+filepath.Join(root, "scripts", "release", "unexpected-ossutil"), ) } else { withdrawEnv = append(withdrawEnv, "OSS_ACCESS_KEY_ID=oss-id", "OSS_ACCESS_KEY_SECRET=oss-secret", "OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com", "OSS_BUCKET=dws-test", "OSSUTIL="+filepath.Join(fakeBin, "ossutil"), ) } cmd.Env = withdrawEnv output, err := cmd.CombinedOutput() if err == nil { t.Fatalf("withdrawal must remain failed while Homebrew PR is pending:\n%s", string(output)) } if !strings.Contains(string(output), "Homebrew rollback PR requires independent review and merge") { t.Fatalf("withdrawal did not report the Homebrew manual gate:\n%s", string(output)) } assertFileEquals(t, filepath.Join(stateDir, "latest"), "v1.0.51") assertFileEquals(t, filepath.Join(stateDir, "npm-latest"), "1.0.51") assertFileContains(t, filepath.Join(stateDir, "npm-deprecated"), "WITHDRAWN v1.0.52") if ossDeferred { assertFileEquals(t, filepath.Join(stateDir, "oss-latest"), "v1.0.52") } else { assertFileEquals(t, filepath.Join(stateDir, "oss-latest"), "v1.0.51") if _, err := os.Stat(filepath.Join(stateDir, "oss-removed")); err != nil { t.Fatalf("OSS withdrawn prefix was not removed: %v", err) } } if _, err := os.Stat(filepath.Join(stateDir, "gitee-release-v1.0.52")); !os.IsNotExist(err) { t.Fatalf("Gitee release still exists, stat error = %v", err) } if refs := mustOutput(t, root, "git", "ls-remote", gitee, "refs/tags/v1.0.52"); strings.TrimSpace(refs) != "" { t.Fatalf("Gitee still exposes withdrawn tag:\n%s", refs) } if _, err := os.Stat(filepath.Join(stateDir, "release-v1.0.52.json")); !os.IsNotExist(err) { t.Fatalf("GitHub problem release still exists while Homebrew review is pending, stat error = %v", err) } if _, err := os.Stat(filepath.Join(stateDir, "github-tag-v1.0.52")); !os.IsNotExist(err) { t.Fatalf("GitHub problem tag still exists while Homebrew review is pending, stat error = %v", err) } assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Original-Commit: "+targetCommit) assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Original-Tag-Object: "+targetTagObject) assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Original-Release-ID: 52") ossMode := "enabled" if ossDeferred { ossMode = "deferred" } assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "OSS-Mirror: "+ossMode) assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Reason: critical startup regression") assertFileContains(t, callLog, "homebrew-pr revert: withdraw v1.0.52 and restore v1.0.51") calls, err := os.ReadFile(callLog) if err != nil { t.Fatalf("ReadFile(call log) error = %v", err) } logText := string(calls) tombstoneIndex := strings.Index(logText, "tombstone-ref") githubIndex := strings.Index(logText, "github-withdraw") npmIndex := strings.Index(logText, "npm-deprecate") ossIndex := strings.Index(logText, "oss-remove") if tombstoneIndex < 0 || githubIndex < 0 || npmIndex < 0 || (!ossDeferred && ossIndex < 0) { t.Fatalf("missing mutation audit entries:\n%s", logText) } homebrewIndex := strings.Index(logText, "homebrew-pr") if !(tombstoneIndex < homebrewIndex && homebrewIndex < githubIndex && githubIndex < npmIndex) || (!ossDeferred && npmIndex >= ossIndex) { t.Fatalf("tombstone was not durable before channel mutations:\n%s", logText) } deleteReleaseIndex := strings.Index(logText, "github-delete-release") deleteTagIndex := strings.Index(logText, "github-delete-tag") channelsCompleteIndex := npmIndex if !ossDeferred { channelsCompleteIndex = ossIndex } if deleteReleaseIndex < 0 || deleteTagIndex < 0 || homebrewIndex < 0 || !(homebrewIndex < channelsCompleteIndex && channelsCompleteIndex < deleteReleaseIndex && deleteReleaseIndex < deleteTagIndex) { t.Fatalf("GitHub problem release was not removed before the Homebrew review pause:\n%s", logText) } if ossDeferred { if strings.Contains(logText, "unexpected-oss-call") || ossIndex >= 0 { t.Fatalf("deferred withdrawal invoked OSS tooling:\n%s", logText) } for _, want := range []string{ "OSS mirroring is disabled; no OSS rollback is required.", "OSS did not contain v1.0.52 because mirroring was disabled", } { if !strings.Contains(string(output), want) { t.Fatalf("deferred withdrawal output missing %q:\n%s", want, output) } } } if !ossDeferred { tombstonePath := filepath.Join(stateDir, "tombstone-message") tombstoneMessage, err := os.ReadFile(tombstonePath) if err != nil { t.Fatalf("ReadFile(legacy tombstone) error = %v", err) } legacyMessage := strings.Replace(string(tombstoneMessage), "OSS-Mirror: enabled\n", "", 1) if legacyMessage == string(tombstoneMessage) { t.Fatal("could not convert tombstone fixture to the legacy format") } mustWriteFile(t, tombstonePath, []byte(legacyMessage), 0o644) } mergedFormula, err := os.ReadFile(filepath.Join(stateDir, "homebrew-formula")) if err != nil { t.Fatalf("ReadFile(rendered Homebrew rollback) error = %v", err) } mustWriteFile(t, filepath.Join(root, "Formula", "dingtalk-workspace-cli.rb"), mergedFormula, 0o644) mustRun(t, root, "git", "add", "Formula/dingtalk-workspace-cli.rb") mustRun(t, root, "git", "commit", "-m", "merge Homebrew rollback") mustRun(t, root, "git", "push", "origin", "main") retryCommit := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "HEAD")) retryEnv := replaceTestEnv(withdrawEnv, "GITHUB_SHA", retryCommit, "GITHUB_RUN_ID", "12346", ) retry := exec.Command("bash", scriptPath, "v1.0.52", "critical startup regression", "WITHDRAW v1.0.52", ) retry.Dir = root retry.Env = retryEnv retryOutput, retryErr := retry.CombinedOutput() if retryErr != nil { t.Fatalf("withdrawal retry after Homebrew merge error = %v\noutput:\n%s", retryErr, string(retryOutput)) } for _, want := range []string{ "Resuming withdrawal for v1.0.52 from exact permanent tombstone metadata.", "Permanent tombstone withdrawn/v1.0.52 already exists", "GitHub Release v1.0.52 was already absent.", "GitHub tag v1.0.52 was already absent.", "Withdrawal completed for all configured distribution channels.", "Already-installed clients cannot be remotely downgraded", } { if !strings.Contains(string(retryOutput), want) { t.Fatalf("withdrawal retry output missing %q:\n%s", want, string(retryOutput)) } } if ossDeferred && !strings.Contains(string(retryOutput), "OSS mirroring is disabled; no OSS rollback is required.") { t.Fatalf("deferred tombstone retry did not restore the sealed OSS policy:\n%s", retryOutput) } assertFileEquals(t, filepath.Join(stateDir, "latest"), "v1.0.51") if _, err := os.Stat(filepath.Join(stateDir, "release-v1.0.52.json")); !os.IsNotExist(err) { t.Fatalf("GitHub problem release still exists, stat error = %v", err) } if _, err := os.Stat(filepath.Join(stateDir, "github-tag-v1.0.52")); !os.IsNotExist(err) { t.Fatalf("GitHub problem tag still exists, stat error = %v", err) } if refs := mustOutput(t, root, "git", "ls-remote", origin, "refs/tags/v1.0.52"); strings.TrimSpace(refs) != "" { t.Fatalf("GitHub origin still exposes withdrawn tag:\n%s", refs) } if _, err := os.Stat(filepath.Join(stateDir, "tombstone-ref")); err != nil { t.Fatalf("permanent withdrawal tombstone is missing: %v", err) } assertFileContains(t, callLog, "github-delete-release") assertFileContains(t, callLog, "github-delete-tag") } func copyTestFile(t *testing.T, source, destination string, mode os.FileMode) { t.Helper() content, err := os.ReadFile(source) if err != nil { t.Fatalf("ReadFile(%s) error = %v", source, err) } mustWriteFile(t, destination, content, mode) } func writeJSONFile(t *testing.T, path string, value any) { t.Helper() content, err := json.Marshal(value) if err != nil { t.Fatalf("Marshal(%s) error = %v", path, err) } mustWriteFile(t, path, append(content, '\n'), 0o644) } func assertFileEquals(t *testing.T, path, want string) { t.Helper() content, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile(%s) error = %v", path, err) } if got := strings.TrimSpace(string(content)); got != want { t.Fatalf("%s = %q, want %q", path, got, want) } } func assertFileContains(t *testing.T, path, want string) { t.Helper() content, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile(%s) error = %v", path, err) } if !strings.Contains(string(content), want) { t.Fatalf("%s missing %q:\n%s", path, want, string(content)) } } func replaceTestEnv(env []string, replacements ...string) []string { result := append([]string{}, env...) for index := 0; index < len(replacements); index += 2 { key := replacements[index] value := replacements[index+1] prefix := key + "=" replaced := false for envIndex, entry := range result { if strings.HasPrefix(entry, prefix) { result[envIndex] = prefix + value replaced = true } } if !replaced { result = append(result, prefix+value) } } return result } func writeWithdrawMocks(t *testing.T, fakeBin string) { t.Helper() mustWriteFile(t, filepath.Join(fakeBin, "gh"), []byte(`#!/usr/bin/env python3 import json import os import pathlib import subprocess import sys state = pathlib.Path(os.environ["MOCK_STATE"]) log = pathlib.Path(os.environ["CALL_LOG"]) args = sys.argv[1:] def record(value): with log.open("a", encoding="utf-8") as handle: handle.write(value + "\n") def value_after(flag, default=""): try: return args[args.index(flag) + 1] except (ValueError, IndexError): return default def not_found(): print("gh: Not Found (HTTP 404)", file=sys.stderr) raise SystemExit(1) if args[:2] == ["release", "download"]: output = pathlib.Path(value_after("--dir")) output.mkdir(parents=True, exist_ok=True) assets = [ "dws-darwin-amd64.tar.gz", "dws-darwin-arm64.tar.gz", "dws-linux-amd64.tar.gz", "dws-linux-arm64.tar.gz", "dws-skills.zip", ] (output / "checksums.txt").write_text( "".join(("a" * 64) + " " + asset + "\n" for asset in assets), encoding="utf-8", ) record("github-download-checksums") raise SystemExit(0) if not args or args[0] != "api": raise SystemExit("unsupported gh invocation: " + " ".join(args)) method = value_after("--method", "GET") skip = {"-H", "--method", "-f", "-F", "--input", "--jq"} endpoint = "" i = 1 while i < len(args): if args[i] in skip: i += 2 continue if args[i].startswith("repos/"): endpoint = args[i] break i += 1 jq = value_after("--jq") if endpoint.endswith("/git/ref/heads/main"): payload = {"object": {"sha": os.environ["GITHUB_SHA"]}} elif "/releases/tags/" in endpoint: version = endpoint.rsplit("/", 1)[1] path = state / f"release-{version}.json" if not path.exists(): not_found() payload = json.loads(path.read_text(encoding="utf-8")) elif endpoint.endswith("/releases/latest"): payload = {"tag_name": (state / "latest").read_text(encoding="utf-8").strip()} elif "/git/ref/tags/withdrawn/" in endpoint: ref_path = state / "tombstone-ref" if not ref_path.exists(): not_found() payload = {"object": {"sha": ref_path.read_text(encoding="utf-8").strip()}} elif endpoint.endswith("/git/ref/tags/v1.0.52") and method == "GET": tag_path = state / "github-tag-v1.0.52" if not tag_path.exists(): not_found() payload = {"object": {"sha": tag_path.read_text(encoding="utf-8").strip()}} elif "/git/tags/" in endpoint and method == "GET": payload = { "tag": "withdrawn/v1.0.52", "message": (state / "tombstone-message").read_text(encoding="utf-8"), "object": { "type": "commit", "sha": (state / "tombstone-target").read_text(encoding="utf-8").strip(), }, } elif endpoint.endswith("/git/tags") and method == "POST": fields = {} for index, arg in enumerate(args): if arg == "-f": key, value = args[index + 1].split("=", 1) fields[key] = value (state / "tombstone-message").write_text(fields["message"], encoding="utf-8") (state / "tombstone-target").write_text(fields["object"] + "\n", encoding="utf-8") payload = {"sha": "a" * 40} record("tombstone-object") elif endpoint.endswith("/git/refs") and method == "POST": (state / "tombstone-ref").write_text("a" * 40 + "\n", encoding="utf-8") payload = {"ref": "refs/tags/withdrawn/v1.0.52", "object": {"sha": "a" * 40}} record("tombstone-ref") elif endpoint.endswith("/releases/52") and method == "DELETE": release_path = state / "release-v1.0.52.json" if not release_path.exists(): not_found() release_path.unlink() (state / "latest").write_text("v1.0.51\n", encoding="utf-8") payload = {} record("github-delete-release") elif endpoint.endswith("/git/refs/tags/v1.0.52") and method == "DELETE": tag_path = state / "github-tag-v1.0.52" if not tag_path.exists(): not_found() tag_path.unlink() subprocess.run( ["git", f"--git-dir={os.environ['ORIGIN_GIT']}", "update-ref", "-d", "refs/tags/v1.0.52"], check=True, ) payload = {} record("github-delete-tag") elif "/releases/" in endpoint and method == "PATCH": release_id = endpoint.rsplit("/", 1)[1] if release_id == "52": path = state / "release-v1.0.52.json" data = json.loads(path.read_text(encoding="utf-8")) input_path = value_after("--input") patch = json.loads(pathlib.Path(input_path).read_text(encoding="utf-8")) data.update({"name": patch["name"], "body": patch["body"]}) path.write_text(json.dumps(data), encoding="utf-8") record("github-withdraw") payload = {} else: raise SystemExit("unsupported gh api endpoint: " + endpoint + " " + method) if jq == ".object.sha": print(payload["object"]["sha"]) elif jq == ".tag_name": print(payload["tag_name"]) elif jq == ".sha": print(payload["sha"]) else: print(json.dumps(payload)) `), 0o755) mustWriteFile(t, filepath.Join(fakeBin, "npm"), []byte(`#!/usr/bin/env python3 import os import pathlib import sys state = pathlib.Path(os.environ["MOCK_STATE"]) log = pathlib.Path(os.environ["CALL_LOG"]) args = sys.argv[1:] def record(value): with log.open("a", encoding="utf-8") as handle: handle.write(value + "\n") if args[0] == "view": spec = args[1] field = args[2] if field == "version": print(spec.rsplit("@", 1)[1]) elif field == "deprecated": path = state / "npm-deprecated" if path.exists() and spec.endswith("@1.0.52"): print(path.read_text(encoding="utf-8").strip()) elif field == "dist-tags.latest": print((state / "npm-latest").read_text(encoding="utf-8").strip()) else: raise SystemExit("unsupported npm view field: " + field) elif args[0] == "deprecate": (state / "npm-deprecated").write_text(args[2] + "\n", encoding="utf-8") record("npm-deprecate") elif args[:2] == ["dist-tag", "add"]: version = args[2].rsplit("@", 1)[1] (state / "npm-latest").write_text(version + "\n", encoding="utf-8") record("npm-pointer") else: raise SystemExit("unsupported npm invocation: " + " ".join(args)) `), 0o755) mustWriteFile(t, filepath.Join(fakeBin, "ossutil"), []byte(`#!/usr/bin/env python3 import os import pathlib import sys state = pathlib.Path(os.environ["MOCK_STATE"]) log = pathlib.Path(os.environ["CALL_LOG"]) args = sys.argv[1:] if args[0] == "cp": source, target = args[-2:] if source.startswith("oss://"): if source.endswith("/latest.txt"): stored = state / "oss-latest" else: stored = state / ("oss-object-" + source.rsplit("/", 1)[1]) pathlib.Path(target).write_bytes(stored.read_bytes()) else: if target.endswith("/latest.txt"): stored = state / "oss-latest" else: stored = state / ("oss-object-" + target.rsplit("/", 1)[1]) stored.write_bytes(pathlib.Path(source).read_bytes()) elif args[0] == "rm": (state / "oss-removed").write_text("yes\n", encoding="utf-8") with log.open("a", encoding="utf-8") as handle: handle.write("oss-remove\n") elif args[0] == "ls": raise SystemExit(0) else: raise SystemExit("unsupported ossutil invocation: " + " ".join(args)) `), 0o755) mustWriteFile(t, filepath.Join(fakeBin, "curl"), []byte(`#!/usr/bin/env python3 import json import os import pathlib import sys state = pathlib.Path(os.environ["MOCK_STATE"]) log = pathlib.Path(os.environ["CALL_LOG"]) args = sys.argv[1:] url = args[-1] version = "v1.0.51" if "v1.0.51" in url else "v1.0.52" release = state / ("gitee-release-" + version) if "-X" in args and args[args.index("-X") + 1] == "DELETE": (state / "gitee-release-v1.0.52").unlink(missing_ok=True) with log.open("a", encoding="utf-8") as handle: handle.write("gitee-delete-release\n") elif "/releases/tags/" in url: output = pathlib.Path(args[args.index("-o") + 1]) if release.exists(): release_id = 751 if version == "v1.0.51" else 752 output.write_text(json.dumps({"id": release_id}), encoding="utf-8") print("200", end="") else: output.write_text('{"message":"Not Found"}', encoding="utf-8") print("404", end="") else: raise SystemExit(22) `), 0o755) }