diff --git a/.github/workflows/dispatcher-publish.yml b/.github/workflows/dispatcher-publish.yml index 90b763f91d..dfbf9e76d8 100644 --- a/.github/workflows/dispatcher-publish.yml +++ b/.github/workflows/dispatcher-publish.yml @@ -27,6 +27,7 @@ jobs: should_release: ${{ steps.release.outputs.release }} dry_run: ${{ steps.release.outputs.dry_run }} release_tag: ${{ steps.release.outputs.tag }} + release_prerelease: ${{ steps.release.outputs.prerelease }} steps: - name: Checkout approved event commit uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd @@ -392,6 +393,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + pull-requests: write + actions: write steps: - name: Checkout approved event commit uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd @@ -423,3 +426,23 @@ jobs: --repo "${{ github.repository }}" --tag "${{ needs.build.outputs.release_tag }}" --tap-repo hatayama/homebrew-tap + + # Fresh installs follow the package pin, so a published stable dispatcher + # only reaches new users once the pin records it. Pre-releases are never + # stamped on main. + - name: Open dispatcher pin stamp pull request + if: needs.build.outputs.should_publish == 'true' && needs.build.outputs.release_prerelease != 'true' + working-directory: cli/release-automation + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + RELEASE_TAG: ${{ needs.build.outputs.release_tag }} + run: | + set -eu + # The checkout kept no credentials, so give git the workflow token + # without ever printing it. + gh auth setup-git + go run ./cmd/open-dispatcher-pin-pr \ + --repo "${GITHUB_REPOSITORY}" \ + --tag "${RELEASE_TAG}" \ + --base-branch main diff --git a/cli/release-automation/cmd/open-dispatcher-pin-pr/main.go b/cli/release-automation/cmd/open-dispatcher-pin-pr/main.go new file mode 100644 index 0000000000..2b7c6cf0ba --- /dev/null +++ b/cli/release-automation/cmd/open-dispatcher-pin-pr/main.go @@ -0,0 +1,21 @@ +// open-dispatcher-pin-pr stamps the Unity package pin from a published stable +// dispatcher release and opens the pull request that carries the stamp. +package main + +import ( + "context" + "os" + "time" + + "github.com/hatayama/unity-cli-loop/tools/release-automation/internal/automation" +) + +// The command only stamps, pushes, and dispatches workflows; a longer run means +// a hung network call rather than useful work. +const openDispatcherPinPRTimeout = 15 * time.Minute + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), openDispatcherPinPRTimeout) + defer cancel() + os.Exit(automation.RunOpenDispatcherPinPR(ctx, os.Stdout, os.Stderr, os.Args[1:])) +} diff --git a/cli/release-automation/internal/automation/dispatcher_pin_pr.go b/cli/release-automation/internal/automation/dispatcher_pin_pr.go new file mode 100644 index 0000000000..f5ee1fb3b8 --- /dev/null +++ b/cli/release-automation/internal/automation/dispatcher_pin_pr.go @@ -0,0 +1,412 @@ +package automation + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +const ( + dispatcherPinPRBranchPrefix = "chore/dispatcher-pin-" + // The workflow bot identity GitHub assigns to GITHUB_TOKEN pushes, so the + // stamp commit is attributed to automation instead of a human account. + dispatcherPinPRAuthorName = "github-actions[bot]" + dispatcherPinPRAuthorEmail = "41898282+github-actions[bot]@users.noreply.github.com" +) + +type dispatcherPinPRConfig struct { + repository string + tag string + baseBranch string +} + +type dispatcherPinPRDeps struct { + runOutput func(ctx context.Context, name string, args ...string) (string, error) + repositoryRoot func(ctx context.Context) (string, error) + stampPin func(ctx context.Context, pinPath string, releaseTag string) error + verifySubjects func(ctx context.Context, packagePin []byte) error + dispatchChecks func(ctx context.Context, stdout io.Writer, repository string, headRefName string, description string) error +} + +type dispatcherPinPullRequest struct { + Number int `json:"number"` + URL string `json:"url"` +} + +// RunOpenDispatcherPinPR stamps the package pin from a published stable +// dispatcher release and opens the review pull request that carries it. +func RunOpenDispatcherPinPR(ctx context.Context, stdout io.Writer, stderr io.Writer, args []string) int { + config, err := parseDispatcherPinPRFlags(args) + if err != nil { + writeDispatcherPinPRLine(stderr, "open-dispatcher-pin-pr:", err) + return 1 + } + return runOpenDispatcherPinPRWithDeps(ctx, stdout, stderr, config, defaultDispatcherPinPRDeps()) +} + +func defaultDispatcherPinPRDeps() dispatcherPinPRDeps { + return dispatcherPinPRDeps{ + runOutput: runReleasePRCheckCommandOutput, + repositoryRoot: dispatcherPinPRRepositoryRoot, + stampPin: StampDispatcherPin, + verifySubjects: VerifyDispatcherPinSubjects, + dispatchChecks: DispatchPullRequestChecksForHead, + } +} + +func parseDispatcherPinPRFlags(args []string) (dispatcherPinPRConfig, error) { + flagSet := flag.NewFlagSet("open-dispatcher-pin-pr", flag.ContinueOnError) + repository := flagSet.String("repo", "", "GitHub repository that owns the dispatcher release") + tag := flagSet.String("tag", "", "dispatcher release tag such as dispatcher-v3.0.0") + baseBranch := flagSet.String("base-branch", "", "branch the pull request targets") + err := flagSet.Parse(args) + if err != nil { + return dispatcherPinPRConfig{}, err + } + if *repository == "" { + return dispatcherPinPRConfig{}, fmt.Errorf("--repo is required") + } + if *baseBranch == "" { + return dispatcherPinPRConfig{}, fmt.Errorf("--base-branch is required") + } + version, err := dispatcherVersionFromReleaseTag(*tag) + if err != nil { + return dispatcherPinPRConfig{}, err + } + // Pre-releases must never reach the pin on a stable branch: fresh installs + // follow the pin, so stamping a pre-release would hand every new install a + // pre-release dispatcher. + if strings.Contains(version, "-") { + return dispatcherPinPRConfig{}, fmt.Errorf("release tag %q is a pre-release and must not be stamped", *tag) + } + return dispatcherPinPRConfig{repository: *repository, tag: *tag, baseBranch: *baseBranch}, nil +} + +func runOpenDispatcherPinPRWithDeps( + ctx context.Context, + stdout io.Writer, + stderr io.Writer, + config dispatcherPinPRConfig, + deps dispatcherPinPRDeps, +) int { + repositoryRoot, err := deps.repositoryRoot(ctx) + if err != nil { + writeDispatcherPinPRLine(stderr, "open-dispatcher-pin-pr:", err) + return 1 + } + branch := dispatcherPinPRBranchPrefix + config.tag + + err = checkoutDispatcherPinPRBranch(ctx, repositoryRoot, config, branch, deps) + if err != nil { + writeDispatcherPinPRLine(stderr, "open-dispatcher-pin-pr:", err) + return 1 + } + err = stampAndVerifyDispatcherPin(ctx, repositoryRoot, config.tag, deps) + if err != nil { + writeDispatcherPinPRLine(stderr, "open-dispatcher-pin-pr:", err) + return 1 + } + + changed, err := dispatcherPinFilesChanged(ctx, repositoryRoot, deps) + if err != nil { + writeDispatcherPinPRLine(stderr, "open-dispatcher-pin-pr:", err) + return 1 + } + if !changed { + writeDispatcherPinPRLine(stdout, fmt.Sprintf("Dispatcher pin already records %s; no pull request is needed.", config.tag)) + return 0 + } + + err = commitAndPushDispatcherPin(ctx, stdout, repositoryRoot, config, branch, deps) + if err != nil { + writeDispatcherPinPRLine(stderr, "open-dispatcher-pin-pr:", err) + return 1 + } + return publishDispatcherPinPullRequest(ctx, stdout, stderr, config, branch, deps) +} + +// checkoutDispatcherPinPRBranch branches from the base tip rather than the +// workflow's checked-out release commit, so the stamp lands on whatever main +// holds when the release is published. +func checkoutDispatcherPinPRBranch( + ctx context.Context, + repositoryRoot string, + config dispatcherPinPRConfig, + branch string, + deps dispatcherPinPRDeps, +) error { + baseRef := "refs/remotes/origin/" + config.baseBranch + fetchRefspec := "+refs/heads/" + config.baseBranch + ":" + baseRef + _, err := deps.runOutput(ctx, "git", "-C", repositoryRoot, "fetch", "origin", fetchRefspec) + if err != nil { + return err + } + _, err = deps.runOutput(ctx, "git", "-C", repositoryRoot, "checkout", "-B", branch, baseRef) + return err +} + +func stampAndVerifyDispatcherPin(ctx context.Context, repositoryRoot string, releaseTag string, deps dispatcherPinPRDeps) error { + packagePinPath := filepath.Join(repositoryRoot, filepath.FromSlash(unityPackageCliPinFile)) + projectPinPath := filepath.Join(repositoryRoot, filepath.FromSlash(unityProjectCliPinFile)) + + err := deps.stampPin(ctx, packagePinPath, releaseTag) + if err != nil { + return err + } + packagePin, err := os.ReadFile(packagePinPath) + if err != nil { + return fmt.Errorf("read stamped pin %s: %w", unityPackageCliPinFile, err) + } + // The two pins must stay byte-identical; the mirror is a copy, never a re-encode. + err = os.WriteFile(projectPinPath, packagePin, 0o644) + if err != nil { + return fmt.Errorf("mirror stamped pin to %s: %w", unityProjectCliPinFile, err) + } + // Read the mirror back instead of trusting the write, so the guard sees the + // same bytes CI will compare. + projectPin, err := os.ReadFile(projectPinPath) + if err != nil { + return fmt.Errorf("read mirrored pin %s: %w", unityProjectCliPinFile, err) + } + err = ValidateDispatcherPinOffline(packagePin, projectPin) + if err != nil { + return err + } + return deps.verifySubjects(ctx, packagePin) +} + +func dispatcherPinFilesChanged(ctx context.Context, repositoryRoot string, deps dispatcherPinPRDeps) (bool, error) { + output, err := deps.runOutput( + ctx, "git", "-C", repositoryRoot, "status", "--porcelain", "--", + unityPackageCliPinFile, unityProjectCliPinFile) + if err != nil { + return false, err + } + return strings.TrimSpace(output) != "", nil +} + +func commitAndPushDispatcherPin( + ctx context.Context, + stdout io.Writer, + repositoryRoot string, + config dispatcherPinPRConfig, + branch string, + deps dispatcherPinPRDeps, +) error { + version := strings.TrimPrefix(config.tag, dispatcherPinTagPrefix) + _, err := deps.runOutput( + ctx, "git", "-C", repositoryRoot, "add", "--", + unityPackageCliPinFile, unityProjectCliPinFile) + if err != nil { + return err + } + _, err = deps.runOutput( + ctx, "git", "-C", repositoryRoot, + "-c", "user.name="+dispatcherPinPRAuthorName, + "-c", "user.email="+dispatcherPinPRAuthorEmail, + "commit", + "--message", dispatcherPinPRTitle(version), + "--message", dispatcherPinPRCommitBody(config.tag)) + if err != nil { + return err + } + err = pushDispatcherPinBranch(ctx, repositoryRoot, branch, deps) + if err != nil { + return err + } + writeDispatcherPinPRLine(stdout, fmt.Sprintf("Pushed the stamped dispatcher pin to %s.", branch)) + return nil +} + +// pushDispatcherPinBranch replaces an earlier attempt's branch only when the +// remote still holds the commit this run observed, so a concurrent push is +// never discarded. +func pushDispatcherPinBranch(ctx context.Context, repositoryRoot string, branch string, deps dispatcherPinPRDeps) error { + branchRef := "refs/heads/" + branch + output, err := deps.runOutput(ctx, "git", "-C", repositoryRoot, "ls-remote", "--heads", "origin", branchRef) + if err != nil { + return err + } + remoteSHA := dispatcherPinRemoteBranchSHA(output) + if remoteSHA == "" { + _, err = deps.runOutput(ctx, "git", "-C", repositoryRoot, "push", "origin", "HEAD:"+branchRef) + return err + } + _, err = deps.runOutput( + ctx, "git", "-C", repositoryRoot, "push", + "--force-with-lease="+branchRef+":"+remoteSHA, + "origin", "HEAD:"+branchRef) + return err +} + +func dispatcherPinRemoteBranchSHA(lsRemoteOutput string) string { + for _, line := range strings.Split(lsRemoteOutput, "\n") { + sha, _, found := strings.Cut(strings.TrimSpace(line), "\t") + if found && sha != "" { + return sha + } + } + return "" +} + +func publishDispatcherPinPullRequest( + ctx context.Context, + stdout io.Writer, + stderr io.Writer, + config dispatcherPinPRConfig, + branch string, + deps dispatcherPinPRDeps, +) int { + pullRequest, err := ensureDispatcherPinPullRequest(ctx, config, branch, deps) + if err != nil { + writeDispatcherPinPRLine(stderr, "open-dispatcher-pin-pr:", err) + return 1 + } + writeDispatcherPinPRLine(stdout, fmt.Sprintf("Dispatcher pin pull request #%d is open: %s", pullRequest.Number, pullRequest.URL)) + + description := fmt.Sprintf("dispatcher pin PR #%d: %s", pullRequest.Number, pullRequest.URL) + err = deps.dispatchChecks(ctx, stdout, config.repository, branch, description) + if err != nil { + writeDispatcherPinPRLine(stderr, "open-dispatcher-pin-pr:", err) + return 1 + } + return 0 +} + +func ensureDispatcherPinPullRequest( + ctx context.Context, + config dispatcherPinPRConfig, + branch string, + deps dispatcherPinPRDeps, +) (dispatcherPinPullRequest, error) { + version := strings.TrimPrefix(config.tag, dispatcherPinTagPrefix) + bodyFile, cleanup, err := writePullRequestBodyFile(dispatcherPinPRBody(config.tag, version)) + if err != nil { + return dispatcherPinPullRequest{}, err + } + defer cleanup() + + existing, found, err := findDispatcherPinPullRequest(ctx, config, branch, deps) + if err != nil { + return dispatcherPinPullRequest{}, err + } + if found { + _, err = deps.runOutput( + ctx, "gh", "pr", "edit", strconv.Itoa(existing.Number), + "--repo", config.repository, + "--title", dispatcherPinPRTitle(version), + "--body-file", bodyFile) + if err != nil { + return dispatcherPinPullRequest{}, err + } + return existing, nil + } + + _, err = deps.runOutput( + ctx, "gh", "pr", "create", + "--repo", config.repository, + "--base", config.baseBranch, + "--head", branch, + "--title", dispatcherPinPRTitle(version), + "--body-file", bodyFile) + if err != nil { + return dispatcherPinPullRequest{}, err + } + + created, found, err := findDispatcherPinPullRequest(ctx, config, branch, deps) + if err != nil { + return dispatcherPinPullRequest{}, err + } + if !found { + return dispatcherPinPullRequest{}, fmt.Errorf("created pull request for %s is not listed as open", branch) + } + return created, nil +} + +func findDispatcherPinPullRequest( + ctx context.Context, + config dispatcherPinPRConfig, + branch string, + deps dispatcherPinPRDeps, +) (dispatcherPinPullRequest, bool, error) { + output, err := deps.runOutput( + ctx, "gh", "pr", "list", + "--repo", config.repository, + "--state", "open", + "--base", config.baseBranch, + "--head", branch, + "--json", "number,url") + if err != nil { + return dispatcherPinPullRequest{}, false, err + } + pullRequests := []dispatcherPinPullRequest{} + err = json.Unmarshal([]byte(output), &pullRequests) + if err != nil { + return dispatcherPinPullRequest{}, false, fmt.Errorf("failed to parse dispatcher pin pull request list: %w", err) + } + if len(pullRequests) == 0 { + return dispatcherPinPullRequest{}, false, nil + } + if len(pullRequests) > 1 { + return dispatcherPinPullRequest{}, false, fmt.Errorf("expected one open pull request for %s, found %d", branch, len(pullRequests)) + } + return pullRequests[0], true, nil +} + +func dispatcherPinPRTitle(version string) string { + return fmt.Sprintf("chore: update dispatcher pin to the %s stable release", version) +} + +func dispatcherPinPRCommitBody(releaseTag string) string { + return "Fresh installs resolve the dispatcher named by this pin, so until it records " + + releaseTag + " every new install keeps landing on the previously pinned release.\n\n" + + "minimumDispatcherVersion stays as it is: the package does not require a newer " + + "dispatcher, and raising the floor would lock out working installs." +} + +func dispatcherPinPRBody(releaseTag string, version string) string { + return strings.Join([]string{ + "## Summary", + "- Fresh CLI installs now resolve the " + version + " stable dispatcher instead of the previously pinned release.", + "", + "## User Impact", + "- Before: `scripts/install.sh`, `scripts/install.ps1`, and Unity's Install CLI button all follow this pin, so a fresh install lands on the dispatcher release recorded before " + releaseTag + ".", + "- After: fresh installs resolve `" + releaseTag + "` with its attestation-verified asset digests.", + "", + "## Changes", + "- Stamped `dispatcherReleaseTag` and `dispatcherArchiveManifest` from the published `" + releaseTag + "` release.", + "- `" + unityPackageCliPinFile + "` and `" + unityProjectCliPinFile + "` kept byte-identical.", + "- `minimumDispatcherVersion` intentionally unchanged: the package does not require a newer dispatcher, and raising the floor would lock out working installs.", + "", + "## Verification", + "- The stamp verified the published release attestations while writing the manifest.", + "- Offline pin validation and a re-verification against the published release subjects both passed before this pull request was opened.", + "- Required checks are dispatched by automation, because a pull request created with `GITHUB_TOKEN` does not trigger `pull_request` workflows.", + "", + "Opened automatically by the `dispatcher-publish` workflow after publishing `" + releaseTag + "`.", + "", + }, "\n") +} + +func dispatcherPinPRRepositoryRoot(ctx context.Context) (string, error) { + output, err := runReleasePRCheckCommandOutput(ctx, "git", "rev-parse", "--show-toplevel") + if err != nil { + return "", fmt.Errorf("resolve repository root: %w", err) + } + root := strings.TrimSpace(output) + if root == "" { + return "", fmt.Errorf("git returned an empty repository root") + } + return root, nil +} + +func writeDispatcherPinPRLine(writer io.Writer, values ...any) { + // CI status output failures cannot be recovered after the command outcome is known. + _, _ = fmt.Fprintln(writer, values...) +} diff --git a/cli/release-automation/internal/automation/dispatcher_pin_pr_test.go b/cli/release-automation/internal/automation/dispatcher_pin_pr_test.go new file mode 100644 index 0000000000..cfecca8e07 --- /dev/null +++ b/cli/release-automation/internal/automation/dispatcher_pin_pr_test.go @@ -0,0 +1,270 @@ +package automation + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +const dispatcherPinPRStableTag = "dispatcher-v3.0.1" + +// dispatcherPinPRCommandRecorder fakes every git and gh invocation so the +// command can be exercised without a repository or network. +type dispatcherPinPRCommandRecorder struct { + commands []string + statusOutput string + lsRemoteOutput string + prListOutputs []string +} + +func (recorder *dispatcherPinPRCommandRecorder) run(_ context.Context, name string, args ...string) (string, error) { + command := name + " " + strings.Join(args, " ") + recorder.commands = append(recorder.commands, command) + if name == "git" && containsDispatcherPinPRArg(args, "status") { + return recorder.statusOutput, nil + } + if name == "git" && containsDispatcherPinPRArg(args, "ls-remote") { + return recorder.lsRemoteOutput, nil + } + if name == "gh" && containsDispatcherPinPRArg(args, "list") { + return recorder.nextPRListOutput(), nil + } + return "", nil +} + +func (recorder *dispatcherPinPRCommandRecorder) nextPRListOutput() string { + if len(recorder.prListOutputs) == 0 { + return "[]" + } + output := recorder.prListOutputs[0] + if len(recorder.prListOutputs) > 1 { + recorder.prListOutputs = recorder.prListOutputs[1:] + } + return output +} + +func containsDispatcherPinPRArg(args []string, wanted string) bool { + for _, arg := range args { + if arg == wanted { + return true + } + } + return false +} + +func TestRunOpenDispatcherPinPRRejectsPreReleaseTag(t *testing.T) { + // Verifies a pre-release dispatcher tag is refused before any git or gh command runs. + recorder := &dispatcherPinPRCommandRecorder{} + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunOpenDispatcherPinPR(context.Background(), &stdout, &stderr, []string{ + "--repo", "example/repository", + "--tag", "dispatcher-v3.0.1-beta.2", + "--base-branch", "main", + }) + + if exitCode != 1 { + t.Fatalf("expected a pre-release tag to fail, got exit code %d", exitCode) + } + if !strings.Contains(stderr.String(), "pre-release") { + t.Fatalf("expected a pre-release message, got %q", stderr.String()) + } + if len(recorder.commands) != 0 { + t.Fatalf("expected no commands to run, got %v", recorder.commands) + } +} + +func TestRunOpenDispatcherPinPROpensPullRequestForStableRelease(t *testing.T) { + // Verifies a stable release stamps both pins, pushes a new branch, creates the PR, and dispatches its checks. + repositoryRoot := setupDispatcherPinPRRepository(t, dispatcherPinPRContent("dispatcher-v3.0.0")) + recorder := &dispatcherPinPRCommandRecorder{ + statusOutput: " M Packages/src/project-runner-pin.json\n", + prListOutputs: []string{"[]", `[{"number":4242,"url":"https://example.test/pr/4242"}]`}, + } + dispatchedHeadRef := "" + deps := dispatcherPinPRTestDeps(repositoryRoot, recorder, dispatcherPinPRContent(dispatcherPinPRStableTag)) + deps.dispatchChecks = func(_ context.Context, _ io.Writer, _ string, headRefName string, _ string) error { + dispatchedHeadRef = headRefName + return nil + } + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + exitCode := runOpenDispatcherPinPRWithDeps(context.Background(), &stdout, &stderr, dispatcherPinPRTestConfig(), deps) + + if exitCode != 0 { + t.Fatalf("open-dispatcher-pin-pr failed: stdout=%s stderr=%s", stdout.String(), stderr.String()) + } + branch := "chore/dispatcher-pin-" + dispatcherPinPRStableTag + assertDispatcherPinPRCommand(t, recorder, "git -C "+repositoryRoot+" checkout -B "+branch+" refs/remotes/origin/main") + assertDispatcherPinPRCommand(t, recorder, "push origin HEAD:refs/heads/"+branch) + assertDispatcherPinPRCommand(t, recorder, "gh pr create --repo example/repository --base main --head "+branch) + if dispatchedHeadRef != branch { + t.Fatalf("expected checks dispatched for %q, got %q", branch, dispatchedHeadRef) + } + assertDispatcherPinPRMirrored(t, repositoryRoot) +} + +func TestRunOpenDispatcherPinPRSkipsWhenPinAlreadyRecordsTheRelease(t *testing.T) { + // Verifies a re-run on an already stamped pin exits successfully without committing, pushing, or opening a PR. + stampedPin := dispatcherPinPRContent(dispatcherPinPRStableTag) + repositoryRoot := setupDispatcherPinPRRepository(t, stampedPin) + recorder := &dispatcherPinPRCommandRecorder{statusOutput: ""} + deps := dispatcherPinPRTestDeps(repositoryRoot, recorder, stampedPin) + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + exitCode := runOpenDispatcherPinPRWithDeps(context.Background(), &stdout, &stderr, dispatcherPinPRTestConfig(), deps) + + if exitCode != 0 { + t.Fatalf("expected an idempotent re-run to succeed: stderr=%s", stderr.String()) + } + if !strings.Contains(stdout.String(), "already records "+dispatcherPinPRStableTag) { + t.Fatalf("expected an already-stamped message, got %q", stdout.String()) + } + assertNoDispatcherPinPRCommand(t, recorder, "commit") + assertNoDispatcherPinPRCommand(t, recorder, "push") + assertNoDispatcherPinPRCommand(t, recorder, "gh pr") +} + +func TestRunOpenDispatcherPinPRUpdatesAnExistingBranchAndPullRequest(t *testing.T) { + // Verifies a re-run over an existing remote branch force-pushes with a lease and edits the open PR instead of creating one. + repositoryRoot := setupDispatcherPinPRRepository(t, dispatcherPinPRContent("dispatcher-v3.0.0")) + remoteSHA := "0123456789012345678901234567890123456789" + branch := "chore/dispatcher-pin-" + dispatcherPinPRStableTag + recorder := &dispatcherPinPRCommandRecorder{ + statusOutput: " M Packages/src/project-runner-pin.json\n", + lsRemoteOutput: remoteSHA + "\trefs/heads/" + branch + "\n", + prListOutputs: []string{`[{"number":77,"url":"https://example.test/pr/77"}]`}, + } + deps := dispatcherPinPRTestDeps(repositoryRoot, recorder, dispatcherPinPRContent(dispatcherPinPRStableTag)) + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + exitCode := runOpenDispatcherPinPRWithDeps(context.Background(), &stdout, &stderr, dispatcherPinPRTestConfig(), deps) + + if exitCode != 0 { + t.Fatalf("open-dispatcher-pin-pr failed: stdout=%s stderr=%s", stdout.String(), stderr.String()) + } + assertDispatcherPinPRCommand(t, recorder, "push --force-with-lease=refs/heads/"+branch+":"+remoteSHA) + assertDispatcherPinPRCommand(t, recorder, "gh pr edit 77 --repo example/repository") + assertNoDispatcherPinPRCommand(t, recorder, "gh pr create") +} + +func TestRunOpenDispatcherPinPRStopsBeforePushWhenTheStampedPinIsInvalid(t *testing.T) { + // Verifies a stamp that fails offline pin validation never reaches commit or push. + repositoryRoot := setupDispatcherPinPRRepository(t, dispatcherPinPRContent("dispatcher-v3.0.0")) + recorder := &dispatcherPinPRCommandRecorder{statusOutput: " M Packages/src/project-runner-pin.json\n"} + deps := dispatcherPinPRTestDeps(repositoryRoot, recorder, `{"projectRunnerVersion":"3.0.1","minimumDispatcherVersion":"3.0.0","dispatcherReleaseTag":"dispatcher-v3.0.1","dispatcherArchiveManifest":""}`+"\n") + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + exitCode := runOpenDispatcherPinPRWithDeps(context.Background(), &stdout, &stderr, dispatcherPinPRTestConfig(), deps) + + if exitCode != 1 { + t.Fatalf("expected an invalid stamped pin to fail, got exit code %d", exitCode) + } + if !strings.Contains(stderr.String(), "dispatcherArchiveManifest is empty") { + t.Fatalf("expected the offline pin guard message, got %q", stderr.String()) + } + assertNoDispatcherPinPRCommand(t, recorder, "commit") + assertNoDispatcherPinPRCommand(t, recorder, "push") +} + +func dispatcherPinPRTestConfig() dispatcherPinPRConfig { + return dispatcherPinPRConfig{ + repository: "example/repository", + tag: dispatcherPinPRStableTag, + baseBranch: "main", + } +} + +func dispatcherPinPRTestDeps( + repositoryRoot string, + recorder *dispatcherPinPRCommandRecorder, + stampedPin string, +) dispatcherPinPRDeps { + return dispatcherPinPRDeps{ + runOutput: recorder.run, + repositoryRoot: func(context.Context) (string, error) { return repositoryRoot, nil }, + stampPin: func(_ context.Context, pinPath string, _ string) error { + return os.WriteFile(pinPath, []byte(stampedPin), 0o644) + }, + verifySubjects: func(context.Context, []byte) error { return nil }, + dispatchChecks: func(context.Context, io.Writer, string, string, string) error { return nil }, + } +} + +// dispatcherPinPRContent renders a pin that passes the offline guard for the +// given dispatcher release tag. +func dispatcherPinPRContent(releaseTag string) string { + manifest := strings.Join([]string{ + "1111111111111111111111111111111111111111111111111111111111111111 install.ps1", + "2222222222222222222222222222222222222222222222222222222222222222 install.sh", + "3333333333333333333333333333333333333333333333333333333333333333 uloop-dispatcher-darwin-amd64.tar.gz", + "4444444444444444444444444444444444444444444444444444444444444444 uloop-dispatcher-darwin-arm64.tar.gz", + "5555555555555555555555555555555555555555555555555555555555555555 uloop-dispatcher-windows-amd64.zip", + }, "\\n") + return `{ + "projectRunnerVersion": "3.0.1", + "minimumDispatcherVersion": "3.0.0", + "dispatcherReleaseTag": "` + releaseTag + `", + "dispatcherArchiveManifest": "` + manifest + `" +} +` +} + +func setupDispatcherPinPRRepository(t *testing.T, pinContent string) string { + t.Helper() + repositoryRoot := t.TempDir() + packagePinPath := filepath.Join(repositoryRoot, filepath.FromSlash(unityPackageCliPinFile)) + projectPinPath := filepath.Join(repositoryRoot, filepath.FromSlash(unityProjectCliPinFile)) + for _, pinPath := range []string{packagePinPath, projectPinPath} { + if err := os.MkdirAll(filepath.Dir(pinPath), 0o755); err != nil { + t.Fatalf("failed to create pin directory: %v", err) + } + if err := os.WriteFile(pinPath, []byte(pinContent), 0o644); err != nil { + t.Fatalf("failed to write pin: %v", err) + } + } + return repositoryRoot +} + +func assertDispatcherPinPRMirrored(t *testing.T, repositoryRoot string) { + t.Helper() + packagePin, err := os.ReadFile(filepath.Join(repositoryRoot, filepath.FromSlash(unityPackageCliPinFile))) + if err != nil { + t.Fatalf("failed to read package pin: %v", err) + } + projectPin, err := os.ReadFile(filepath.Join(repositoryRoot, filepath.FromSlash(unityProjectCliPinFile))) + if err != nil { + t.Fatalf("failed to read project pin: %v", err) + } + if !bytes.Equal(packagePin, projectPin) { + t.Fatalf("mirrored pin differs from the package pin") + } +} + +func assertDispatcherPinPRCommand(t *testing.T, recorder *dispatcherPinPRCommandRecorder, wanted string) { + t.Helper() + for _, command := range recorder.commands { + if strings.Contains(command, wanted) { + return + } + } + t.Fatalf("expected a command containing %q, got %v", wanted, recorder.commands) +} + +func assertNoDispatcherPinPRCommand(t *testing.T, recorder *dispatcherPinPRCommandRecorder, unwanted string) { + t.Helper() + for _, command := range recorder.commands { + if strings.Contains(command, unwanted) { + t.Fatalf("expected no command containing %q, got %q", unwanted, command) + } + } +} diff --git a/cli/release-automation/internal/automation/pull_request_checks.go b/cli/release-automation/internal/automation/pull_request_checks.go new file mode 100644 index 0000000000..aa673793a1 --- /dev/null +++ b/cli/release-automation/internal/automation/pull_request_checks.go @@ -0,0 +1,80 @@ +package automation + +import ( + "context" + "fmt" + "io" + "os" + "time" +) + +// DispatchPullRequestChecksForHead starts every required check workflow on an +// explicit head ref. Why this exists: a pull request opened with GITHUB_TOKEN +// never triggers pull_request workflows, so its required checks only appear +// when automation dispatches them. description labels the log lines with the +// pull request the dispatch belongs to. +func DispatchPullRequestChecksForHead( + ctx context.Context, + stdout io.Writer, + repository string, + headRefName string, + description string, +) error { + if repository == "" { + return fmt.Errorf("repository is required to dispatch pull request checks") + } + if headRefName == "" { + return fmt.Errorf("head ref is required to dispatch pull request checks") + } + workflows, err := releasePRCheckWorkflowsFromEnvironment() + if err != nil { + return err + } + _, err = dispatchPullRequestCheckWorkflows(ctx, stdout, repository, headRefName, description, workflows, defaultReleasePRCheckDeps()) + return err +} + +// dispatchPullRequestCheckWorkflows dispatches the workflows and reports the +// instant the first dispatch was issued, which callers use to ignore workflow +// runs that predate their own dispatch. +func dispatchPullRequestCheckWorkflows( + ctx context.Context, + stdout io.Writer, + repository string, + headRefName string, + description string, + workflows []string, + deps releasePRCheckDeps, +) (time.Time, error) { + dispatchedAt := deps.now().UTC().Truncate(time.Second) + for _, workflow := range workflows { + writeReleasePRCheckLine(stdout, fmt.Sprintf("Dispatching %s for %s", workflow, description)) + _, err := deps.runOutput(ctx, "gh", "workflow", "run", workflow, "--repo", repository, "--ref", headRefName) + if err != nil { + return time.Time{}, err + } + } + return dispatchedAt, nil +} + +// writePullRequestBodyFile stages a pull request body on disk because gh only +// accepts multi-line bodies through --body-file. +func writePullRequestBodyFile(body string) (string, func(), error) { + bodyFile, err := os.CreateTemp("", "uloop-pull-request-body-*.md") + if err != nil { + return "", func() {}, fmt.Errorf("failed to create pull request 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 pull request body file: %w", writeErr) + } + if closeErr != nil { + cleanup() + return "", func() {}, fmt.Errorf("failed to close pull request body file: %w", closeErr) + } + return bodyFile.Name(), cleanup, nil +} diff --git a/cli/release-automation/internal/automation/pull_request_checks_test.go b/cli/release-automation/internal/automation/pull_request_checks_test.go new file mode 100644 index 0000000000..6eaac202e0 --- /dev/null +++ b/cli/release-automation/internal/automation/pull_request_checks_test.go @@ -0,0 +1,56 @@ +package automation + +import ( + "bytes" + "context" + "strings" + "testing" + "time" +) + +func TestDispatchPullRequestCheckWorkflowsUsesTheGivenHeadRef(t *testing.T) { + // Verifies each required workflow is dispatched on the head ref passed in, not on a release-please branch name. + commands := []string{} + deps := releasePRCheckDeps{ + now: func() time.Time { return time.Unix(0, 0).UTC() }, + sleep: func(context.Context, time.Duration) error { return nil }, + runOutput: func(_ context.Context, name string, args ...string) (string, error) { + commands = append(commands, name+" "+strings.Join(args, " ")) + return "", nil + }, + } + stdout := bytes.Buffer{} + + _, err := dispatchPullRequestCheckWorkflows( + context.Background(), &stdout, "example/repository", "chore/dispatcher-pin-dispatcher-v3.0.1", + "dispatcher pin PR #7: https://example.test/pr/7", + []string{"build-and-test.yml", "unity-compile-check-and-test-runner.yml"}, deps) + if err != nil { + t.Fatalf("dispatchPullRequestCheckWorkflows failed: %v", err) + } + + wanted := []string{ + "gh workflow run build-and-test.yml --repo example/repository --ref chore/dispatcher-pin-dispatcher-v3.0.1", + "gh workflow run unity-compile-check-and-test-runner.yml --repo example/repository --ref chore/dispatcher-pin-dispatcher-v3.0.1", + } + for index, wantedCommand := range wanted { + if index >= len(commands) || commands[index] != wantedCommand { + t.Fatalf("expected command %q, got %v", wantedCommand, commands) + } + } + if !strings.Contains(stdout.String(), "Dispatching build-and-test.yml for dispatcher pin PR #7: https://example.test/pr/7") { + t.Fatalf("expected the dispatch log to name the pull request, got %q", stdout.String()) + } +} + +func TestDispatchPullRequestChecksForHeadRequiresARepositoryAndHeadRef(t *testing.T) { + // Verifies the exported entry point refuses to dispatch without both a repository and a head ref. + err := DispatchPullRequestChecksForHead(context.Background(), &bytes.Buffer{}, "", "some-branch", "") + if err == nil { + t.Fatal("expected a missing repository to fail") + } + err = DispatchPullRequestChecksForHead(context.Background(), &bytes.Buffer{}, "example/repository", "", "") + if err == nil { + t.Fatal("expected a missing head ref to fail") + } +} diff --git a/cli/release-automation/internal/automation/release_pr_check_runs.go b/cli/release-automation/internal/automation/release_pr_check_runs.go index 235f28a57e..9dfcf1388d 100644 --- a/cli/release-automation/internal/automation/release_pr_check_runs.go +++ b/cli/release-automation/internal/automation/release_pr_check_runs.go @@ -14,11 +14,6 @@ type releaseWorkflowRun struct { CreatedAt string `json:"createdAt"` } -func dispatchReleasePRCheckWorkflow(ctx context.Context, config releasePRCheckConfig, workflow string, releasePR releasePullRequest, deps releasePRCheckDeps) error { - _, err := deps.runOutput(ctx, "gh", "workflow", "run", workflow, "--repo", config.repository, "--ref", releasePR.HeadRefName) - return err -} - func findDispatchedReleasePRCheckRun( ctx context.Context, config releasePRCheckConfig, diff --git a/cli/release-automation/internal/automation/release_pr_checks.go b/cli/release-automation/internal/automation/release_pr_checks.go index fa4eed7198..dddc0f3fa0 100644 --- a/cli/release-automation/internal/automation/release_pr_checks.go +++ b/cli/release-automation/internal/automation/release_pr_checks.go @@ -147,14 +147,12 @@ func dispatchAndWatchReleasePRCheckWorkflows( releasePR releasePullRequest, deps releasePRCheckDeps, ) (string, int) { - dispatchedAt := deps.now().UTC().Truncate(time.Second) - for _, workflow := range config.workflows { - writeReleasePRCheckLine(stdout, fmt.Sprintf("Dispatching %s for release PR #%d: %s", workflow, releasePR.Number, releasePR.URL)) - err := dispatchReleasePRCheckWorkflow(ctx, config, workflow, releasePR, deps) - if err != nil { - writeReleasePRCheckLine(stderr, err) - return "", 1 - } + description := fmt.Sprintf("release PR #%d: %s", releasePR.Number, releasePR.URL) + dispatchedAt, err := dispatchPullRequestCheckWorkflows( + ctx, stdout, config.repository, releasePR.HeadRefName, description, config.workflows, deps) + if err != nil { + writeReleasePRCheckLine(stderr, err) + return "", 1 } checkedHeadSHA := "" @@ -394,7 +392,7 @@ func clarifyReleasePRCheckBody(ctx context.Context, config releasePRCheckConfig, return false, nil } - bodyFile, cleanup, err := writeReleasePRCheckBodyFile(clarifiedBody) + bodyFile, cleanup, err := writePullRequestBodyFile(clarifiedBody) if err != nil { return false, err } @@ -407,26 +405,6 @@ func clarifyReleasePRCheckBody(ctx context.Context, config releasePRCheckConfig, return true, nil } -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 verifyReleasePRCheckHeadMatchesRun( ctx context.Context, config releasePRCheckConfig, diff --git a/docs/dispatcher-pin-release-order.md b/docs/dispatcher-pin-release-order.md index 868d878b42..d8de75356c 100644 --- a/docs/dispatcher-pin-release-order.md +++ b/docs/dispatcher-pin-release-order.md @@ -4,10 +4,23 @@ The Unity package uses a provenance-pinned dispatcher Release for first installation. Package release preparation must follow this order: 1. Publish the dispatcher Release and its Sigstore attestations. -2. Run `stamp-dispatcher-pin` against that immutable Release. +2. Stamp the pin against that immutable Release. For a **stable** release the + `dispatcher-publish` workflow does this on its own: after publishing, its + `post-publish` job runs `open-dispatcher-pin-pr`, which branches from `main` + as `chore/dispatcher-pin-`, stamps `Packages/src/project-runner-pin.json`, + mirrors it byte-identically to `.uloop/project-runner-pin.json`, re-verifies + the stamp offline and against the published release, and opens the pull + request for a human to review and merge. It leaves `minimumDispatcherVersion` + alone, is idempotent (a re-run with nothing to change opens no pull request), + and never stamps a **pre-release** — those stay off `main`. `stamp-dispatcher-pin` + remains the manual fallback when the automated pull request has to be redone by hand. 3. Verify the resulting pin with `check-dispatcher-pin` and publish the Unity package. +A pull request created with `GITHUB_TOKEN` does not trigger `pull_request` +workflows, so `open-dispatcher-pin-pr` dispatches the required check workflows +itself once the pull request exists. + Changing `scripts/install.sh` or `scripts/install.ps1` does not immediately change the Unity first-install path. Unity downloads the script from the Release named in its package pin. The change becomes active only after a new diff --git a/docs/project-runner-pin.md b/docs/project-runner-pin.md index 246cf37fbc..827170094d 100644 --- a/docs/project-runner-pin.md +++ b/docs/project-runner-pin.md @@ -13,7 +13,9 @@ for cross-component version requirements. Its required fields: needs a newly published dispatcher, not because the dispatcher implementation changed. - `dispatcherReleaseTag` and `dispatcherArchiveManifest` — the provenance-pinned dispatcher release used for first installation and its verified asset hashes. Stamped by automation - against a published release; never edit by hand — `VerifyDispatcherPinSubjects` requires the + against a published release: publishing a **stable** dispatcher release makes the + `dispatcher-publish` workflow open a pull request that carries the new stamp (pre-releases are + never stamped on `main`). Never edit by hand — `VerifyDispatcherPinSubjects` requires the manifest to match the published release's verified subjects exactly, so a hand-written value cannot pass CI (see `docs/dispatcher-pin-release-order.md`). Consumers: Unity's **Install CLI** path (via `CliPinReader` / `NativeCliCommandBuilder`) and the terminal installers