package scripts_test import ( "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" "testing" "time" ) var requiredGiteeAssets = []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", } func TestSyncGiteeTagSkipsAnAlreadyAlignedImmutableTag(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "sync-gitee-tag.sh")) root := t.TempDir() workDir := filepath.Join(root, "work") remoteDir := filepath.Join(root, "gitee.git") seedTaggedRepository(t, workDir, "v1.2.3") mustRun(t, root, "git", "init", "--bare", remoteDir) firstOutput := runGiteeTagSync(t, scriptPath, workDir, remoteDir, "v1.2.3", true) if !strings.Contains(firstOutput, "Gitee tag v1.2.3 is aligned") { t.Fatalf("first tag sync did not report alignment:\n%s", firstOutput) } // Reject every subsequent push. A truly idempotent second run succeeds only // if it observes the aligned peeled commit and skips git push entirely. mustWriteFile(t, filepath.Join(remoteDir, "hooks", "pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0o755) secondOutput := runGiteeTagSync(t, scriptPath, workDir, remoteDir, "v1.2.3", true) if !strings.Contains(secondOutput, "already aligned") || !strings.Contains(secondOutput, "skip push") { t.Fatalf("second tag sync did not take the idempotent path:\n%s", secondOutput) } } func TestSyncGiteeTagRefusesToMoveAnExistingTag(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "sync-gitee-tag.sh")) root := t.TempDir() workDir := filepath.Join(root, "work") remoteDir := filepath.Join(root, "gitee.git") seedTaggedRepository(t, workDir, "v1.2.3") mustRun(t, root, "git", "init", "--bare", remoteDir) mustRun(t, workDir, "git", "push", remoteDir, "refs/tags/v1.2.3:refs/tags/v1.2.3") originalCommit := peeledRemoteTag(t, workDir, remoteDir, "v1.2.3") mustRun(t, workDir, "git", "tag", "-d", "v1.2.3") mustWriteFile(t, filepath.Join(workDir, "payload.txt"), []byte("new release bytes\n"), 0o644) mustRun(t, workDir, "git", "add", "payload.txt") mustRun(t, workDir, "git", "commit", "-m", "new release commit") mustRun(t, workDir, "git", "tag", "-a", "v1.2.3", "-m", "v1.2.3 moved locally") output := runGiteeTagSync(t, scriptPath, workDir, remoteDir, "v1.2.3", false) if !strings.Contains(output, "refusing to move it") { t.Fatalf("conflicting tag sync did not fail closed:\n%s", output) } if got := peeledRemoteTag(t, workDir, remoteDir, "v1.2.3"); got != originalCommit { t.Fatalf("remote tag moved from %s to %s", originalCommit, got) } } func TestSyncGiteeTagRejectsAMissingLocalTagWithoutInventingACommit(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "sync-gitee-tag.sh")) root := t.TempDir() workDir := filepath.Join(root, "work") remoteDir := filepath.Join(root, "gitee.git") seedTaggedRepository(t, workDir, "v1.2.3") mustRun(t, root, "git", "init", "--bare", remoteDir) output := runGiteeTagSync(t, scriptPath, workDir, remoteDir, "v9.9.9", false) if !strings.Contains(output, "could not resolve local release tag v9.9.9") { t.Fatalf("missing tag did not report a local resolution failure:\n%s", output) } for _, misleading := range []string{"refusing to move it", "v9.9.9^{commit}"} { if strings.Contains(output, misleading) { t.Fatalf("missing tag output contains misleading commit information %q:\n%s", misleading, output) } } } func TestSyncGiteeTagReportsFetchFailureWhenItCannotRecoverAMissingTag(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "sync-gitee-tag.sh")) root := t.TempDir() workDir := filepath.Join(root, "work") remoteDir := filepath.Join(root, "gitee.git") seedTaggedRepository(t, workDir, "v1.2.3") mustRun(t, root, "git", "init", "--bare", remoteDir) output := runGiteeTagSync( t, scriptPath, workDir, remoteDir, "v9.9.9", false, "GITEE_SOURCE_REMOTE=missing-source-remote", ) if !strings.Contains(output, "source tag fetch failed") || !strings.Contains(output, "local release tag v9.9.9 could not be resolved") { t.Fatalf("fetch failure did not preserve the actionable cause:\n%s", output) } } func TestSyncGiteeTagHonorsItsDeadlineDuringFetch(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "sync-gitee-tag.sh")) root := t.TempDir() workDir := filepath.Join(root, "work") remoteDir := filepath.Join(root, "gitee.git") seedTaggedRepository(t, workDir, "v1.2.3") mustRun(t, root, "git", "init", "--bare", remoteDir) realGit, err := exec.LookPath("git") if err != nil { t.Fatalf("LookPath(git) error = %v", err) } wrapperDir := t.TempDir() mustWriteFile(t, filepath.Join(wrapperDir, "git"), []byte(fmt.Sprintf(`#!/bin/sh if [ "$1" = "fetch" ]; then sleep 5 exit 1 fi exec %q "$@" `, realGit)), 0o755) started := time.Now() output := runGiteeTagSync( t, scriptPath, workDir, remoteDir, "v1.2.3", false, "PATH="+wrapperDir+string(os.PathListSeparator)+os.Getenv("PATH"), "GITEE_SOURCE_REMOTE=origin", "GITEE_TAG_TIMEOUT_SECONDS=1", "GITEE_GIT_TIMEOUT_SECONDS=10", ) elapsed := time.Since(started) if elapsed > 6*time.Second { t.Fatalf("tag deadline took %s, want no more than 6s\noutput:\n%s", elapsed, output) } if !strings.Contains(output, "deadline exhausted") { t.Fatalf("tag deadline failure was not reported clearly:\n%s", output) } } func TestSyncGiteeTagUsesAskpassWithoutEmbeddingCredentials(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "sync-gitee-tag.sh")) root := t.TempDir() workDir := filepath.Join(root, "work") seedTaggedRepository(t, workDir, "v1.2.3") expectedCommit := strings.TrimSpace(mustOutput(t, workDir, "git", "rev-parse", "v1.2.3^{commit}")) realGit, err := exec.LookPath("git") if err != nil { t.Fatalf("LookPath(git) error = %v", err) } wrapperDir := t.TempDir() logPath := filepath.Join(root, "git.log") pushMarker := filepath.Join(root, "pushed") wrapper := fmt.Sprintf(`#!/bin/sh set -eu case "$1" in ls-remote) printf '%%s\n' "$*" >>"$GIT_TEST_LOG" case " $* " in *"$DWS_GITEE_TOKEN"*) exit 91 ;; esac [ "${GIT_TERMINAL_PROMPT:-}" = "0" ] [ -x "${GIT_ASKPASS:-}" ] [ "$("$GIT_ASKPASS" "Username for https://gitee.com")" = "$DWS_GITEE_USER" ] [ "$("$GIT_ASKPASS" "Password for https://gitee.com")" = "$DWS_GITEE_TOKEN" ] if [ -f "$GIT_PUSH_MARKER" ]; then printf '%%s\trefs/tags/v1.2.3^{}\n' "$EXPECTED_COMMIT" fi ;; push) printf '%%s\n' "$*" >>"$GIT_TEST_LOG" case " $* " in *"$DWS_GITEE_TOKEN"*) exit 92 ;; esac [ "$2" = "https://gitee.com/owner/repo.git" ] : >"$GIT_PUSH_MARKER" ;; *) exec %q "$@" ;; esac `, realGit) mustWriteFile(t, filepath.Join(wrapperDir, "git"), []byte(wrapper), 0o755) cmd := exec.Command("bash", scriptPath) cmd.Dir = workDir cmd.Env = append(os.Environ(), "PATH="+wrapperDir+string(os.PathListSeparator)+os.Getenv("PATH"), "VERSION=v1.2.3", "GITEE_USER=test-user", "GITEE_TOKEN=sensitive-token", "GITEE_REPO=owner/repo", "GITEE_SOURCE_REMOTE=", "GITEE_GIT_REMOTE=", "GITEE_PUBLIC_GIT_REMOTE=", "GITEE_TAG_VERIFY_ATTEMPTS=1", "GITEE_TAG_VERIFY_DELAY=0", "GITEE_GIT_TIMEOUT_SECONDS=10", "GIT_TEST_LOG="+logPath, "GIT_PUSH_MARKER="+pushMarker, "EXPECTED_COMMIT="+expectedCommit, ) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("sync-gitee-tag.sh error = %v\noutput:\n%s", err, output) } logData, err := os.ReadFile(logPath) if err != nil { t.Fatalf("ReadFile(git log) error = %v", err) } if strings.Contains(string(logData), "sensitive-token") { t.Fatalf("Gitee token leaked into git argv:\n%s", logData) } if !strings.Contains(string(logData), "push https://gitee.com/owner/repo.git") { t.Fatalf("tag push did not use the credential-free remote:\n%s", logData) } } func TestSyncGiteeTagAndReleasePathShareOneOverallDeadline(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "sync-to-gitee.sh")) root := t.TempDir() workDir := filepath.Join(root, "work") remoteDir := filepath.Join(root, "gitee.git") seedTaggedRepository(t, workDir, "v1.2.3") mustRun(t, root, "git", "init", "--bare", remoteDir) mustRun(t, workDir, "git", "push", remoteDir, "refs/tags/v1.2.3:refs/tags/v1.2.3") distDir := seedGiteeDist(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { time.Sleep(10 * time.Second) http.Error(w, "slow Gitee API", http.StatusGatewayTimeout) })) defer server.Close() cmd := exec.Command("bash", scriptPath) cmd.Dir = workDir cmd.Env = append(os.Environ(), "VERSION=v1.2.3", "DIST_DIR="+distDir, "GITEE_API="+server.URL, "GITEE_TOKEN=test-token", "GITEE_USER=test-user", "GITEE_REPO=owner/repo", "GITEE_SOURCE_REMOTE=", "GITEE_GIT_REMOTE="+remoteDir, "GITEE_PUBLIC_GIT_REMOTE="+remoteDir, "GITEE_SYNC_TIMEOUT_SECONDS=6", "GITEE_TAG_TIMEOUT_SECONDS=3", "GITEE_GIT_TIMEOUT_SECONDS=3", "GITEE_RELEASE_LOOKUP_MAX_TIME=10", "GITEE_RELEASE_CREATE_MAX_TIME=10", "GITEE_RECONCILE_TIMEOUT_SECONDS=10", ) started := time.Now() output, err := cmd.CombinedOutput() elapsed := time.Since(started) if err == nil { t.Fatalf("full Gitee sync unexpectedly succeeded after its deadline:\n%s", output) } if elapsed > 12*time.Second { t.Fatalf("full Gitee sync deadline took %s, want no more than 12s\noutput:\n%s", elapsed, output) } if !strings.Contains(string(output), "overall Gitee sync deadline exhausted") { t.Fatalf("full-path deadline failure was not reported clearly:\n%s", output) } } func TestSyncToGiteeRunsTagReleaseCreationAndAssetReconciliationWithinOneBudget(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "sync-to-gitee.sh")) root := t.TempDir() workDir := filepath.Join(root, "work") remoteDir := filepath.Join(root, "gitee.git") seedTaggedRepository(t, workDir, "v1.2.3") mustRun(t, root, "git", "init", "--bare", remoteDir) mustRun(t, workDir, "git", "push", remoteDir, "refs/tags/v1.2.3:refs/tags/v1.2.3") distDir := seedGiteeDist(t) fake := newFakeGiteeRelease(false, false) var releaseMu sync.Mutex releaseLookups := 0 releaseCreates := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/repos/") { if got := r.Header.Get("Authorization"); got != "token test-token" { t.Errorf("Authorization header = %q, want token authentication", got) } if got := r.URL.Query().Get("access_token"); got != "" { t.Errorf("Gitee token leaked in query string: %q", got) } } else if got := r.Header.Get("Authorization"); got != "" { t.Errorf("token forwarded to asset download URL: %q", got) } switch { case r.Method == http.MethodGet && r.URL.Path == "/repos/owner/repo/releases/tags/v1.2.3": releaseMu.Lock() releaseLookups++ lookupAttempt := releaseLookups releaseMu.Unlock() if lookupAttempt == 1 { http.Error(w, "temporary Gitee outage", http.StatusServiceUnavailable) return } http.NotFound(w, r) case r.Method == http.MethodPost && r.URL.Path == "/repos/owner/repo/releases": if err := r.ParseMultipartForm(1 << 20); err != nil { t.Errorf("ParseMultipartForm(release create) error = %v", err) } if got := r.FormValue("access_token"); got != "" { t.Errorf("Gitee token leaked in release form: %q", got) } releaseMu.Lock() releaseCreates++ releaseMu.Unlock() w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"metadata":{"id":999},"id":1}`) default: fake.ServeHTTP(w, r) } })) defer server.Close() cmd := exec.Command("bash", scriptPath) cmd.Dir = workDir cmd.Env = append(os.Environ(), "VERSION=v1.2.3", "DIST_DIR="+distDir, "GITEE_API="+server.URL, "GITEE_TOKEN=test-token", "GITEE_USER=test-user", "GITEE_REPO=owner/repo", "GITEE_SOURCE_REMOTE=", "GITEE_GIT_REMOTE="+remoteDir, "GITEE_PUBLIC_GIT_REMOTE="+remoteDir, "GITEE_SYNC_TIMEOUT_SECONDS=30", "GITEE_TAG_TIMEOUT_SECONDS=5", "GITEE_GIT_TIMEOUT_SECONDS=3", "GITEE_RELEASE_LOOKUP_MAX_TIME=2", "GITEE_RELEASE_LOOKUP_RETRIES=2", "GITEE_RELEASE_LOOKUP_RETRY_DELAY=0", "GITEE_RELEASE_CREATE_MAX_TIME=2", "GITEE_RECONCILE_TIMEOUT_SECONDS=20", "GITEE_CHILD_DEADLINE_RESERVE_SECONDS=1", "GITEE_CURL_CONNECT_TIMEOUT=2", "GITEE_CURL_MAX_TIME=2", "GITEE_UPLOAD_MAX_TIME=2", "GITEE_UPLOAD_RETRIES=1", "GITEE_UPLOAD_RETRY_DELAY=0", "GITEE_EXISTING_VERIFY_ATTEMPTS=1", "GITEE_POST_UPLOAD_VERIFY_ATTEMPTS=1", "GITEE_VERIFY_RETRY_DELAY=0", ) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("full Gitee sync error = %v\noutput:\n%s", err, output) } if !strings.Contains(string(output), "all 8 verified") { t.Fatalf("full Gitee sync did not reconcile every release asset:\n%s", output) } releaseMu.Lock() if releaseLookups != 2 || releaseCreates != 1 { t.Errorf("release lookup/create calls = %d/%d, want 2/1", releaseLookups, releaseCreates) } releaseMu.Unlock() fake.mu.Lock() defer fake.mu.Unlock() for _, name := range requiredGiteeAssets { if got := fake.uploadCalls[name]; got != 1 { t.Errorf("upload calls for %s = %d, want 1", name, got) } } } func TestReconcileGiteeAssetsRecoversACommittedUploadWithLostResponse(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh")) distDir := seedGiteeDist(t) fake := newFakeGiteeRelease(true, false) fake.listErrorResponsesAfterUpload = 1 fake.listEmptyResponsesAfterUpload = 1 server := httptest.NewServer(fake) defer server.Close() cmd := exec.Command("bash", scriptPath) cmd.Env = append(giteeAssetEnv(distDir, server.URL, "2"), "GITEE_POST_UPLOAD_VERIFY_ATTEMPTS=2", ) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("reconcile-gitee-assets.sh error = %v\noutput:\n%s", err, output) } if !strings.Contains(string(output), "appeared with the expected SHA after a lost upload response") { t.Fatalf("sync did not recognize the committed upload after the response was lost:\n%s", output) } if !strings.Contains(string(output), "all 8 verified") { t.Fatalf("sync did not report complete final verification:\n%s", output) } secondCmd := exec.Command("bash", scriptPath) secondCmd.Env = giteeAssetEnv(distDir, server.URL, "2") secondOutput, err := secondCmd.CombinedOutput() if err != nil { t.Fatalf("idempotent reconcile error = %v\noutput:\n%s", err, secondOutput) } if !strings.Contains(string(secondOutput), "uploaded 0, replaced 0, skipped 8") { t.Fatalf("second reconcile did not skip all verified assets:\n%s", secondOutput) } fake.mu.Lock() defer fake.mu.Unlock() for _, name := range requiredGiteeAssets { if got := fake.uploadCalls[name]; got != 1 { t.Errorf("upload calls for %s = %d, want 1", name, got) } } } func TestReconcileGiteeAssetsDoesNotReplayAnAmbiguousUpload(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh")) distDir := seedGiteeDist(t) fake := newFakeGiteeRelease(true, false) fake.listEmptyResponsesAfterUpload = 3 server := httptest.NewServer(fake) defer server.Close() cmd := exec.Command("bash", scriptPath) cmd.Env = append(giteeAssetEnv(distDir, server.URL, "2"), "GITEE_POST_UPLOAD_VERIFY_ATTEMPTS=2", ) output, err := cmd.CombinedOutput() if err == nil { t.Fatalf("ambiguous upload unexpectedly succeeded:\n%s", output) } if !strings.Contains(string(output), "refusing to replay POST") { t.Fatalf("ambiguous upload did not fail closed:\n%s", output) } fake.mu.Lock() defer fake.mu.Unlock() for _, name := range requiredGiteeAssets { if got := fake.uploadCalls[name]; got != 1 { t.Errorf("upload calls for %s = %d, want exactly 1", name, got) } } } func TestReconcileGiteeAssetsRetriesATransientListOutage(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh")) distDir := seedGiteeDist(t) fake := newFakeGiteeRelease(false, false) fake.listFailuresRemaining = 2 server := httptest.NewServer(fake) defer server.Close() cmd := exec.Command("bash", scriptPath) cmd.Env = append(giteeAssetEnv(distDir, server.URL, "1"), "GITEE_LIST_RETRIES=3", "GITEE_LIST_RETRY_DELAY=1", "GITEE_LIST_RETRY_WINDOW_SECONDS=4", ) started := time.Now() output, err := cmd.CombinedOutput() elapsed := time.Since(started) if err != nil { t.Fatalf("transient-list reconciliation error = %v\noutput:\n%s", err, output) } if !strings.Contains(string(output), "Gitee attachment list attempt 2/3 failed; retrying in 1s") { t.Fatalf("transient list failures were not retried visibly:\n%s", output) } if elapsed < 1500*time.Millisecond { t.Fatalf("transient list retry elapsed = %s, want production-style backoff", elapsed) } if !strings.Contains(string(output), "all 8 verified") { t.Fatalf("transient-list reconciliation did not verify every asset:\n%s", output) } fake.mu.Lock() defer fake.mu.Unlock() if fake.listFailuresRemaining != 0 { t.Fatalf("unconsumed list failures = %d, want 0", fake.listFailuresRemaining) } for _, name := range requiredGiteeAssets { if got := fake.uploadCalls[name]; got != 1 { t.Errorf("upload calls for %s = %d, want 1", name, got) } } } func TestReconcileGiteeAssetsDoesNotBurstRetriesInTheFinalWindowSecond(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh")) distDir := seedGiteeDist(t) fake := newFakeGiteeRelease(false, false) fake.listFailuresRemaining = 1000 server := httptest.NewServer(fake) defer server.Close() cmd := exec.Command("bash", scriptPath) cmd.Env = append(giteeAssetEnv(distDir, server.URL, "1"), "GITEE_LIST_RETRIES=24", "GITEE_LIST_RETRY_DELAY=1", "GITEE_LIST_RETRY_WINDOW_SECONDS=1", ) output, err := cmd.CombinedOutput() if err == nil { t.Fatalf("permanent list outage unexpectedly succeeded:\n%s", output) } fake.mu.Lock() defer fake.mu.Unlock() if fake.listCalls > len(requiredGiteeAssets)+1 { t.Fatalf("list calls = %d, want at most one per asset plus final verification", fake.listCalls) } } func TestReconcileGiteeAssetsDiscardsPartialOutputFromAMalformedList(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh")) distDir := seedGiteeDist(t) fake := newFakeGiteeRelease(false, false) existingName := requiredGiteeAssets[0] existingData, err := os.ReadFile(filepath.Join(distDir, existingName)) if err != nil { t.Fatalf("ReadFile(%s) error = %v", existingName, err) } fake.assets[1] = fakeGiteeAsset{id: 1, name: existingName, data: existingData} fake.nextID = 2 fake.malformedListResponsesRemaining = 1 server := httptest.NewServer(fake) defer server.Close() cmd := exec.Command("bash", scriptPath) cmd.Env = append(giteeAssetEnv(distDir, server.URL, "1"), "GITEE_LIST_RETRIES=2", "GITEE_LIST_RETRY_DELAY=0", "GITEE_LIST_RETRY_WINDOW_SECONDS=2", ) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("malformed-list reconciliation error = %v\noutput:\n%s", err, output) } if !strings.Contains(string(output), existingName+" already correct on Gitee") { t.Fatalf("partial failed-list output manufactured a stale or duplicate asset:\n%s", output) } if !strings.Contains(string(output), "all 8 verified") { t.Fatalf("malformed-list reconciliation did not verify every asset:\n%s", output) } fake.mu.Lock() defer fake.mu.Unlock() if got := fake.uploadCalls[existingName]; got != 0 { t.Errorf("upload calls for existing %s = %d, want 0", existingName, got) } } func TestReconcileGiteeAssetsDisablesExpectContinueForLargeUploads(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh")) distDir := seedGiteeDist(t) mustWriteFile( t, filepath.Join(distDir, requiredGiteeAssets[0]), []byte(strings.Repeat("x", 2<<20)), 0o644, ) fake := newFakeGiteeRelease(false, false) fake.rejectExpectContinue = true server := httptest.NewServer(fake) defer server.Close() cmd := exec.Command("bash", scriptPath) cmd.Env = giteeAssetEnv(distDir, server.URL, "1") output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("large-asset reconciliation error = %v\noutput:\n%s", err, output) } if !strings.Contains(string(output), "all 8 verified") { t.Fatalf("large-asset reconciliation did not verify every asset:\n%s", output) } } func TestReconcileGiteeAssetsFailsWhenAnyUploadIsMissing(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh")) distDir := seedGiteeDist(t) fake := newFakeGiteeRelease(false, true) server := httptest.NewServer(fake) defer server.Close() cmd := exec.Command("bash", scriptPath) cmd.Env = giteeAssetEnv(distDir, server.URL, "1") output, err := cmd.CombinedOutput() if err == nil { t.Fatalf("reconcile-gitee-assets.sh unexpectedly succeeded with failed uploads:\n%s", output) } if !strings.Contains(string(output), "reconciliation finished with") { t.Fatalf("failed reconciliation did not report a hard final error:\n%s", output) } fake.mu.Lock() defer fake.mu.Unlock() for _, name := range requiredGiteeAssets { if got := fake.uploadCalls[name]; got != 1 { t.Errorf("upload calls for %s = %d, want exactly the configured single attempt", name, got) } } } func TestReconcileGiteeAssetsHonorsTheOverallDeadline(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh")) distDir := seedGiteeDist(t) fake := newFakeGiteeRelease(false, false) fake.uploadDelay = 5 * time.Second server := httptest.NewServer(fake) defer server.Close() cmd := exec.Command("bash", scriptPath) cmd.Env = append(giteeAssetEnv(distDir, server.URL, "2"), "GITEE_ASSET_TIMEOUT_SECONDS=1", "GITEE_OVERALL_TIMEOUT_SECONDS=3", "GITEE_UPLOAD_MAX_TIME=10", ) started := time.Now() output, err := cmd.CombinedOutput() elapsed := time.Since(started) if err == nil { t.Fatalf("reconcile unexpectedly succeeded after the overall deadline:\n%s", output) } if elapsed > 8*time.Second { t.Fatalf("overall deadline took %s, want no more than 8s\noutput:\n%s", elapsed, output) } if !strings.Contains(string(output), "deadline") { t.Fatalf("deadline failure was not reported clearly:\n%s", output) } } func TestGiteeReleaseWorkflowUsesImmutableTagsAndBoundedRetryBudget(t *testing.T) { mirrorPath := mustAbs(t, filepath.Join("..", "..", ".github", "workflows", "mirror-to-gitee.yml")) mirrorData, err := os.ReadFile(mirrorPath) if err != nil { t.Fatalf("ReadFile(%s) error = %v", mirrorPath, err) } mirror := string(mirrorData) if strings.Contains(mirror, "tags:") || strings.Contains(mirror, "sync-gitee-tag.sh") { t.Fatal("code mirror must not publish release tags outside the guarded release workflow") } for _, forbidden := range []string{ `git push --force "$REMOTE" "refs/tags/`, `git push --force --tags`, } { if strings.Contains(mirror, forbidden) { t.Fatalf("tag workflow still contains unsafe force push %q", forbidden) } } manualPath := mustAbs(t, filepath.Join("..", "..", ".github", "workflows", "sync-release-to-gitee.yml")) if _, err := os.Stat(manualPath); !os.IsNotExist(err) { t.Fatalf("manual Gitee release workflow must be removed, stat error = %v", err) } syncPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "sync-to-gitee.sh")) syncData, err := os.ReadFile(syncPath) if err != nil { t.Fatalf("ReadFile(%s) error = %v", syncPath, err) } syncScript := string(syncData) syncSeconds := shellDefaultInt(t, syncScript, "GITEE_SYNC_TIMEOUT_SECONDS") tagSeconds := shellDefaultInt(t, syncScript, "GITEE_TAG_TIMEOUT_SECONDS") lookupSeconds := shellDefaultInt(t, syncScript, "GITEE_RELEASE_LOOKUP_MAX_TIME") createSeconds := shellDefaultInt(t, syncScript, "GITEE_RELEASE_CREATE_MAX_TIME") reconcileSeconds := shellDefaultInt(t, syncScript, "GITEE_RECONCILE_TIMEOUT_SECONDS") reconcilerPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh")) reconcilerData, err := os.ReadFile(reconcilerPath) if err != nil { t.Fatalf("ReadFile(%s) error = %v", reconcilerPath, err) } reconciler := string(reconcilerData) for _, forbidden := range []string{"?access_token=", `-F "access_token=`} { if strings.Contains(syncScript, forbidden) || strings.Contains(reconciler, forbidden) { t.Fatalf("Gitee API scripts still expose tokens via %q", forbidden) } } if !strings.Contains(syncScript, "Authorization: token") || !strings.Contains(reconciler, "Authorization: token") { t.Fatal("Gitee API scripts must authenticate with an Authorization header") } perAssetSeconds := shellDefaultInt(t, reconciler, "GITEE_ASSET_TIMEOUT_SECONDS") overallSeconds := shellDefaultInt(t, reconciler, "GITEE_OVERALL_TIMEOUT_SECONDS") listSeconds := shellDefaultInt(t, reconciler, "GITEE_LIST_MAX_TIME") listRetries := shellDefaultInt(t, reconciler, "GITEE_LIST_RETRIES") listRetryDelay := shellDefaultInt(t, reconciler, "GITEE_LIST_RETRY_DELAY") listRetryWindow := shellDefaultInt(t, reconciler, "GITEE_LIST_RETRY_WINDOW_SECONDS") uploadSeconds := shellDefaultInt(t, reconciler, "GITEE_UPLOAD_MAX_TIME") verifySeconds := shellDefaultInt(t, reconciler, "GITEE_VERIFY_MAX_TIME") postUploadVerifyAttempts := shellDefaultInt(t, reconciler, "GITEE_POST_UPLOAD_VERIFY_ATTEMPTS") verifyRetryDelay := shellDefaultInt(t, reconciler, "GITEE_VERIFY_RETRY_DELAY") uploadRetryDelay := shellDefaultInt(t, reconciler, "GITEE_UPLOAD_RETRY_DELAY") const minimumLargeAssetUploadSeconds = 1200 if uploadSeconds < minimumLargeAssetUploadSeconds { t.Fatalf( "Gitee upload deadline = %ds, want at least %ds for near-10 MiB release assets", uploadSeconds, minimumLargeAssetUploadSeconds, ) } const minimumTransientListOutageSeconds = 300 if listRetryWindow < minimumTransientListOutageSeconds { t.Fatalf( "Gitee list recovery budget = %ds, want at least %ds for a transient API outage", listRetryWindow, minimumTransientListOutageSeconds, ) } if (listRetries-1)*listRetryDelay < listRetryWindow { t.Fatalf( "%d Gitee list attempts with %ds delay cannot span the configured %ds retry window after fast failures", listRetries, listRetryDelay, listRetryWindow, ) } completeListRecoveryBudget := listRetryWindow oneSlowSuccessBudget := 2*completeListRecoveryBudget + uploadSeconds + verifySeconds if oneSlowSuccessBudget > perAssetSeconds { t.Fatalf( "one complete slow upload budget = %ds, exceeds per-asset deadline %ds", oneSlowSuccessBudget, perAssetSeconds, ) } fastFailureRetryBudget := uploadSeconds + (postUploadVerifyAttempts+3)*listSeconds + verifySeconds + (postUploadVerifyAttempts-1)*verifyRetryDelay + uploadRetryDelay fastFailureRetryBudget += 2 * (completeListRecoveryBudget - listSeconds) if fastFailureRetryBudget > perAssetSeconds { t.Fatalf( "fast-failure retry budget = %ds, exceeds per-asset deadline %ds", fastFailureRetryBudget, perAssetSeconds, ) } finalListSeconds := completeListRecoveryBudget completeAssetBudget := perAssetSeconds*len(requiredGiteeAssets) + finalListSeconds if completeAssetBudget > overallSeconds { t.Fatalf( "complete asset budget = %ds (%d assets x %ds + %ds final list), exceeds overall deadline %ds", completeAssetBudget, len(requiredGiteeAssets), perAssetSeconds, finalListSeconds, overallSeconds, ) } if completeAssetBudget > reconcileSeconds || reconcileSeconds > overallSeconds { t.Fatalf( "asset budget nesting invalid: complete=%ds configured-reconcile=%ds reconciler-overall=%ds", completeAssetBudget, reconcileSeconds, overallSeconds, ) } childDeadlineReserveSeconds := shellDefaultInt(t, syncScript, "GITEE_CHILD_DEADLINE_RESERVE_SECONDS") completeSyncBudget := tagSeconds + lookupSeconds + createSeconds + reconcileSeconds + childDeadlineReserveSeconds if completeSyncBudget > syncSeconds { t.Fatalf( "complete sync budget = %ds (tag=%d + lookup=%d + create=%d + reconcile=%d + child reserve=%d), exceeds sync deadline %ds", completeSyncBudget, tagSeconds, lookupSeconds, createSeconds, reconcileSeconds, childDeadlineReserveSeconds, syncSeconds, ) } const syncStepReserveMinutes = 4 releasePath := mustAbs(t, filepath.Join("..", "..", ".github", "workflows", "release.yml")) releaseData, err := os.ReadFile(releasePath) if err != nil { t.Fatalf("ReadFile(%s) error = %v", releasePath, err) } assertWorkflowBudget( t, string(releaseData), "mirror-gitee-release:", []string{ "name: Check out sealed release source", "name: Check out trusted release tooling", "name: Fetch and verify sealed release tag", "name: Restore finalized distribution files", "name: Mirror release to Gitee (China)", }, 17, ) releaseSyncStepSeconds := workflowTimeoutMinutesAfter( t, string(releaseData), "mirror-gitee-release:", "name: Mirror release to Gitee (China)", ) * 60 if syncSeconds+syncStepReserveMinutes*60 > releaseSyncStepSeconds { t.Fatalf( "sync deadline %ds plus reserve exceeds release fallback step %ds", syncSeconds, releaseSyncStepSeconds, ) } assertWorkflowBudget( t, string(releaseData), "repair-channel:", []string{ "name: Check out trusted release tooling", "name: Validate repair version", "name: Verify immutable release authority", "name: Check out sealed release source", "name: Fetch and verify sealed release tag", "name: Require successful Release workflow delivery", "name: Download and verify immutable GitHub Release assets", "name: Mirror release to Gitee (China)", }, 5, ) repairSyncStepSeconds := workflowTimeoutMinutesAfter( t, string(releaseData), "repair-channel:", "name: Mirror release to Gitee (China)", ) * 60 if syncSeconds+syncStepReserveMinutes*60 > repairSyncStepSeconds { t.Fatalf( "sync deadline %ds plus reserve exceeds repair step %ds", syncSeconds, repairSyncStepSeconds, ) } localBuildPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "build-and-publish-gitee.sh")) localBuildData, err := os.ReadFile(localBuildPath) if err != nil { t.Fatalf("ReadFile(%s) error = %v", localBuildPath, err) } if !strings.Contains(string(localBuildData), "Direct Gitee release builds are disabled") { t.Fatal("local Gitee builds must stay disabled so only immutable GitHub assets are mirrored") } localPublishPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "publish-gitee-local.sh")) localPublishData, err := os.ReadFile(localPublishPath) if err != nil { t.Fatalf("ReadFile(%s) error = %v", localPublishPath, err) } if !strings.Contains(string(localPublishData), "Direct Gitee artifact publication is disabled") { t.Fatal("direct local Gitee publication must stay disabled") } tagSyncPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "sync-gitee-tag.sh")) tagSyncData, err := os.ReadFile(tagSyncPath) if err != nil { t.Fatalf("ReadFile(%s) error = %v", tagSyncPath, err) } tagSync := string(tagSyncData) if strings.Contains(tagSync, `https://${GITEE_USER}:${GITEE_TOKEN}@`) { t.Fatal("Gitee git remote must not embed credentials") } for _, required := range []string{"GIT_ASKPASS", "GIT_TERMINAL_PROMPT", "DWS_GITEE_TOKEN"} { if !strings.Contains(tagSync, required) { t.Fatalf("Gitee tag sync missing askpass safeguard %q", required) } } } func mustAbs(t *testing.T, path string) string { t.Helper() abs, err := filepath.Abs(path) if err != nil { t.Fatalf("Abs(%s) error = %v", path, err) } return abs } func seedTaggedRepository(t *testing.T, workDir, tag string) { t.Helper() mustRun(t, t.TempDir(), "git", "init", workDir) mustRun(t, workDir, "git", "config", "user.name", "Release Test") mustRun(t, workDir, "git", "config", "user.email", "release-test@example.com") mustWriteFile(t, filepath.Join(workDir, "payload.txt"), []byte("release bytes\n"), 0o644) mustRun(t, workDir, "git", "add", "payload.txt") mustRun(t, workDir, "git", "commit", "-m", "release commit") mustRun(t, workDir, "git", "tag", "-a", tag, "-m", tag) } func runGiteeTagSync(t *testing.T, scriptPath, workDir, remoteDir, tag string, wantSuccess bool, extraEnv ...string) string { t.Helper() cmd := exec.Command("bash", scriptPath) cmd.Dir = workDir cmd.Env = append(os.Environ(), "VERSION="+tag, "GITEE_SOURCE_REMOTE=", "GITEE_GIT_REMOTE="+remoteDir, "GITEE_PUBLIC_GIT_REMOTE="+remoteDir, "GITEE_TAG_VERIFY_ATTEMPTS=1", "GITEE_TAG_VERIFY_DELAY=0", "GITEE_GIT_TIMEOUT_SECONDS=10", ) cmd.Env = append(cmd.Env, extraEnv...) output, err := cmd.CombinedOutput() if wantSuccess && err != nil { t.Fatalf("sync-gitee-tag.sh error = %v\noutput:\n%s", err, output) } if !wantSuccess && err == nil { t.Fatalf("sync-gitee-tag.sh unexpectedly succeeded:\n%s", output) } return string(output) } func shellDefaultInt(t *testing.T, content, name string) int { t.Helper() prefix := name + `="${` + name + `:-` for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, prefix) || !strings.HasSuffix(line, `}"`) { continue } raw := strings.TrimSuffix(strings.TrimPrefix(line, prefix), `}"`) value, err := strconv.Atoi(raw) if err != nil { t.Fatalf("parse %s default %q: %v", name, raw, err) } return value } t.Fatalf("shell default %s not found", name) return 0 } func workflowTimeoutMinutesAfter(t *testing.T, content string, markers ...string) int { t.Helper() for _, marker := range markers { index := strings.Index(content, marker) if index < 0 { t.Fatalf("workflow marker %q not found", marker) } content = content[index+len(marker):] } for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) const prefix = "timeout-minutes:" if !strings.HasPrefix(line, prefix) { continue } raw := strings.TrimSpace(strings.TrimPrefix(line, prefix)) value, err := strconv.Atoi(raw) if err != nil { t.Fatalf("parse workflow timeout %q: %v", raw, err) } return value } t.Fatal("workflow timeout-minutes not found") return 0 } func assertWorkflowBudget(t *testing.T, content, jobMarker string, stepMarkers []string, reserveMinutes int) { t.Helper() jobMinutes := workflowTimeoutMinutesAfter(t, content, jobMarker) stepMinutes := 0 jobContent := content[strings.Index(content, jobMarker)+len(jobMarker):] for _, marker := range stepMarkers { stepMinutes += workflowTimeoutMinutesAfter(t, jobContent, marker) } if stepMinutes+reserveMinutes > jobMinutes { t.Fatalf( "workflow budget for %s = %d step minutes + %d reserve, exceeds %d-minute job", jobMarker, stepMinutes, reserveMinutes, jobMinutes, ) } } func peeledRemoteTag(t *testing.T, workDir, remoteDir, tag string) string { t.Helper() output := mustOutput(t, workDir, "git", "ls-remote", remoteDir, "refs/tags/"+tag+"^{}") fields := strings.Fields(output) if len(fields) != 2 { t.Fatalf("unexpected ls-remote output for %s: %q", tag, output) } return fields[0] } func seedGiteeDist(t *testing.T) string { t.Helper() distDir := t.TempDir() for _, name := range requiredGiteeAssets { mustWriteFile(t, filepath.Join(distDir, name), []byte("payload for "+name+"\n"), 0o644) } return distDir } func giteeAssetEnv(distDir, apiURL, retries string) []string { return append(os.Environ(), "DIST_DIR="+distDir, "GITEE_API="+apiURL, "GITEE_TOKEN=test-token", "GITEE_REPO=owner/repo", "GITEE_RELEASE_ID=1", "GITEE_CURL_CONNECT_TIMEOUT=2", "GITEE_CURL_MAX_TIME=2", "GITEE_LIST_RETRIES=2", "GITEE_LIST_RETRY_DELAY=0", "GITEE_LIST_RETRY_WINDOW_SECONDS=2", "GITEE_UPLOAD_MAX_TIME=2", "GITEE_UPLOAD_RETRIES="+retries, "GITEE_UPLOAD_RETRY_DELAY=0", "GITEE_EXISTING_VERIFY_ATTEMPTS=1", "GITEE_POST_UPLOAD_VERIFY_ATTEMPTS=1", "GITEE_VERIFY_RETRY_DELAY=0", ) } type fakeGiteeAsset struct { id int name string data []byte } type fakeGiteeRelease struct { mu sync.Mutex nextID int assets map[int]fakeGiteeAsset uploadCalls map[string]int dropFirstResponse bool droppedResponse bool failUploads bool uploadDelay time.Duration rejectExpectContinue bool listFailuresRemaining int malformedListResponsesRemaining int listErrorResponsesRemaining int listEmptyResponsesRemaining int listErrorResponsesAfterUpload int listEmptyResponsesAfterUpload int listCalls int } func newFakeGiteeRelease(dropFirstResponse, failUploads bool) *fakeGiteeRelease { return &fakeGiteeRelease{ nextID: 1, assets: make(map[int]fakeGiteeAsset), uploadCalls: make(map[string]int), dropFirstResponse: dropFirstResponse, failUploads: failUploads, } } func (f *fakeGiteeRelease) ServeHTTP(w http.ResponseWriter, r *http.Request) { const attachPath = "/repos/owner/repo/releases/1/attach_files" if strings.HasPrefix(r.URL.Path, "/repos/") { if got := r.Header.Get("Authorization"); got != "token test-token" { http.Error(w, "missing token authorization header", http.StatusUnauthorized) return } if r.URL.Query().Get("access_token") != "" { http.Error(w, "token leaked in query string", http.StatusBadRequest) return } } else if r.Header.Get("Authorization") != "" { http.Error(w, "token forwarded to asset download URL", http.StatusBadRequest) return } switch { case r.Method == http.MethodGet && r.URL.Path == attachPath: f.list(w, r) case r.Method == http.MethodPost && r.URL.Path == attachPath: f.upload(w, r) case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, attachPath+"/"): f.delete(w, strings.TrimPrefix(r.URL.Path, attachPath+"/")) case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/download/"): f.download(w, strings.TrimPrefix(r.URL.Path, "/download/")) default: http.NotFound(w, r) } } func (f *fakeGiteeRelease) list(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() f.listCalls++ if f.listFailuresRemaining > 0 { f.listFailuresRemaining-- http.Error(w, "temporary Gitee list outage", http.StatusServiceUnavailable) return } if f.listErrorResponsesRemaining > 0 { f.listErrorResponsesRemaining-- w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{}`) return } if f.listEmptyResponsesRemaining > 0 { f.listEmptyResponsesRemaining-- w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode([]any{}) return } baseURL := requestBaseURL(r) if f.malformedListResponsesRemaining > 0 { f.malformedListResponsesRemaining-- for _, asset := range f.assets { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode([]any{ map[string]any{ "id": asset.id, "name": asset.name, "browser_download_url": fmt.Sprintf("%s/download/%d", baseURL, asset.id), }, nil, }) return } http.Error(w, "malformed-list fixture requires one asset", http.StatusInternalServerError) return } rows := make([]map[string]any, 0, len(f.assets)) for _, asset := range f.assets { rows = append(rows, map[string]any{ "id": asset.id, "name": asset.name, "browser_download_url": fmt.Sprintf("%s/download/%d", baseURL, asset.id), }) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(rows) } func (f *fakeGiteeRelease) upload(w http.ResponseWriter, r *http.Request) { if f.rejectExpectContinue && r.Header.Get("Expect") != "" { http.Error(w, "Expect: 100-continue is not supported", http.StatusExpectationFailed) return } if err := r.ParseMultipartForm(32 << 20); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if r.FormValue("access_token") != "" { http.Error(w, "token leaked in upload form", http.StatusBadRequest) return } file, header, err := r.FormFile("file") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } defer file.Close() data, err := io.ReadAll(file) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if f.uploadDelay > 0 { time.Sleep(f.uploadDelay) } baseURL := requestBaseURL(r) f.mu.Lock() f.uploadCalls[header.Filename]++ if f.failUploads { f.mu.Unlock() http.Error(w, "temporary Gitee failure", http.StatusServiceUnavailable) return } id := f.nextID f.nextID++ f.assets[id] = fakeGiteeAsset{id: id, name: header.Filename, data: data} if f.listErrorResponsesAfterUpload > 0 { f.listErrorResponsesRemaining += f.listErrorResponsesAfterUpload f.listErrorResponsesAfterUpload = 0 } if f.listEmptyResponsesAfterUpload > 0 { f.listEmptyResponsesRemaining += f.listEmptyResponsesAfterUpload f.listEmptyResponsesAfterUpload = 0 } drop := f.dropFirstResponse && !f.droppedResponse if drop { f.droppedResponse = true } f.mu.Unlock() if drop { hijacker, ok := w.(http.Hijacker) if !ok { http.Error(w, "hijacking unavailable", http.StatusInternalServerError) return } conn, _, err := hijacker.Hijack() if err == nil { _ = conn.Close() } return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": id, "name": header.Filename, "browser_download_url": fmt.Sprintf("%s/download/%d", baseURL, id), }) } func requestBaseURL(r *http.Request) string { scheme := "http" if r.TLS != nil { scheme = "https" } return scheme + "://" + r.Host } func (f *fakeGiteeRelease) delete(w http.ResponseWriter, rawID string) { id, err := strconv.Atoi(rawID) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } f.mu.Lock() delete(f.assets, id) f.mu.Unlock() w.WriteHeader(http.StatusNoContent) } func (f *fakeGiteeRelease) download(w http.ResponseWriter, rawID string) { id, err := strconv.Atoi(rawID) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } f.mu.Lock() asset, ok := f.assets[id] f.mu.Unlock() if !ok { http.Error(w, "asset not found", http.StatusNotFound) return } _, _ = w.Write(asset.data) }