From 37457f42cfdc242f14e12cf636251ee96a89a88a Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 30 Jun 2026 10:21:05 +0900 Subject: [PATCH 1/3] chore: Keep release automation out of package notes Exclude release PR automation paths from the project runner release component and retry release PR lookup after release-please opens a PR so body normalization can run despite GitHub API propagation delay. Also correct the project runner changelog entry that accidentally included the release PR automation follow-up. --- .github/workflows/release-please.yml | 4 +-- cli/CHANGELOG.md | 1 - cli/internal/automation/release_pr_checks.go | 15 ++++++++++- .../automation/release_pr_checks_test.go | 27 ++++++++++++++++++- release-please-config.json | 4 +++ scripts/test-release-please-config.sh | 6 +++-- 6 files changed, 50 insertions(+), 7 deletions(-) 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..7e664c22f 100644 --- a/cli/internal/automation/release_pr_checks.go +++ b/cli/internal/automation/release_pr_checks.go @@ -62,7 +62,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 +220,19 @@ 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 { + releasePRCheckSleep(time.Duration(config.lookupIntervalSeconds) * time.Second) + } + } + 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--") { diff --git a/cli/internal/automation/release_pr_checks_test.go b/cli/internal/automation/release_pr_checks_test.go index 79200fd63..3fb1a59c6 100644 --- a/cli/internal/automation/release_pr_checks_test.go +++ b/cli/internal/automation/release_pr_checks_test.go @@ -116,6 +116,26 @@ 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 same-second workflow runs are accepted when GitHub rounds createdAt timestamps. func TestReleasePRChecksAcceptSameSecondRunAfterDispatch(t *testing.T) { result := runReleasePRCheckCase(t, releasePRCheckCase{ @@ -221,6 +241,7 @@ type releasePRCheckCase struct { prViewJSON string runListJSON string runWatchStatus string + lookupAttempts string now time.Time } @@ -248,12 +269,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) diff --git a/release-please-config.json b/release-please-config.json index f2c4e51d6..af1a0671e 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": [ + "cmd/dispatch-release-please-pr-checks", + "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..e05fb60cf 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]' 'cmd/dispatch-release-please-pr-checks' +assert_json_value '.packages["cli"].["exclude-paths"][1]' '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' From e08459cedeed67c190f2d3fe81e008626a340176 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 30 Jun 2026 10:32:27 +0900 Subject: [PATCH 2/3] chore: Honor cancellation during release PR retries Make release PR retry backoff context-aware so CI cancellation can stop the automation promptly, and cover the canceled lookup path in tests. --- cli/internal/automation/release_pr_checks.go | 22 +++++++++-- .../automation/release_pr_checks_test.go | 38 +++++++++++++++---- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/cli/internal/automation/release_pr_checks.go b/cli/internal/automation/release_pr_checks.go index 7e664c22f..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.-]*)?)`) ) @@ -227,7 +237,10 @@ func findReleasePRCheckPullRequestWithRetry(ctx context.Context, config releaseP return releasePR, found, err } 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 releasePullRequest{}, false, err + } } } return releasePullRequest{}, false, nil @@ -351,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 3fb1a59c6..4b27997af 100644 --- a/cli/internal/automation/release_pr_checks_test.go +++ b/cli/internal/automation/release_pr_checks_test.go @@ -136,6 +136,26 @@ func TestReleasePRChecksRetryPendingReleasePRLookup(t *testing.T) { 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{ @@ -236,6 +256,7 @@ func TestReleasePRChecksFailWhenHeadChangesBeforeReady(t *testing.T) { } type releasePRCheckCase struct { + ctx context.Context prListJSON string prListJSONAfterWatch string prViewJSON string @@ -300,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, } From 99defb4ff38029af73f9dee9adf00b47fb4939ea Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 30 Jun 2026 10:34:07 +0900 Subject: [PATCH 3/3] chore: Use root-relative release exclude paths Make the project runner release exclusions match release-please file paths from the repository root so release automation changes are actually filtered out. --- release-please-config.json | 4 ++-- scripts/test-release-please-config.sh | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/release-please-config.json b/release-please-config.json index af1a0671e..e52b15c12 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -40,8 +40,8 @@ "include-v-in-tag": true, "include-component-in-tag": true, "exclude-paths": [ - "cmd/dispatch-release-please-pr-checks", - "internal/automation" + "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 e05fb60cf..fd26c0452 100755 --- a/scripts/test-release-please-config.sh +++ b/scripts/test-release-please-config.sh @@ -136,8 +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]' 'cmd/dispatch-release-please-pr-checks' -assert_json_value '.packages["cli"].["exclude-paths"][1]' 'internal/automation' +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'