diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 37e3daaddf..92e2eaceea 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -93,6 +93,16 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: go run ./cmd/check-dispatcher-pin --network + # Not on pull_request: while the automated pin-stamp pull request is open, the pin on every + # other branch legitimately lags the newest stable dispatcher release, and failing there would + # turn all open pull requests red for a state only main can fix. Failing on main is the point. + - name: Check dispatcher pin freshness + if: github.event_name != 'pull_request' + working-directory: cli/release-automation + env: + GITHUB_TOKEN: ${{ github.token }} + run: go run ./cmd/check-dispatcher-pin-freshness + - name: Check release triggers if: github.event_name == 'pull_request' working-directory: cli/release-automation diff --git a/cli/release-automation/cmd/check-dispatcher-pin-freshness/main.go b/cli/release-automation/cmd/check-dispatcher-pin-freshness/main.go new file mode 100644 index 0000000000..fe28f5a426 --- /dev/null +++ b/cli/release-automation/cmd/check-dispatcher-pin-freshness/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "context" + "os" + "time" + + "github.com/hatayama/unity-cli-loop/tools/release-automation/internal/automation" +) + +const dispatcherPinFreshnessCheckTimeout = 2 * time.Minute + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), dispatcherPinFreshnessCheckTimeout) + exitCode := automation.RunDispatcherPinFreshnessCheck(ctx, os.Stdout, os.Stderr, os.Args[1:]) + cancel() + os.Exit(exitCode) +} diff --git a/cli/release-automation/internal/automation/dispatcher_pin_freshness.go b/cli/release-automation/internal/automation/dispatcher_pin_freshness.go new file mode 100644 index 0000000000..dafd85899f --- /dev/null +++ b/cli/release-automation/internal/automation/dispatcher_pin_freshness.go @@ -0,0 +1,232 @@ +package automation + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + sharedversion "github.com/hatayama/unity-cli-loop/common/version" + "github.com/hatayama/unity-cli-loop/dispatcher/attestation" +) + +const ( + dispatcherPinFreshnessCommandName = "check-dispatcher-pin-freshness" + dispatcherPinFreshnessPageSize = 100 + // A listing this long can only mean the API is paging in a loop; failing is + // safer than reporting a "newest" release derived from a truncated listing. + dispatcherPinFreshnessMaxPages = 50 + dispatcherPinFreshnessRequestTimeout = 30 * time.Second +) + +// dispatcherRelease describes the subset of a GitHub Release needed to decide +// whether it is a published stable dispatcher release. +type dispatcherRelease struct { + TagName string `json:"tag_name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` +} + +type dispatcherPinFreshnessConfig struct { + repository string + pinPath string +} + +type dispatcherPinFreshnessDeps struct { + fetchReleases func(ctx context.Context, repository string) ([]dispatcherRelease, error) + readPin func(path string) ([]byte, error) +} + +// RunDispatcherPinFreshnessCheck fails when the package pin still records an +// older dispatcher release than the newest published stable one, because fresh +// installs follow the pin and would keep landing on the superseded release. +func RunDispatcherPinFreshnessCheck(ctx context.Context, stdout io.Writer, stderr io.Writer, args []string) int { + config, err := parseDispatcherPinFreshnessFlags(args) + if err != nil { + writeDispatcherPinFreshnessLine(stderr, dispatcherPinFreshnessCommandName+":", err) + return 1 + } + return runDispatcherPinFreshnessCheck(ctx, stdout, stderr, config, defaultDispatcherPinFreshnessDeps()) +} + +func defaultDispatcherPinFreshnessDeps() dispatcherPinFreshnessDeps { + return dispatcherPinFreshnessDeps{ + fetchReleases: fetchDispatcherReleases, + readPin: os.ReadFile, + } +} + +func parseDispatcherPinFreshnessFlags(args []string) (dispatcherPinFreshnessConfig, error) { + flagSet := flag.NewFlagSet(dispatcherPinFreshnessCommandName, flag.ContinueOnError) + repository := flagSet.String("repo", "", "GitHub repository that publishes dispatcher releases") + err := flagSet.Parse(args) + if err != nil { + return dispatcherPinFreshnessConfig{}, err + } + return dispatcherPinFreshnessConfig{ + repository: resolveDispatcherPinFreshnessRepository(*repository), + // The command runs from cli/release-automation, matching check-dispatcher-pin. + pinPath: filepath.Join("..", "..", filepath.FromSlash(unityPackageCliPinFile)), + }, nil +} + +// resolveDispatcherPinFreshnessRepository prefers the explicit flag, then the +// workflow's repository, and finally the repository that owns the releases the +// pin is stamped from. +func resolveDispatcherPinFreshnessRepository(flagValue string) string { + if flagValue != "" { + return flagValue + } + if environmentValue := os.Getenv("GITHUB_REPOSITORY"); environmentValue != "" { + return environmentValue + } + return attestation.ReleaseRepository +} + +func runDispatcherPinFreshnessCheck( + ctx context.Context, + stdout io.Writer, + stderr io.Writer, + config dispatcherPinFreshnessConfig, + deps dispatcherPinFreshnessDeps, +) int { + pinnedTag, pinnedVersion, err := readPinnedDispatcherRelease(config, deps) + if err != nil { + writeDispatcherPinFreshnessLine(stderr, dispatcherPinFreshnessCommandName+":", err) + return 1 + } + releases, err := deps.fetchReleases(ctx, config.repository) + if err != nil { + writeDispatcherPinFreshnessLine(stderr, dispatcherPinFreshnessCommandName+":", err) + return 1 + } + newestTag, newestVersion := newestStableDispatcherRelease(releases) + if newestVersion == "" { + writeDispatcherPinFreshnessLine(stdout, fmt.Sprintf( + "No stable dispatcher release is published yet; pin %s is accepted.", pinnedTag)) + return 0 + } + if sharedversion.IsLessThan(pinnedVersion, newestVersion) { + writeDispatcherPinFreshnessLine(stderr, fmt.Sprintf( + "%s: pin records %s but the newest stable dispatcher release is %s. "+ + "Merge the automated pin-stamp pull request, or run stamp-dispatcher-pin --tag %s.", + dispatcherPinFreshnessCommandName, pinnedTag, newestTag, newestTag)) + return 1 + } + writeDispatcherPinFreshnessLine(stdout, fmt.Sprintf( + "Dispatcher pin freshness guard passed: pin records %s and the newest stable dispatcher release is %s.", + pinnedTag, newestTag)) + return 0 +} + +func readPinnedDispatcherRelease( + config dispatcherPinFreshnessConfig, + deps dispatcherPinFreshnessDeps, +) (string, string, error) { + content, err := deps.readPin(config.pinPath) + if err != nil { + return "", "", fmt.Errorf("read dispatcher pin %s: %w", unityPackageCliPinFile, err) + } + values := dispatcherPinGuardValues{} + if err := json.Unmarshal(content, &values); err != nil { + return "", "", fmt.Errorf("%s is invalid JSON: %w", unityPackageCliPinFile, err) + } + version, err := dispatcherVersionFromReleaseTag(values.DispatcherReleaseTag) + if err != nil { + return "", "", fmt.Errorf("%s dispatcherReleaseTag is unusable: %w", unityPackageCliPinFile, err) + } + return values.DispatcherReleaseTag, version, nil +} + +// newestStableDispatcherRelease returns the highest stable dispatcher release. +// Tags that are not dispatcher releases belong to the other components released +// from this repository, so they are skipped rather than treated as an error. +func newestStableDispatcherRelease(releases []dispatcherRelease) (string, string) { + newestTag := "" + newestVersion := "" + for _, release := range releases { + if release.Draft || release.Prerelease { + continue + } + version, err := dispatcherVersionFromReleaseTag(release.TagName) + if err != nil { + continue + } + // A pre-release identifier can appear on a release GitHub does not flag + // as a pre-release, and the pin must never move to one. + if strings.Contains(version, "-") { + continue + } + if newestVersion != "" && !sharedversion.IsLessThan(newestVersion, version) { + continue + } + newestTag = release.TagName + newestVersion = version + } + return newestTag, newestVersion +} + +func fetchDispatcherReleases(ctx context.Context, repository string) ([]dispatcherRelease, error) { + client := &http.Client{Timeout: dispatcherPinFreshnessRequestTimeout} + releases := []dispatcherRelease{} + for page := 1; page <= dispatcherPinFreshnessMaxPages; page++ { + pageReleases, err := fetchDispatcherReleasePage(ctx, client, repository, page) + if err != nil { + return nil, err + } + releases = append(releases, pageReleases...) + if len(pageReleases) < dispatcherPinFreshnessPageSize { + return releases, nil + } + } + return nil, fmt.Errorf("release listing for %s exceeded %d pages", repository, dispatcherPinFreshnessMaxPages) +} + +func fetchDispatcherReleasePage( + ctx context.Context, + client *http.Client, + repository string, + page int, +) ([]dispatcherRelease, error) { + requestURL := fmt.Sprintf( + "%s/repos/%s/releases?per_page=%d&page=%d", + dispatcherPinStampAPIBaseURL, + repository, + dispatcherPinFreshnessPageSize, + page) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, fmt.Errorf("build GitHub release list request: %w", err) + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if token := dispatcherPinStampGitHubToken(); token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer func() { + _ = response.Body.Close() + }() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("GitHub release list API returned %s", response.Status) + } + pageReleases := []dispatcherRelease{} + if err := json.NewDecoder(response.Body).Decode(&pageReleases); err != nil { + return nil, fmt.Errorf("decode GitHub release list: %w", err) + } + return pageReleases, nil +} + +func writeDispatcherPinFreshnessLine(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_freshness_test.go b/cli/release-automation/internal/automation/dispatcher_pin_freshness_test.go new file mode 100644 index 0000000000..e77eac5da3 --- /dev/null +++ b/cli/release-automation/internal/automation/dispatcher_pin_freshness_test.go @@ -0,0 +1,228 @@ +package automation + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" +) + +type dispatcherPinFreshnessResult struct { + exitCode int + stdout string + stderr string +} + +// runDispatcherPinFreshnessCase exercises the guard with a fixed pin and a +// fixed release listing, so no repository checkout or network call is involved. +func runDispatcherPinFreshnessCase( + t *testing.T, + pinContent string, + releases []dispatcherRelease, + fetchError error, +) dispatcherPinFreshnessResult { + t.Helper() + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + deps := dispatcherPinFreshnessDeps{ + fetchReleases: func(_ context.Context, _ string) ([]dispatcherRelease, error) { + return releases, fetchError + }, + readPin: func(string) ([]byte, error) { + return []byte(pinContent), nil + }, + } + exitCode := runDispatcherPinFreshnessCheck( + context.Background(), + &stdout, + &stderr, + dispatcherPinFreshnessConfig{repository: "owner/repository", pinPath: "pin.json"}, + deps) + return dispatcherPinFreshnessResult{exitCode: exitCode, stdout: stdout.String(), stderr: stderr.String()} +} + +func dispatcherPinFreshnessPin(tag string) string { + return fmt.Sprintf(`{"minimumDispatcherVersion":"3.0.0","dispatcherReleaseTag":%q}`, tag) +} + +func stableDispatcherRelease(tag string) dispatcherRelease { + return dispatcherRelease{TagName: tag} +} + +func TestRunDispatcherPinFreshnessCheckFailsWhenPinLagsBehindNewestStableRelease(t *testing.T) { + // Verifies a pin older than the newest stable dispatcher release fails with the remediation message. + result := runDispatcherPinFreshnessCase( + t, + dispatcherPinFreshnessPin("dispatcher-v3.0.1"), + []dispatcherRelease{stableDispatcherRelease("dispatcher-v3.0.1"), stableDispatcherRelease("dispatcher-v3.1.0")}, + nil) + + if result.exitCode != 1 { + t.Fatalf("expected exit code 1, got %d\nstdout: %s", result.exitCode, result.stdout) + } + expected := "check-dispatcher-pin-freshness: pin records dispatcher-v3.0.1 but the newest stable " + + "dispatcher release is dispatcher-v3.1.0. Merge the automated pin-stamp pull request, " + + "or run stamp-dispatcher-pin --tag dispatcher-v3.1.0." + if strings.TrimSpace(result.stderr) != expected { + t.Fatalf("expected stderr %q, got %q", expected, result.stderr) + } +} + +func TestRunDispatcherPinFreshnessCheckPassesWhenPinRecordsNewestStableRelease(t *testing.T) { + // Verifies a pin equal to the newest stable dispatcher release passes. + result := runDispatcherPinFreshnessCase( + t, + dispatcherPinFreshnessPin("dispatcher-v3.1.0"), + []dispatcherRelease{stableDispatcherRelease("dispatcher-v3.0.1"), stableDispatcherRelease("dispatcher-v3.1.0")}, + nil) + + if result.exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", result.exitCode, result.stderr) + } + if !strings.Contains(result.stdout, "Dispatcher pin freshness guard passed") { + t.Fatalf("expected a pass line, got %q", result.stdout) + } +} + +func TestRunDispatcherPinFreshnessCheckPassesWhenPinIsAheadOfPublishedReleases(t *testing.T) { + // Verifies a pin newer than every published release passes instead of failing on the ordering. + result := runDispatcherPinFreshnessCase( + t, + dispatcherPinFreshnessPin("dispatcher-v3.2.0"), + []dispatcherRelease{stableDispatcherRelease("dispatcher-v3.1.0")}, + nil) + + if result.exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", result.exitCode, result.stderr) + } +} + +func TestRunDispatcherPinFreshnessCheckIgnoresDraftPreReleaseAndOtherComponentReleases(t *testing.T) { + // Verifies drafts, pre-releases, pre-release identifiers, and other components' tags never move the pin target. + result := runDispatcherPinFreshnessCase( + t, + dispatcherPinFreshnessPin("dispatcher-v3.0.1"), + []dispatcherRelease{ + stableDispatcherRelease("dispatcher-v3.0.1"), + {TagName: "dispatcher-v4.0.0", Draft: true}, + {TagName: "dispatcher-v5.0.0", Prerelease: true}, + stableDispatcherRelease("dispatcher-v6.0.0-beta.1"), + stableDispatcherRelease("v7.0.0"), + stableDispatcherRelease("project-runner-v8.0.0"), + }, + nil) + + if result.exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", result.exitCode, result.stderr) + } + if !strings.Contains(result.stdout, "the newest stable dispatcher release is dispatcher-v3.0.1") { + t.Fatalf("expected dispatcher-v3.0.1 to remain the newest stable release, got %q", result.stdout) + } +} + +func TestRunDispatcherPinFreshnessCheckPassesWhenNoStableDispatcherReleaseExists(t *testing.T) { + // Verifies the initial state, before any stable dispatcher release exists, is not reported as stale. + result := runDispatcherPinFreshnessCase( + t, + dispatcherPinFreshnessPin("dispatcher-v3.0.1"), + []dispatcherRelease{}, + nil) + + if result.exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", result.exitCode, result.stderr) + } + if !strings.Contains(result.stdout, "No stable dispatcher release is published yet") { + t.Fatalf("expected the initial-state pass line, got %q", result.stdout) + } +} + +func TestRunDispatcherPinFreshnessCheckFailsWhenReleaseListingFails(t *testing.T) { + // Verifies an API failure fails the check instead of silently passing. + result := runDispatcherPinFreshnessCase( + t, + dispatcherPinFreshnessPin("dispatcher-v3.0.1"), + nil, + fmt.Errorf("GitHub release list API returned 503 Service Unavailable")) + + if result.exitCode != 1 { + t.Fatalf("expected exit code 1, got %d\nstdout: %s", result.exitCode, result.stdout) + } + if !strings.Contains(result.stderr, "503 Service Unavailable") { + t.Fatalf("expected the fetch error in stderr, got %q", result.stderr) + } +} + +func TestRunDispatcherPinFreshnessCheckFailsWhenPinnedTagIsUnusable(t *testing.T) { + // Verifies a pin whose dispatcherReleaseTag is not a dispatcher release tag fails the check. + result := runDispatcherPinFreshnessCase( + t, + dispatcherPinFreshnessPin("v3.0.1"), + []dispatcherRelease{stableDispatcherRelease("dispatcher-v3.0.1")}, + nil) + + if result.exitCode != 1 { + t.Fatalf("expected exit code 1, got %d\nstdout: %s", result.exitCode, result.stdout) + } + if !strings.Contains(result.stderr, "dispatcherReleaseTag is unusable") { + t.Fatalf("expected an unusable-tag error, got %q", result.stderr) + } +} + +func TestRunDispatcherPinFreshnessCheckFailsWhenPinCannotBeRead(t *testing.T) { + // Verifies a missing or unreadable pin file fails the check rather than passing on an error. + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + deps := dispatcherPinFreshnessDeps{ + fetchReleases: func(_ context.Context, _ string) ([]dispatcherRelease, error) { + t.Fatal("release listing must not be requested when the pin is unreadable") + return nil, nil + }, + readPin: func(string) ([]byte, error) { + return nil, fmt.Errorf("no such file") + }, + } + + exitCode := runDispatcherPinFreshnessCheck( + context.Background(), + &stdout, + &stderr, + dispatcherPinFreshnessConfig{repository: "owner/repository", pinPath: "pin.json"}, + deps) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d\nstdout: %s", exitCode, stdout.String()) + } + if !strings.Contains(stderr.String(), "read dispatcher pin") { + t.Fatalf("expected a pin read error, got %q", stderr.String()) + } +} + +func TestParseDispatcherPinFreshnessFlagsDefaultsToTheReleaseRepository(t *testing.T) { + // Verifies the repository falls back to the release repository and the pin path stays repo-relative. + t.Setenv("GITHUB_REPOSITORY", "") + + config, err := parseDispatcherPinFreshnessFlags(nil) + if err != nil { + t.Fatalf("expected flag parsing to succeed, got %v", err) + } + if config.repository == "" || !strings.Contains(config.repository, "/") { + t.Fatalf("expected a default owner/repository, got %q", config.repository) + } + if !strings.HasSuffix(config.pinPath, "project-runner-pin.json") { + t.Fatalf("expected the package pin path, got %q", config.pinPath) + } +} + +func TestParseDispatcherPinFreshnessFlagsPrefersTheExplicitRepository(t *testing.T) { + // Verifies --repo overrides the GITHUB_REPOSITORY environment value. + t.Setenv("GITHUB_REPOSITORY", "environment/repository") + + config, err := parseDispatcherPinFreshnessFlags([]string{"--repo", "flag/repository"}) + if err != nil { + t.Fatalf("expected flag parsing to succeed, got %v", err) + } + if config.repository != "flag/repository" { + t.Fatalf("expected the flag repository, got %q", config.repository) + } +} diff --git a/docs/dispatcher-pin-release-order.md b/docs/dispatcher-pin-release-order.md index d8de75356c..0d0aecadf2 100644 --- a/docs/dispatcher-pin-release-order.md +++ b/docs/dispatcher-pin-release-order.md @@ -17,6 +17,14 @@ installation. Package release preparation must follow this order: 3. Verify the resulting pin with `check-dispatcher-pin` and publish the Unity package. +`build-and-test.yml` runs `check-dispatcher-pin-freshness` on everything except +pull requests, so `main` fails while the pin still records an older release than +the newest published stable `dispatcher-v*` one. The failure clears by merging +the automated pin-stamp pull request, or by stamping by hand with +`stamp-dispatcher-pin --tag `. The gate deliberately skips `pull_request` +runs: until that pull request merges, every other branch carries the same lagging +pin, and failing there would only hide the state that `main` alone can fix. + 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. diff --git a/docs/project-runner-pin.md b/docs/project-runner-pin.md index 827170094d..cc606427d9 100644 --- a/docs/project-runner-pin.md +++ b/docs/project-runner-pin.md @@ -15,7 +15,8 @@ for cross-component version requirements. Its required fields: release used for first installation and its verified asset hashes. Stamped by automation 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 + never stamped on `main`), and CI on `main` fails through `check-dispatcher-pin-freshness` for as + long as `dispatcherReleaseTag` lags the newest published stable dispatcher release. 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