1217 lines
42 KiB
Go
1217 lines
42 KiB
Go
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)
|
|
}
|