diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 81e6cad0e..98e41e811 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -105,14 +105,14 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Go for release PR automation - if: steps.target.outputs.branch == 'v3-beta' && steps.release_commit.outputs.skip != 'true' && steps.package_release_sync.outputs.ready != 'false' + if: steps.target.outputs.branch == 'v3-beta' && steps.release_commit.outputs.skip != 'true' && steps.package_release_sync.outputs.ready != 'false' && steps.release.outputs.prs_created == 'true' uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c with: go-version-file: cli/.go-version cache-dependency-path: cli/**/go.sum - name: Dispatch release PR checks - if: steps.target.outputs.branch == 'v3-beta' && steps.release_commit.outputs.skip != 'true' && steps.package_release_sync.outputs.ready != 'false' + if: steps.target.outputs.branch == 'v3-beta' && steps.release_commit.outputs.skip != 'true' && steps.package_release_sync.outputs.ready != 'false' && steps.release.outputs.prs_created == 'true' working-directory: cli env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index ace29a69d..7fec65033 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -5,7 +5,6 @@ ### Bug Fixes -* Release PRs label the Unity package summary ([#1438](https://github.com/hatayama/unity-cli-loop/issues/1438)) ([085c13f](https://github.com/hatayama/unity-cli-loop/commit/085c13f619c3ec03c7a9be44ff56c436a398956e)) * Windows update checks no longer interrupt routine commands ([#1434](https://github.com/hatayama/unity-cli-loop/issues/1434)) ([02ce6cc](https://github.com/hatayama/unity-cli-loop/commit/02ce6cc3dbeb92360ebcd5c5318eae6b9ca87c7c)) ## [3.0.0-beta.43](https://github.com/hatayama/unity-cli-loop/compare/cli-v3.0.0-beta.42...cli-v3.0.0-beta.43) (2026-06-28) diff --git a/cli/internal/automation/release_pr_checks.go b/cli/internal/automation/release_pr_checks.go index 3e86f4973..45e30376f 100644 --- a/cli/internal/automation/release_pr_checks.go +++ b/cli/internal/automation/release_pr_checks.go @@ -23,7 +23,17 @@ const ( var ( releasePRCheckNow = time.Now - releasePRCheckSleep = time.Sleep + releasePRCheckSleep = func(ctx context.Context, duration time.Duration) error { + timer := time.NewTimer(duration) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } + } releasePRCheckPlainUnityPackageSummary = regexp.MustCompile(`
((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?)`) ) @@ -62,7 +72,7 @@ func RunReleasePleasePRChecks(ctx context.Context, stdout io.Writer, stderr io.W return 1 } - releasePR, found, err := findReleasePRCheckPullRequest(ctx, config) + releasePR, found, err := findReleasePRCheckPullRequestWithRetry(ctx, config) if err != nil { writeReleasePRCheckLine(stderr, err) return 1 @@ -220,6 +230,22 @@ func findReleasePRCheckPullRequest(ctx context.Context, config releasePRCheckCon return matchingPRs[0], true, nil } +func findReleasePRCheckPullRequestWithRetry(ctx context.Context, config releasePRCheckConfig) (releasePullRequest, bool, error) { + for attempt := 0; attempt < config.lookupAttempts; attempt++ { + releasePR, found, err := findReleasePRCheckPullRequest(ctx, config) + if err != nil || found { + return releasePR, found, err + } + if attempt+1 < config.lookupAttempts { + err = releasePRCheckSleep(ctx, time.Duration(config.lookupIntervalSeconds)*time.Second) + if err != nil { + return releasePullRequest{}, false, err + } + } + } + return releasePullRequest{}, false, nil +} + func releasePRCheckMatches(releasePR releasePullRequest, targetBranch string) bool { releasePRBranch := "release-please--branches--" + targetBranch if releasePR.HeadRefName != releasePRBranch && !strings.HasPrefix(releasePR.HeadRefName, releasePRBranch+"--components--") { @@ -338,7 +364,10 @@ func findDispatchedReleasePRCheckRun( return run, nil } if attempt+1 < config.lookupAttempts { - releasePRCheckSleep(time.Duration(config.lookupIntervalSeconds) * time.Second) + err = releasePRCheckSleep(ctx, time.Duration(config.lookupIntervalSeconds)*time.Second) + if err != nil { + return releaseWorkflowRun{}, err + } } } return releaseWorkflowRun{}, fmt.Errorf("could not find dispatched %s workflow run for %s", config.workflow, releasePR.HeadRefOID) diff --git a/cli/internal/automation/release_pr_checks_test.go b/cli/internal/automation/release_pr_checks_test.go index 79200fd63..4b27997af 100644 --- a/cli/internal/automation/release_pr_checks_test.go +++ b/cli/internal/automation/release_pr_checks_test.go @@ -116,6 +116,46 @@ func TestReleasePRChecksClarifyUnityPackageSummaryBeforeDispatch(t *testing.T) { assertReleasePRCheckLogContains(t, result.ghLog, "workflow run build-and-test.yml --repo owner/repository --ref release-please--branches--v3-beta") } +// Verifies that release PR lookup is retried while GitHub exposes the newly opened PR and label. +func TestReleasePRChecksRetryPendingReleasePRLookup(t *testing.T) { + result := runReleasePRCheckCase(t, releasePRCheckCase{ + prListJSON: `[]`, + prListJSONAfterWatch: `[{"number":1043,"headRefName":"release-please--branches--v3-beta","headRefOid":"abc123","title":"chore: release v3-beta","url":"https://example.test/pr/1043"}]`, + runListJSON: `[{"databaseId":4242,"headSha":"abc123","createdAt":"2026-05-30T01:00:01Z","status":"queued","conclusion":"","url":"https://example.test/run/4242"}]`, + runWatchStatus: "0", + lookupAttempts: "2", + }) + + if result.exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", result.exitCode, result.stderr) + } + if len(result.sleeps) != 1 || result.sleeps[0] != time.Second { + t.Fatalf("expected one initial lookup sleep, got %v", result.sleeps) + } + assertReleasePRCheckLogContains(t, result.stdout, "Marked release PR #1043 as draft while checks run.") + assertReleasePRCheckLogContainsLine(t, result.ghLog, "pr ready 1043 --repo owner/repository") +} + +// Verifies that release PR lookup stops when the caller cancels the command. +func TestReleasePRChecksCancelPendingReleasePRLookup(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result := runReleasePRCheckCase(t, releasePRCheckCase{ + ctx: ctx, + prListJSON: `[]`, + runListJSON: `[]`, + runWatchStatus: "0", + lookupAttempts: "2", + }) + + if result.exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", result.exitCode) + } + assertReleasePRCheckLogContains(t, result.stderr, "context canceled") + assertReleasePRCheckLogDoesNotContain(t, result.ghLog, "workflow run") +} + // Verifies that same-second workflow runs are accepted when GitHub rounds createdAt timestamps. func TestReleasePRChecksAcceptSameSecondRunAfterDispatch(t *testing.T) { result := runReleasePRCheckCase(t, releasePRCheckCase{ @@ -216,11 +256,13 @@ func TestReleasePRChecksFailWhenHeadChangesBeforeReady(t *testing.T) { } type releasePRCheckCase struct { + ctx context.Context prListJSON string prListJSONAfterWatch string prViewJSON string runListJSON string runWatchStatus string + lookupAttempts string now time.Time } @@ -248,12 +290,16 @@ func runReleasePRCheckCase(t *testing.T, testCase releasePRCheckCase) releasePRC if prListJSONAfterWatch == "" { prListJSONAfterWatch = testCase.prListJSON } + lookupAttempts := testCase.lookupAttempts + if lookupAttempts == "" { + lookupAttempts = "1" + } t.Setenv("PATH", mockBin+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("GITHUB_REPOSITORY", "owner/repository") t.Setenv("TARGET_BRANCH", "v3-beta") t.Setenv("ULOOP_REPOSITORY_ROOT", workDir) - t.Setenv("RELEASE_PR_CHECK_LOOKUP_ATTEMPTS", "1") + t.Setenv("RELEASE_PR_CHECK_LOOKUP_ATTEMPTS", lookupAttempts) t.Setenv("RELEASE_PR_CHECK_LOOKUP_INTERVAL_SECONDS", "1") t.Setenv("RELEASE_PR_CHECK_WATCH_INTERVAL_SECONDS", "1") t.Setenv("GH_PR_LIST_JSON", testCase.prListJSON) @@ -275,28 +321,31 @@ func runReleasePRCheckCase(t *testing.T, testCase releasePRCheckCase) releasePRC releasePRCheckNow = func() time.Time { return now } - releasePRCheckSleep = func(duration time.Duration) { + releasePRCheckSleep = func(ctx context.Context, duration time.Duration) error { sleeps = append(sleeps, duration) + return ctx.Err() } t.Cleanup(func() { releasePRCheckNow = originalNow releasePRCheckSleep = originalSleep }) + ctx := testCase.ctx + if ctx == nil { + ctx = context.Background() + } + stdout := bytes.Buffer{} stderr := bytes.Buffer{} - exitCode := RunReleasePleasePRChecks(context.Background(), &stdout, &stderr) - ghLogBytes, err := os.ReadFile(ghLogPath) - if err != nil { - t.Fatalf("failed to read gh log: %v", err) - } + exitCode := RunReleasePleasePRChecks(ctx, &stdout, &stderr) + ghLog := readOptionalFile(t, ghLogPath) gitLog := readOptionalFile(t, gitLogPath) return releasePRCheckRunResult{ exitCode: exitCode, stdout: stdout.String(), stderr: stderr.String(), - ghLog: string(ghLogBytes), + ghLog: ghLog, gitLog: gitLog, sleeps: sleeps, } diff --git a/release-please-config.json b/release-please-config.json index f2c4e51d6..e52b15c12 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -39,6 +39,10 @@ "prerelease-type": "beta", "include-v-in-tag": true, "include-component-in-tag": true, + "exclude-paths": [ + "cli/cmd/dispatch-release-please-pr-checks", + "cli/internal/automation" + ], "changelog-path": "CHANGELOG.md", "extra-files": [ { diff --git a/scripts/test-release-please-config.sh b/scripts/test-release-please-config.sh index 5baa0c243..fd26c0452 100755 --- a/scripts/test-release-please-config.sh +++ b/scripts/test-release-please-config.sh @@ -136,6 +136,8 @@ assert_json_value '.packages["."].["extra-files"][2].jsonpath' '$.packageVersion assert_json_value '.packages["cli"].component' 'uloop-project-runner' assert_json_value '.packages["cli"].["include-component-in-tag"]' 'true' assert_json_value '.packages["cli"].["changelog-path"]' 'CHANGELOG.md' +assert_json_value '.packages["cli"].["exclude-paths"][0]' 'cli/cmd/dispatch-release-please-pr-checks' +assert_json_value '.packages["cli"].["exclude-paths"][1]' 'cli/internal/automation' assert_json_value '.packages["cli"].["extra-files"] | length' '4' assert_json_value '.packages["cli"].["extra-files"][0].path' 'internal/tools/default-tools.json' assert_json_value '.packages["cli"].["extra-files"][1].path' 'contract.json' @@ -153,8 +155,8 @@ assert_file_contains "$RELEASE_WORKFLOW" ' - name: Setup Go for release PR assert_file_contains "$RELEASE_WORKFLOW" ' - name: Dispatch release PR checks' assert_file_contains "$RELEASE_WORKFLOW" ' working-directory: cli' assert_file_contains "$RELEASE_WORKFLOW" ' run: go run ./cmd/dispatch-release-please-pr-checks' -assert_step_contains "$RELEASE_WORKFLOW" ' - name: Setup Go for release PR automation' " if: steps.target.outputs.branch == 'v3-beta' && steps.release_commit.outputs.skip != 'true' && steps.package_release_sync.outputs.ready != 'false'" -assert_step_contains "$RELEASE_WORKFLOW" ' - name: Dispatch release PR checks' " if: steps.target.outputs.branch == 'v3-beta' && steps.release_commit.outputs.skip != 'true' && steps.package_release_sync.outputs.ready != 'false'" +assert_step_contains "$RELEASE_WORKFLOW" ' - name: Setup Go for release PR automation' " if: steps.target.outputs.branch == 'v3-beta' && steps.release_commit.outputs.skip != 'true' && steps.package_release_sync.outputs.ready != 'false' && steps.release.outputs.prs_created == 'true'" +assert_step_contains "$RELEASE_WORKFLOW" ' - name: Dispatch release PR checks' " if: steps.target.outputs.branch == 'v3-beta' && steps.release_commit.outputs.skip != 'true' && steps.package_release_sync.outputs.ready != 'false' && steps.release.outputs.prs_created == 'true'" assert_file_order "$RELEASE_WORKFLOW" ' - name: Setup Go for package release sync' 'Sync release-please package releases' assert_file_order "$RELEASE_WORKFLOW" ' - name: Setup Go for release PR automation' ' - name: Dispatch release PR checks'