From 4a4b1434156a1ac0aa2475c5c5f500a70a77a7da Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 22 Jul 2026 21:35:17 +0900 Subject: [PATCH 1/4] Add notify-pending-release-approvals Go command Adds a reconcile-style command that lists repository-wide waiting workflow runs, resolves each one's pending environment deployment, and creates/updates/closes a single tracking issue accordingly. TDD-covered with unit tests for the pure plan decision, body generation, and the gh-CLI-backed orchestration layer, following the deps-injection pattern from cancel-superseded-waiting-runs. --- .../notify-pending-release-approvals/main.go | 19 ++ .../notify_pending_release_approvals.go | 268 +++++++++++++++++ .../notify_pending_release_approvals_test.go | 270 ++++++++++++++++++ 3 files changed, 557 insertions(+) create mode 100644 cli/release-automation/cmd/notify-pending-release-approvals/main.go create mode 100644 cli/release-automation/internal/automation/notify_pending_release_approvals.go create mode 100644 cli/release-automation/internal/automation/notify_pending_release_approvals_test.go diff --git a/cli/release-automation/cmd/notify-pending-release-approvals/main.go b/cli/release-automation/cmd/notify-pending-release-approvals/main.go new file mode 100644 index 000000000..8964b7c69 --- /dev/null +++ b/cli/release-automation/cmd/notify-pending-release-approvals/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/hatayama/unity-cli-loop/tools/release-automation/internal/automation" +) + +func main() { + repository, isSet := os.LookupEnv("GITHUB_REPOSITORY") + if !isSet || repository == "" { + fmt.Fprintln(os.Stderr, "GITHUB_REPOSITORY is not set") + os.Exit(1) + } + + os.Exit(automation.RunNotifyPendingReleaseApprovals(context.Background(), os.Stdout, os.Stderr, repository)) +} diff --git a/cli/release-automation/internal/automation/notify_pending_release_approvals.go b/cli/release-automation/internal/automation/notify_pending_release_approvals.go new file mode 100644 index 000000000..281622b5a --- /dev/null +++ b/cli/release-automation/internal/automation/notify_pending_release_approvals.go @@ -0,0 +1,268 @@ +package automation + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os/exec" + "strconv" + "strings" +) + +// pendingApprovalIssueTitle and pendingApprovalIssueLabel identify the single +// tracking issue this command reconciles: an exact title match (rather than a +// generated one) keeps repeated runs from creating duplicates. +const pendingApprovalIssueTitle = "Release approval pending" +const pendingApprovalIssueLabel = "release-approval-pending" +const pendingApprovalIssueLabelDescription = "Release environment approvals waiting on manual review" +const pendingApprovalIssueLabelColor = "5319E7" +const pendingApprovalResolvedComment = "All pending approvals resolved." + +// pendingApprovalRun is one workflow run currently waiting on a GitHub +// environment manual approval. +type pendingApprovalRun struct { + WorkflowName string + HeadBranch string + Environment string + URL string +} + +// existingPendingApprovalIssue is the tracking issue's current state, read +// back from GitHub so the reconcile decision can compare against it. +type existingPendingApprovalIssue struct { + Number int + Body string +} + +type notifyPendingReleaseApprovalsPlanKind int + +const ( + notifyPendingReleaseApprovalsPlanNone notifyPendingReleaseApprovalsPlanKind = iota + notifyPendingReleaseApprovalsPlanCreate + notifyPendingReleaseApprovalsPlanUpdate + notifyPendingReleaseApprovalsPlanClose +) + +// notifyPendingReleaseApprovalsPlan is the single action the reconcile loop +// takes this run, decided purely from waiting runs vs. existing issue state. +type notifyPendingReleaseApprovalsPlan struct { + Kind notifyPendingReleaseApprovalsPlanKind + Body string + IssueNumber int +} + +type notifyPendingReleaseApprovalsDeps struct { + runOutput func(context.Context, string, ...string) (string, error) +} + +type waitingRunListEntry struct { + DatabaseID int64 `json:"databaseId"` + WorkflowName string `json:"workflowName"` + HeadBranch string `json:"headBranch"` + URL string `json:"url"` +} + +type pendingDeploymentEntry struct { + Environment struct { + Name string `json:"name"` + } `json:"environment"` +} + +type openIssueListEntry struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` +} + +// RunNotifyPendingReleaseApprovals reconciles the repository-wide set of +// approval-waiting workflow runs against a single tracking issue: created or +// updated while runs are waiting, closed once none remain. It only relies on +// waiting-status runs having a pending environment deployment, so it needs no +// per-workflow filter — any future release gate is picked up automatically. +func RunNotifyPendingReleaseApprovals(ctx context.Context, stdout io.Writer, stderr io.Writer, repository string) int { + if repository == "" { + writeNotifyPendingReleaseApprovalsLine(stderr, "notify-pending-release-approvals: GITHUB_REPOSITORY is required") + return 1 + } + return runNotifyPendingReleaseApprovalsWithDeps(ctx, stdout, stderr, repository, defaultNotifyPendingReleaseApprovalsDeps()) +} + +func defaultNotifyPendingReleaseApprovalsDeps() notifyPendingReleaseApprovalsDeps { + return notifyPendingReleaseApprovalsDeps{ + runOutput: runNotifyPendingReleaseApprovalsCommandOutput, + } +} + +func runNotifyPendingReleaseApprovalsWithDeps(ctx context.Context, stdout io.Writer, stderr io.Writer, repository string, deps notifyPendingReleaseApprovalsDeps) int { + runs, err := listPendingApprovalRuns(ctx, repository, deps) + if err != nil { + writeNotifyPendingReleaseApprovalsLine(stderr, "notify-pending-release-approvals:", err) + return 1 + } + + err = ensurePendingApprovalIssueLabel(ctx, repository, deps) + if err != nil { + writeNotifyPendingReleaseApprovalsLine(stderr, "notify-pending-release-approvals:", err) + return 1 + } + + existing, err := findExistingPendingApprovalIssue(ctx, repository, deps) + if err != nil { + writeNotifyPendingReleaseApprovalsLine(stderr, "notify-pending-release-approvals:", err) + return 1 + } + + plan := planNotifyPendingReleaseApprovals(runs, existing) + + switch plan.Kind { + case notifyPendingReleaseApprovalsPlanNone: + writeNotifyPendingReleaseApprovalsLine(stdout, "No changes needed.") + case notifyPendingReleaseApprovalsPlanCreate: + _, err = deps.runOutput(ctx, "gh", "issue", "create", "--repo", repository, "--title", pendingApprovalIssueTitle, "--body", plan.Body, "--label", pendingApprovalIssueLabel) + if err != nil { + writeNotifyPendingReleaseApprovalsLine(stderr, "notify-pending-release-approvals:", err) + return 1 + } + writeNotifyPendingReleaseApprovalsLine(stdout, "Created pending approval issue.") + case notifyPendingReleaseApprovalsPlanUpdate: + _, err = deps.runOutput(ctx, "gh", "issue", "edit", strconv.Itoa(plan.IssueNumber), "--repo", repository, "--body", plan.Body) + if err != nil { + writeNotifyPendingReleaseApprovalsLine(stderr, "notify-pending-release-approvals:", err) + return 1 + } + writeNotifyPendingReleaseApprovalsLine(stdout, fmt.Sprintf("Updated pending approval issue #%d.", plan.IssueNumber)) + case notifyPendingReleaseApprovalsPlanClose: + _, err = deps.runOutput(ctx, "gh", "issue", "close", strconv.Itoa(plan.IssueNumber), "--repo", repository, "--comment", pendingApprovalResolvedComment) + if err != nil { + writeNotifyPendingReleaseApprovalsLine(stderr, "notify-pending-release-approvals:", err) + return 1 + } + writeNotifyPendingReleaseApprovalsLine(stdout, fmt.Sprintf("Closed pending approval issue #%d.", plan.IssueNumber)) + } + + return 0 +} + +// planNotifyPendingReleaseApprovals decides the single action to take, purely +// from the current waiting runs and the existing issue's last-written body. +// Comparing generated body text (rather than a separate hash/version field) +// keeps the reconcile loop idempotent without extra state to track. +func planNotifyPendingReleaseApprovals(runs []pendingApprovalRun, existing *existingPendingApprovalIssue) notifyPendingReleaseApprovalsPlan { + if len(runs) == 0 { + if existing == nil { + return notifyPendingReleaseApprovalsPlan{Kind: notifyPendingReleaseApprovalsPlanNone} + } + return notifyPendingReleaseApprovalsPlan{Kind: notifyPendingReleaseApprovalsPlanClose, IssueNumber: existing.Number} + } + + body := buildPendingApprovalIssueBody(runs) + if existing == nil { + return notifyPendingReleaseApprovalsPlan{Kind: notifyPendingReleaseApprovalsPlanCreate, Body: body} + } + if existing.Body == body { + return notifyPendingReleaseApprovalsPlan{Kind: notifyPendingReleaseApprovalsPlanNone} + } + return notifyPendingReleaseApprovalsPlan{Kind: notifyPendingReleaseApprovalsPlanUpdate, Body: body, IssueNumber: existing.Number} +} + +func buildPendingApprovalIssueBody(runs []pendingApprovalRun) string { + builder := strings.Builder{} + builder.WriteString("The following release approval(s) are waiting on a manual review:\n\n") + for _, run := range runs { + builder.WriteString(fmt.Sprintf("- Workflow: %s\n Branch: %s\n Environment: %s\n Run: %s\n\n", run.WorkflowName, run.HeadBranch, run.Environment, run.URL)) + } + builder.WriteString("Approve the pending deployment(s) at the run URL(s) above. After approval, the next release-please run publishes the blocked package releases automatically.") + return builder.String() +} + +func listPendingApprovalRuns(ctx context.Context, repository string, deps notifyPendingReleaseApprovalsDeps) ([]pendingApprovalRun, error) { + output, err := deps.runOutput(ctx, "gh", "run", "list", "--repo", repository, "--status", "waiting", "--json", "databaseId,workflowName,headBranch,url", "--limit", "100") + if err != nil { + return nil, err + } + + entries := []waitingRunListEntry{} + err = json.Unmarshal([]byte(output), &entries) + if err != nil { + return nil, fmt.Errorf("failed to parse waiting workflow runs: %w", err) + } + + runs := make([]pendingApprovalRun, 0, len(entries)) + for _, entry := range entries { + environment, err := resolvePendingDeploymentEnvironment(ctx, repository, entry.DatabaseID, deps) + if err != nil { + return nil, fmt.Errorf("failed to resolve pending deployment environment for run %d: %w", entry.DatabaseID, err) + } + runs = append(runs, pendingApprovalRun{ + WorkflowName: entry.WorkflowName, + HeadBranch: entry.HeadBranch, + Environment: environment, + URL: entry.URL, + }) + } + return runs, nil +} + +func resolvePendingDeploymentEnvironment(ctx context.Context, repository string, runID int64, deps notifyPendingReleaseApprovalsDeps) (string, error) { + output, err := deps.runOutput(ctx, "gh", "api", fmt.Sprintf("repos/%s/actions/runs/%d/pending_deployments", repository, runID)) + if err != nil { + return "", err + } + + entries := []pendingDeploymentEntry{} + err = json.Unmarshal([]byte(output), &entries) + if err != nil { + return "", fmt.Errorf("failed to parse pending deployments: %w", err) + } + + environments := make([]string, 0, len(entries)) + for _, entry := range entries { + environments = append(environments, entry.Environment.Name) + } + return strings.Join(environments, ", "), nil +} + +func ensurePendingApprovalIssueLabel(ctx context.Context, repository string, deps notifyPendingReleaseApprovalsDeps) error { + _, err := deps.runOutput(ctx, "gh", "label", "create", pendingApprovalIssueLabel, "--repo", repository, "--description", pendingApprovalIssueLabelDescription, "--color", pendingApprovalIssueLabelColor, "--force") + return err +} + +func findExistingPendingApprovalIssue(ctx context.Context, repository string, deps notifyPendingReleaseApprovalsDeps) (*existingPendingApprovalIssue, error) { + output, err := deps.runOutput(ctx, "gh", "issue", "list", "--repo", repository, "--state", "open", "--label", pendingApprovalIssueLabel, "--json", "number,title,body", "--limit", "100") + if err != nil { + return nil, err + } + + entries := []openIssueListEntry{} + err = json.Unmarshal([]byte(output), &entries) + if err != nil { + return nil, fmt.Errorf("failed to parse open issue list: %w", err) + } + + for _, entry := range entries { + if entry.Title == pendingApprovalIssueTitle { + return &existingPendingApprovalIssue{Number: entry.Number, Body: entry.Body}, nil + } + } + return nil, nil +} + +func writeNotifyPendingReleaseApprovalsLine(writer io.Writer, values ...any) { + // CI status output failures cannot be recovered after the command outcome is known. + _, _ = fmt.Fprintln(writer, values...) +} + +func runNotifyPendingReleaseApprovalsCommandOutput(ctx context.Context, name string, args ...string) (string, error) { + command := exec.CommandContext(ctx, name, args...) + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + command.Stdout = &stdout + command.Stderr = &stderr + err := command.Run() + if err != nil { + return "", fmt.Errorf("%s %s failed: %w\n%s%s", name, strings.Join(args, " "), err, stderr.String(), stdout.String()) + } + return stdout.String(), nil +} diff --git a/cli/release-automation/internal/automation/notify_pending_release_approvals_test.go b/cli/release-automation/internal/automation/notify_pending_release_approvals_test.go new file mode 100644 index 000000000..8716cca6b --- /dev/null +++ b/cli/release-automation/internal/automation/notify_pending_release_approvals_test.go @@ -0,0 +1,270 @@ +package automation + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" +) + +// Verifies that when no existing issue is open, the body-generation logic +// composes one entry per waiting run with its workflow, branch, environment +// and run URL, plus the fixed explanation trailer. +func TestBuildPendingApprovalIssueBodyListsEachRunAndExplanation(t *testing.T) { + runs := []pendingApprovalRun{ + {WorkflowName: "native-cli-publish", HeadBranch: "v3-beta", Environment: "cli-release", URL: "https://github.com/hatayama/unity-cli-loop/actions/runs/1"}, + {WorkflowName: "dispatcher-publish", HeadBranch: "v3-beta", Environment: "cli-release", URL: "https://github.com/hatayama/unity-cli-loop/actions/runs/2"}, + } + + body := buildPendingApprovalIssueBody(runs) + + assertNotifyPendingReleaseApprovalsContains(t, body, "native-cli-publish") + assertNotifyPendingReleaseApprovalsContains(t, body, "dispatcher-publish") + assertNotifyPendingReleaseApprovalsContains(t, body, "v3-beta") + assertNotifyPendingReleaseApprovalsContains(t, body, "cli-release") + assertNotifyPendingReleaseApprovalsContains(t, body, "https://github.com/hatayama/unity-cli-loop/actions/runs/1") + assertNotifyPendingReleaseApprovalsContains(t, body, "https://github.com/hatayama/unity-cli-loop/actions/runs/2") + assertNotifyPendingReleaseApprovalsContains(t, body, "Approve the pending deployment(s)") +} + +// Verifies that two calls to buildPendingApprovalIssueBody with the same +// input runs produce byte-identical output, which is the idempotency +// guarantee the reconcile loop relies on to avoid pointless issue updates. +func TestBuildPendingApprovalIssueBodyIsDeterministic(t *testing.T) { + runs := []pendingApprovalRun{ + {WorkflowName: "native-cli-publish", HeadBranch: "v3-beta", Environment: "cli-release", URL: "https://github.com/hatayama/unity-cli-loop/actions/runs/1"}, + } + + first := buildPendingApprovalIssueBody(runs) + second := buildPendingApprovalIssueBody(runs) + + if first != second { + t.Fatalf("expected deterministic body, got:\n%s\n---\n%s", first, second) + } +} + +// Verifies that with no waiting runs and no existing issue, the plan is a +// no-op: nothing to create and nothing to close. +func TestPlanNotifyPendingReleaseApprovalsNoRunsNoIssueIsNoop(t *testing.T) { + plan := planNotifyPendingReleaseApprovals(nil, nil) + + if plan.Kind != notifyPendingReleaseApprovalsPlanNone { + t.Fatalf("expected plan kind None, got %v", plan.Kind) + } +} + +// Verifies that when waiting runs exist and no issue is currently open, the +// plan is to create a new issue with the generated body. +func TestPlanNotifyPendingReleaseApprovalsRunsWithoutIssueCreates(t *testing.T) { + runs := []pendingApprovalRun{ + {WorkflowName: "native-cli-publish", HeadBranch: "v3-beta", Environment: "cli-release", URL: "https://github.com/hatayama/unity-cli-loop/actions/runs/1"}, + } + + plan := planNotifyPendingReleaseApprovals(runs, nil) + + if plan.Kind != notifyPendingReleaseApprovalsPlanCreate { + t.Fatalf("expected plan kind Create, got %v", plan.Kind) + } + assertNotifyPendingReleaseApprovalsContains(t, plan.Body, "native-cli-publish") +} + +// Verifies that when the waiting runs are unchanged from the last reconcile +// (the existing issue body already matches the freshly generated body), the +// plan is a no-op so the issue is not rewritten on every 15-minute tick. +func TestPlanNotifyPendingReleaseApprovalsUnchangedRunsIsNoop(t *testing.T) { + runs := []pendingApprovalRun{ + {WorkflowName: "native-cli-publish", HeadBranch: "v3-beta", Environment: "cli-release", URL: "https://github.com/hatayama/unity-cli-loop/actions/runs/1"}, + } + existing := &existingPendingApprovalIssue{Number: 42, Body: buildPendingApprovalIssueBody(runs)} + + plan := planNotifyPendingReleaseApprovals(runs, existing) + + if plan.Kind != notifyPendingReleaseApprovalsPlanNone { + t.Fatalf("expected plan kind None for unchanged runs, got %v", plan.Kind) + } +} + +// Verifies that when the waiting run set changed since the existing issue +// was last written (e.g. a new run started waiting), the plan is to update +// the existing issue in place rather than creating a duplicate. +func TestPlanNotifyPendingReleaseApprovalsChangedRunsUpdates(t *testing.T) { + oldRuns := []pendingApprovalRun{ + {WorkflowName: "native-cli-publish", HeadBranch: "v3-beta", Environment: "cli-release", URL: "https://github.com/hatayama/unity-cli-loop/actions/runs/1"}, + } + newRuns := []pendingApprovalRun{ + {WorkflowName: "native-cli-publish", HeadBranch: "v3-beta", Environment: "cli-release", URL: "https://github.com/hatayama/unity-cli-loop/actions/runs/1"}, + {WorkflowName: "dispatcher-publish", HeadBranch: "v3-beta", Environment: "cli-release", URL: "https://github.com/hatayama/unity-cli-loop/actions/runs/2"}, + } + existing := &existingPendingApprovalIssue{Number: 42, Body: buildPendingApprovalIssueBody(oldRuns)} + + plan := planNotifyPendingReleaseApprovals(newRuns, existing) + + if plan.Kind != notifyPendingReleaseApprovalsPlanUpdate { + t.Fatalf("expected plan kind Update, got %v", plan.Kind) + } + if plan.IssueNumber != 42 { + t.Fatalf("expected issue number 42, got %d", plan.IssueNumber) + } + assertNotifyPendingReleaseApprovalsContains(t, plan.Body, "dispatcher-publish") +} + +// Verifies that when no waiting runs remain but an issue is still open, the +// plan is to close that issue, so approvals resolving outside the 15-minute +// schedule (via the workflow_run trigger) still clear the issue promptly. +func TestPlanNotifyPendingReleaseApprovalsNoRunsWithIssueCloses(t *testing.T) { + existing := &existingPendingApprovalIssue{Number: 42, Body: "stale body"} + + plan := planNotifyPendingReleaseApprovals(nil, existing) + + if plan.Kind != notifyPendingReleaseApprovalsPlanClose { + t.Fatalf("expected plan kind Close, got %v", plan.Kind) + } + if plan.IssueNumber != 42 { + t.Fatalf("expected issue number 42, got %d", plan.IssueNumber) + } +} + +// Verifies the end-to-end orchestration: waiting runs are listed repo-wide, +// each run's pending environment is looked up, the label is ensured, and +// (since no matching issue exists yet) a new issue is created with a body +// covering both waiting runs. +func TestRunNotifyPendingReleaseApprovalsCreatesIssueForWaitingRuns(t *testing.T) { + commandLog := []string{} + deps := notifyPendingReleaseApprovalsDeps{ + runOutput: func(ctx context.Context, name string, args ...string) (string, error) { + commandLine := strings.Join(append([]string{name}, args...), " ") + commandLog = append(commandLog, commandLine) + switch { + case commandLine == "gh run list --repo owner/repository --status waiting --json databaseId,workflowName,headBranch,url --limit 100": + return `[{"databaseId":1,"workflowName":"native-cli-publish","headBranch":"v3-beta","url":"https://github.com/owner/repository/actions/runs/1"}]`, nil + case commandLine == "gh api repos/owner/repository/actions/runs/1/pending_deployments": + return `[{"environment":{"name":"cli-release"}}]`, nil + case strings.HasPrefix(commandLine, "gh label create release-approval-pending"): + return "", nil + case commandLine == "gh issue list --repo owner/repository --state open --label release-approval-pending --json number,title,body --limit 100": + return `[]`, nil + case strings.HasPrefix(commandLine, "gh issue create --repo owner/repository --title Release approval pending"): + return "https://github.com/owner/repository/issues/99", nil + } + return "", fmt.Errorf("unexpected command: %s", commandLine) + }, + } + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := runNotifyPendingReleaseApprovalsWithDeps(context.Background(), &stdout, &stderr, "owner/repository", deps) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", exitCode, stderr.String()) + } + commandLogText := strings.Join(commandLog, "\n") + assertNotifyPendingReleaseApprovalsContains(t, commandLogText, "gh issue create --repo owner/repository --title Release approval pending") +} + +// Verifies that when no waiting runs exist but a matching open issue is +// found, the command closes it with a resolution comment instead of leaving +// it open or creating a duplicate. +func TestRunNotifyPendingReleaseApprovalsClosesIssueWhenNoWaitingRunsRemain(t *testing.T) { + commandLog := []string{} + deps := notifyPendingReleaseApprovalsDeps{ + runOutput: func(ctx context.Context, name string, args ...string) (string, error) { + commandLine := strings.Join(append([]string{name}, args...), " ") + commandLog = append(commandLog, commandLine) + switch { + case commandLine == "gh run list --repo owner/repository --status waiting --json databaseId,workflowName,headBranch,url --limit 100": + return `[]`, nil + case strings.HasPrefix(commandLine, "gh label create release-approval-pending"): + return "", nil + case commandLine == "gh issue list --repo owner/repository --state open --label release-approval-pending --json number,title,body --limit 100": + return `[{"number":42,"title":"Release approval pending","body":"stale body"}]`, nil + case commandLine == "gh issue close 42 --repo owner/repository --comment All pending approvals resolved.": + return "", nil + } + return "", fmt.Errorf("unexpected command: %s", commandLine) + }, + } + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := runNotifyPendingReleaseApprovalsWithDeps(context.Background(), &stdout, &stderr, "owner/repository", deps) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", exitCode, stderr.String()) + } + commandLogText := strings.Join(commandLog, "\n") + assertNotifyPendingReleaseApprovalsContains(t, commandLogText, "gh issue close 42 --repo owner/repository --comment All pending approvals resolved.") +} + +// Verifies that when the waiting run set is unchanged from what the existing +// issue already describes, no create/update/close command is issued at all, +// proving the reconcile loop is idempotent across repeated runs. +func TestRunNotifyPendingReleaseApprovalsNoopsWhenUnchanged(t *testing.T) { + runs := []pendingApprovalRun{ + {WorkflowName: "native-cli-publish", HeadBranch: "v3-beta", Environment: "cli-release", URL: "https://github.com/owner/repository/actions/runs/1"}, + } + existingBody := buildPendingApprovalIssueBody(runs) + commandLog := []string{} + deps := notifyPendingReleaseApprovalsDeps{ + runOutput: func(ctx context.Context, name string, args ...string) (string, error) { + commandLine := strings.Join(append([]string{name}, args...), " ") + commandLog = append(commandLog, commandLine) + switch { + case commandLine == "gh run list --repo owner/repository --status waiting --json databaseId,workflowName,headBranch,url --limit 100": + return `[{"databaseId":1,"workflowName":"native-cli-publish","headBranch":"v3-beta","url":"https://github.com/owner/repository/actions/runs/1"}]`, nil + case commandLine == "gh api repos/owner/repository/actions/runs/1/pending_deployments": + return `[{"environment":{"name":"cli-release"}}]`, nil + case strings.HasPrefix(commandLine, "gh label create release-approval-pending"): + return "", nil + case commandLine == "gh issue list --repo owner/repository --state open --label release-approval-pending --json number,title,body --limit 100": + return fmt.Sprintf(`[{"number":42,"title":"Release approval pending","body":%q}]`, existingBody), nil + } + return "", fmt.Errorf("unexpected command: %s", commandLine) + }, + } + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := runNotifyPendingReleaseApprovalsWithDeps(context.Background(), &stdout, &stderr, "owner/repository", deps) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", exitCode, stderr.String()) + } + commandLogText := strings.Join(commandLog, "\n") + assertNotifyPendingReleaseApprovalsDoesNotContain(t, commandLogText, "gh issue create") + assertNotifyPendingReleaseApprovalsDoesNotContain(t, commandLogText, "gh issue edit") + assertNotifyPendingReleaseApprovalsDoesNotContain(t, commandLogText, "gh issue close") +} + +// Verifies that a gh run list failure aborts with a non-zero exit code +// instead of silently treating the repository as having no waiting runs. +func TestRunNotifyPendingReleaseApprovalsFailsWhenRunListFails(t *testing.T) { + deps := notifyPendingReleaseApprovalsDeps{ + runOutput: func(ctx context.Context, name string, args ...string) (string, error) { + return "", fmt.Errorf("gh: authentication failed") + }, + } + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := runNotifyPendingReleaseApprovalsWithDeps(context.Background(), &stdout, &stderr, "owner/repository", deps) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + assertNotifyPendingReleaseApprovalsContains(t, stderr.String(), "authentication failed") +} + +func assertNotifyPendingReleaseApprovalsContains(t *testing.T, actual string, expected string) { + t.Helper() + if !strings.Contains(actual, expected) { + t.Fatalf("expected %q to contain %q", actual, expected) + } +} + +func assertNotifyPendingReleaseApprovalsDoesNotContain(t *testing.T, actual string, unexpected string) { + t.Helper() + if strings.Contains(actual, unexpected) { + t.Fatalf("expected %q not to contain %q", actual, unexpected) + } +} From d9095c223c735c2dab88ccc44db1529b1eef3037 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 22 Jul 2026 21:35:34 +0900 Subject: [PATCH 2/4] Add scheduled workflow to reconcile the pending approval issue Runs every 15 minutes plus on native-cli-publish/dispatcher-publish completion, so an approval-waiting release run is never left silently stuck the way run 29917524403 was after PR #1933. --- .../release-approval-pending-notify.yml | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/release-approval-pending-notify.yml diff --git a/.github/workflows/release-approval-pending-notify.yml b/.github/workflows/release-approval-pending-notify.yml new file mode 100644 index 000000000..f13cb9d4b --- /dev/null +++ b/.github/workflows/release-approval-pending-notify.yml @@ -0,0 +1,38 @@ +name: Release Approval Pending Notify + +on: + schedule: + - cron: "*/15 * * * *" + workflow_dispatch: + workflow_run: + workflows: + - native-cli-publish + - dispatcher-publish + types: + - completed + +permissions: + actions: read + issues: write + contents: read + +jobs: + notify: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c + with: + go-version-file: cli/.go-version + cache: false + + - name: Reconcile pending release approval issue + working-directory: cli/release-automation + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: go run ./cmd/notify-pending-release-approvals From 3e7488511d73ebf55ebebaf08b78287d08aaeba0 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 22 Jul 2026 21:37:21 +0900 Subject: [PATCH 3/4] Fix gofmt const grouping and staticcheck Fprintf suggestion scripts/check-go-cli.sh flagged both on the new command. --- .../automation/notify_pending_release_approvals.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cli/release-automation/internal/automation/notify_pending_release_approvals.go b/cli/release-automation/internal/automation/notify_pending_release_approvals.go index 281622b5a..3192b8731 100644 --- a/cli/release-automation/internal/automation/notify_pending_release_approvals.go +++ b/cli/release-automation/internal/automation/notify_pending_release_approvals.go @@ -14,11 +14,13 @@ import ( // pendingApprovalIssueTitle and pendingApprovalIssueLabel identify the single // tracking issue this command reconciles: an exact title match (rather than a // generated one) keeps repeated runs from creating duplicates. -const pendingApprovalIssueTitle = "Release approval pending" -const pendingApprovalIssueLabel = "release-approval-pending" -const pendingApprovalIssueLabelDescription = "Release environment approvals waiting on manual review" -const pendingApprovalIssueLabelColor = "5319E7" -const pendingApprovalResolvedComment = "All pending approvals resolved." +const ( + pendingApprovalIssueTitle = "Release approval pending" + pendingApprovalIssueLabel = "release-approval-pending" + pendingApprovalIssueLabelDescription = "Release environment approvals waiting on manual review" + pendingApprovalIssueLabelColor = "5319E7" + pendingApprovalResolvedComment = "All pending approvals resolved." +) // pendingApprovalRun is one workflow run currently waiting on a GitHub // environment manual approval. @@ -171,7 +173,7 @@ func buildPendingApprovalIssueBody(runs []pendingApprovalRun) string { builder := strings.Builder{} builder.WriteString("The following release approval(s) are waiting on a manual review:\n\n") for _, run := range runs { - builder.WriteString(fmt.Sprintf("- Workflow: %s\n Branch: %s\n Environment: %s\n Run: %s\n\n", run.WorkflowName, run.HeadBranch, run.Environment, run.URL)) + fmt.Fprintf(&builder, "- Workflow: %s\n Branch: %s\n Environment: %s\n Run: %s\n\n", run.WorkflowName, run.HeadBranch, run.Environment, run.URL) } builder.WriteString("Approve the pending deployment(s) at the run URL(s) above. After approval, the next release-please run publishes the blocked package releases automatically.") return builder.String() From 49d599afd19a23f98be0eb92b3b42bf97e6c6413 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 22 Jul 2026 21:47:10 +0900 Subject: [PATCH 4/4] Fix workflow so it actually fires and cannot double-reconcile schedule/workflow_run only trigger from workflow files on the default branch (main), which has no cli/ directory; pin checkout to v3-beta so the scheduled run finds the reconcile implementation. Also add a concurrency group so an overlapping schedule + workflow_run trigger can't race to create duplicate tracking issues. --- .github/workflows/release-approval-pending-notify.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/release-approval-pending-notify.yml b/.github/workflows/release-approval-pending-notify.yml index f13cb9d4b..2f64b07eb 100644 --- a/.github/workflows/release-approval-pending-notify.yml +++ b/.github/workflows/release-approval-pending-notify.yml @@ -16,13 +16,22 @@ permissions: issues: write contents: read +concurrency: + group: release-approval-pending-notify + cancel-in-progress: false + jobs: notify: runs-on: ubuntu-latest steps: + # schedule/workflow_run only fire from workflow files on the default branch (main), which + # has no cli/ directory; the reconcile implementation lives on v3-beta, so this checkout + # must pin ref explicitly instead of using the triggering event's ref. - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: v3-beta - name: Setup Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c