From 451f418f8e5ed39a868f9ef65d778b96d84fe99f Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 30 Jun 2026 02:38:55 +0900 Subject: [PATCH 1/2] fix: Label Unity package summary in release PRs Update the release PR automation that runs after release-please so it rewrites a plain root package details summary to identify the Unity package. This keeps regenerated v3-beta release PR bodies from looking like two unlabeled package releases. Also make the release PR check test mock executable from Windows so the new body update path is covered locally. --- cli/internal/automation/release_pr_checks.go | 77 ++++++++ .../automation/release_pr_checks_test.go | 177 +++++++++++++++++- 2 files changed, 251 insertions(+), 3 deletions(-) diff --git a/cli/internal/automation/release_pr_checks.go b/cli/internal/automation/release_pr_checks.go index 22cfc7ef4..af63c4c3f 100644 --- a/cli/internal/automation/release_pr_checks.go +++ b/cli/internal/automation/release_pr_checks.go @@ -8,6 +8,7 @@ import ( "io" "os" "os/exec" + "regexp" "strconv" "strings" "time" @@ -23,6 +24,8 @@ const ( var ( releasePRCheckNow = time.Now releasePRCheckSleep = time.Sleep + + releasePRCheckPlainUnityPackageSummary = regexp.MustCompile(`
((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?)`) ) type releasePRCheckConfig struct { @@ -42,6 +45,10 @@ type releasePullRequest struct { URL string `json:"url"` } +type releasePullRequestBody struct { + Body string `json:"body"` +} + type releaseWorkflowRun struct { DatabaseID int64 `json:"databaseId"` HeadSHA string `json:"headSha"` @@ -65,6 +72,15 @@ func RunReleasePleasePRChecks(ctx context.Context, stdout io.Writer, stderr io.W return 0 } + bodyChanged, err := clarifyReleasePRCheckBody(ctx, config, releasePR) + if err != nil { + writeReleasePRCheckLine(stderr, err) + return 1 + } + if bodyChanged { + writeReleasePRCheckLine(stdout, fmt.Sprintf("Updated release PR #%d body to label the Unity package summary.", releasePR.Number)) + } + err = markReleasePRCheckDraft(ctx, config, releasePR) if err != nil { writeReleasePRCheckLine(stderr, err) @@ -242,6 +258,67 @@ func dispatchReleasePRCheckWorkflow(ctx context.Context, config releasePRCheckCo return err } +func clarifyReleasePRCheckBody(ctx context.Context, config releasePRCheckConfig, releasePR releasePullRequest) (bool, error) { + output, err := runReleasePRCheckOutput(ctx, "gh", "pr", "view", strconv.Itoa(releasePR.Number), "--repo", config.repository, "--json", "body") + if err != nil { + return false, err + } + + prBody := releasePullRequestBody{} + err = json.Unmarshal([]byte(output), &prBody) + if err != nil { + return false, fmt.Errorf("failed to parse release PR body: %w", err) + } + + clarifiedBody, changed := clarifyReleasePRCheckUnityPackageSummary(prBody.Body) + if !changed { + return false, nil + } + + bodyFile, cleanup, err := writeReleasePRCheckBodyFile(clarifiedBody) + if err != nil { + return false, err + } + defer cleanup() + + _, err = runReleasePRCheckOutput(ctx, "gh", "pr", "edit", strconv.Itoa(releasePR.Number), "--repo", config.repository, "--body-file", bodyFile) + if err != nil { + return false, err + } + return true, nil +} + +func clarifyReleasePRCheckUnityPackageSummary(body string) (string, bool) { + matches := releasePRCheckPlainUnityPackageSummary.FindStringSubmatchIndex(body) + if matches == nil { + return body, false + } + + version := body[matches[2]:matches[3]] + replacement := "
unity-package: " + version + "" + return body[:matches[0]] + replacement + body[matches[1]:], true +} + +func writeReleasePRCheckBodyFile(body string) (string, func(), error) { + bodyFile, err := os.CreateTemp("", "uloop-release-pr-body-*.md") + if err != nil { + return "", func() {}, fmt.Errorf("failed to create release PR body file: %w", err) + } + + cleanup := func() { _ = os.Remove(bodyFile.Name()) } + _, writeErr := bodyFile.WriteString(body) + closeErr := bodyFile.Close() + if writeErr != nil { + cleanup() + return "", func() {}, fmt.Errorf("failed to write release PR body file: %w", writeErr) + } + if closeErr != nil { + cleanup() + return "", func() {}, fmt.Errorf("failed to close release PR body file: %w", closeErr) + } + return bodyFile.Name(), cleanup, nil +} + func findDispatchedReleasePRCheckRun( ctx context.Context, config releasePRCheckConfig, diff --git a/cli/internal/automation/release_pr_checks_test.go b/cli/internal/automation/release_pr_checks_test.go index 939f4e494..7f5e8c512 100644 --- a/cli/internal/automation/release_pr_checks_test.go +++ b/cli/internal/automation/release_pr_checks_test.go @@ -5,6 +5,7 @@ import ( "context" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -19,6 +20,41 @@ type releasePRCheckRunResult struct { sleeps []time.Duration } +// Verifies that a plain root package release summary is labeled as the Unity package. +func TestReleasePRCheckUnityPackageSummaryIsClarified(t *testing.T) { + body := "## Releases\n" + + "
3.0.0-beta.46\n" + + "\n" + + "* Unity package notes\n" + + "
\n" + + "
uloop-project-runner: 3.0.0-beta.44\n" + + "\n" + + "* Project runner notes\n" + + "
\n" + + clarifiedBody, changed := clarifyReleasePRCheckUnityPackageSummary(body) + + if !changed { + t.Fatal("expected plain Unity package summary to change") + } + assertReleasePRCheckLogContains(t, clarifiedBody, "
unity-package: 3.0.0-beta.46") + assertReleasePRCheckLogContains(t, clarifiedBody, "
uloop-project-runner: 3.0.0-beta.44") +} + +// Verifies that already labeled release summaries are left unchanged. +func TestReleasePRCheckUnityPackageSummaryAlreadyClarified(t *testing.T) { + body := "
unity-package: 3.0.0-beta.46\n
\n" + + clarifiedBody, changed := clarifyReleasePRCheckUnityPackageSummary(body) + + if changed { + t.Fatal("expected labeled Unity package summary to stay unchanged") + } + if clarifiedBody != body { + t.Fatalf("expected body to stay unchanged, got:\n%s", clarifiedBody) + } +} + // Verifies that missing release PRs skip without dispatching checks. func TestReleasePRChecksSkipWhenNoReleasePRExists(t *testing.T) { result := runReleasePRCheckCase(t, releasePRCheckCase{ @@ -59,6 +95,24 @@ func TestReleasePRChecksDispatchAndMarkReady(t *testing.T) { assertReleasePRCheckLogContainsLine(t, result.ghLog, "pr ready 1043 --repo owner/repository") } +// Verifies that matching release PRs are updated when the body has a plain Unity package summary. +func TestReleasePRChecksClarifyUnityPackageSummaryBeforeDispatch(t *testing.T) { + result := runReleasePRCheckCase(t, releasePRCheckCase{ + prListJSON: `[{"number":1043,"headRefName":"release-please--branches--v3-beta","headRefOid":"abc123","title":"chore(v3-beta): release 3.0.0-beta.46","url":"https://example.test/pr/1043"}]`, + prViewJSON: `{"body":"
3.0.0-beta.46\n
\n
uloop-project-runner: 3.0.0-beta.44\n
\n"}`, + runListJSON: `[{"databaseId":4242,"headSha":"abc123","createdAt":"2026-05-30T01:00:01Z","status":"queued","conclusion":"","url":"https://example.test/run/4242"}]`, + runWatchStatus: "0", + }) + + if result.exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", result.exitCode, result.stderr) + } + assertReleasePRCheckLogContains(t, result.stdout, "Updated release PR #1043 body to label the Unity package summary.") + assertReleasePRCheckLogContains(t, result.ghLog, "pr view 1043 --repo owner/repository --json body") + assertReleasePRCheckLogContains(t, result.ghLog, "pr edit 1043 --repo owner/repository --body-file") + assertReleasePRCheckLogContains(t, result.ghLog, "workflow run build-and-test.yml --repo owner/repository --ref release-please--branches--v3-beta") +} + // Verifies that same-second workflow runs are accepted when GitHub rounds createdAt timestamps. func TestReleasePRChecksAcceptSameSecondRunAfterDispatch(t *testing.T) { result := runReleasePRCheckCase(t, releasePRCheckCase{ @@ -161,6 +215,7 @@ func TestReleasePRChecksFailWhenHeadChangesBeforeReady(t *testing.T) { type releasePRCheckCase struct { prListJSON string prListJSONAfterWatch string + prViewJSON string runListJSON string runWatchStatus string now time.Time @@ -182,6 +237,15 @@ func runReleasePRCheckCase(t *testing.T, testCase releasePRCheckCase) releasePRC writeReleasePRCheckMockGH(t, filepath.Join(mockBin, "gh")) writeReleasePRCheckMockGit(t, filepath.Join(mockBin, "git")) + prViewJSON := testCase.prViewJSON + if prViewJSON == "" { + prViewJSON = `{"body":""}` + } + prListJSONAfterWatch := testCase.prListJSONAfterWatch + if prListJSONAfterWatch == "" { + prListJSONAfterWatch = testCase.prListJSON + } + t.Setenv("PATH", mockBin+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("GITHUB_REPOSITORY", "owner/repository") t.Setenv("TARGET_BRANCH", "v3-beta") @@ -190,8 +254,9 @@ func runReleasePRCheckCase(t *testing.T, testCase releasePRCheckCase) releasePRC 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) - t.Setenv("GH_PR_LIST_JSON_AFTER_WATCH", testCase.prListJSONAfterWatch) + t.Setenv("GH_PR_LIST_JSON_AFTER_WATCH", prListJSONAfterWatch) t.Setenv("GH_PR_LIST_COUNT_PATH", prListCountPath) + t.Setenv("GH_PR_VIEW_JSON", prViewJSON) t.Setenv("GH_RUN_LIST_JSON", testCase.runListJSON) t.Setenv("GH_RUN_WATCH_STATUS", testCase.runWatchStatus) t.Setenv("GH_LOG", ghLogPath) @@ -237,6 +302,11 @@ func runReleasePRCheckCase(t *testing.T, testCase releasePRCheckCase) releasePRC func writeReleasePRCheckMockGH(t *testing.T, path string) { t.Helper() + if runtime.GOOS == "windows" { + writeReleasePRCheckMockGHBatch(t, path+".cmd") + return + } + content := `#!/bin/sh set -eu @@ -263,6 +333,15 @@ if [ "$1" = "pr" ] && [ "$2" = "ready" ]; then exit 0 fi +if [ "$1" = "pr" ] && [ "$2" = "view" ]; then + printf '%s\n' "$GH_PR_VIEW_JSON" + exit 0 +fi + +if [ "$1" = "pr" ] && [ "$2" = "edit" ]; then + exit 0 +fi + if [ "$1" = "workflow" ] && [ "$2" = "run" ]; then exit 0 fi @@ -288,6 +367,11 @@ exit 1 func writeReleasePRCheckMockGit(t *testing.T, path string) { t.Helper() + if runtime.GOOS == "windows" { + writeReleasePRCheckMockGitBatch(t, path+".cmd") + return + } + content := `#!/bin/sh set -eu @@ -351,6 +435,93 @@ exit 1 } } +func writeReleasePRCheckMockGHBatch(t *testing.T, path string) { + t.Helper() + content := `@echo off +setlocal EnableExtensions EnableDelayedExpansion +>>"%GH_LOG%" echo %* + +if "%~1"=="pr" if "%~2"=="list" ( + set "count=0" + if exist "%GH_PR_LIST_COUNT_PATH%" set /p count=<"%GH_PR_LIST_COUNT_PATH%" + set /a count+=1 + >"%GH_PR_LIST_COUNT_PATH%" echo !count! + if !count! GTR 1 ( + powershell -NoProfile -ExecutionPolicy Bypass -Command "[Console]::Out.WriteLine($env:GH_PR_LIST_JSON_AFTER_WATCH)" + exit /b 0 + ) + powershell -NoProfile -ExecutionPolicy Bypass -Command "[Console]::Out.WriteLine($env:GH_PR_LIST_JSON)" + exit /b 0 +) + +if "%~1"=="pr" if "%~2"=="ready" exit /b 0 + +if "%~1"=="pr" if "%~2"=="view" ( + powershell -NoProfile -ExecutionPolicy Bypass -Command "[Console]::Out.WriteLine($env:GH_PR_VIEW_JSON)" + exit /b 0 +) + +if "%~1"=="pr" if "%~2"=="edit" exit /b 0 + +if "%~1"=="workflow" if "%~2"=="run" exit /b 0 + +if "%~1"=="run" if "%~2"=="list" ( + powershell -NoProfile -ExecutionPolicy Bypass -Command "[Console]::Out.WriteLine($env:GH_RUN_LIST_JSON)" + exit /b 0 +) + +if "%~1"=="run" if "%~2"=="watch" exit /b %GH_RUN_WATCH_STATUS% + +echo unexpected gh command: %* 1>&2 +exit /b 1 +` + writeFile(t, path, content) +} + +func writeReleasePRCheckMockGitBatch(t *testing.T, path string) { + t.Helper() + content := `@echo off +setlocal EnableExtensions EnableDelayedExpansion +>>"%GIT_LOG%" echo %* + +if "%~1"=="rev-parse" if "%~2"=="--show-toplevel" ( + powershell -NoProfile -ExecutionPolicy Bypass -Command "[Console]::Out.WriteLine($env:ULOOP_REPOSITORY_ROOT)" + exit /b 0 +) + +if "%~1"=="-C" ( + shift + shift +) + +if "%~1"=="fetch" if "%~2"=="origin" exit /b 0 +if "%~1"=="switch" if "%~2"=="--detach" if "%~3"=="FETCH_HEAD" exit /b 0 + +if "%~1"=="show" ( + echo %~2| findstr /r "^uloop-project-runner-v.*:cli/contract.json$" >nul + if not errorlevel 1 ( + if not "%GIT_RELEASE_CONTRACT%"=="" ( + type "%GIT_RELEASE_CONTRACT%" + exit /b 0 + ) + echo release not found 1>&2 + exit /b 1 + ) + echo unexpected git show ref: %~2 1>&2 + exit /b 1 +) + +if "%~1"=="config" exit /b 0 +if "%~1"=="add" exit /b 0 +if "%~1"=="commit" exit /b 0 +if "%~1"=="push" exit /b 0 + +echo unexpected git command: %* 1>&2 +exit /b 1 +` + writeFile(t, path, content) +} + func readOptionalFile(t *testing.T, path string) string { t.Helper() content, err := os.ReadFile(path) @@ -380,7 +551,7 @@ func assertReleasePRCheckLogDoesNotContain(t *testing.T, actual string, unexpect func assertReleasePRCheckLogContainsLine(t *testing.T, actual string, expected string) { t.Helper() for _, line := range strings.Split(actual, "\n") { - if line == expected { + if strings.TrimSuffix(line, "\r") == expected { return } } @@ -390,7 +561,7 @@ func assertReleasePRCheckLogContainsLine(t *testing.T, actual string, expected s func assertReleasePRCheckLogDoesNotContainLine(t *testing.T, actual string, unexpected string) { t.Helper() for _, line := range strings.Split(actual, "\n") { - if line == unexpected { + if strings.TrimSuffix(line, "\r") == unexpected { t.Fatalf("expected log not to contain line %q, got:\n%s", unexpected, actual) } } From 477ca576da2379f8027e90b4e9375924ea878842 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 30 Jun 2026 09:43:45 +0900 Subject: [PATCH 2/2] fix: Avoid relabeling clarified release PR bodies Skip release PR body rewriting when the Unity package summary is already labeled so repeated automation runs cannot relabel later release-note details blocks. --- cli/internal/automation/release_pr_checks.go | 4 ++++ cli/internal/automation/release_pr_checks_test.go | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/cli/internal/automation/release_pr_checks.go b/cli/internal/automation/release_pr_checks.go index af63c4c3f..3e86f4973 100644 --- a/cli/internal/automation/release_pr_checks.go +++ b/cli/internal/automation/release_pr_checks.go @@ -289,6 +289,10 @@ func clarifyReleasePRCheckBody(ctx context.Context, config releasePRCheckConfig, } func clarifyReleasePRCheckUnityPackageSummary(body string) (string, bool) { + if strings.Contains(body, "
unity-package: ") { + return body, false + } + matches := releasePRCheckPlainUnityPackageSummary.FindStringSubmatchIndex(body) if matches == nil { return body, false diff --git a/cli/internal/automation/release_pr_checks_test.go b/cli/internal/automation/release_pr_checks_test.go index 7f5e8c512..79200fd63 100644 --- a/cli/internal/automation/release_pr_checks_test.go +++ b/cli/internal/automation/release_pr_checks_test.go @@ -43,7 +43,10 @@ func TestReleasePRCheckUnityPackageSummaryIsClarified(t *testing.T) { // Verifies that already labeled release summaries are left unchanged. func TestReleasePRCheckUnityPackageSummaryAlreadyClarified(t *testing.T) { - body := "
unity-package: 3.0.0-beta.46\n
\n" + body := "
unity-package: 3.0.0-beta.46\n
\n" + + "
1.2.3\n" + + "* Release note content that should not be relabeled.\n" + + "
\n" clarifiedBody, changed := clarifyReleasePRCheckUnityPackageSummary(body)