From 29314fc254696f0b0c7859a094dfc7536155fc55 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 31 Aug 2026 23:55:07 +0900 Subject: [PATCH 1/2] feat: add check-dispatcher-pin-freshness to detect a lagging dispatcher pin Fresh CLI installs resolve the dispatcher release named by the package pin, so between a stable dispatcher release and the pin stamp that follows it every new install keeps landing on the superseded release. Nothing detected that window; the 3.0.0 and 3.0.1 stamps were only noticed and opened by hand. The command lists the release repository's releases, keeps the published stable dispatcher-v tags, and fails when the pin records an older version than the newest of those. It fails closed: an unusable pinned tag or an API failure exits non-zero rather than passing on missing evidence. No stable dispatcher release at all is the initial state, not staleness, so that passes. --- .../check-dispatcher-pin-freshness/main.go | 18 ++ .../automation/dispatcher_pin_freshness.go | 232 ++++++++++++++++++ .../dispatcher_pin_freshness_test.go | 228 +++++++++++++++++ 3 files changed, 478 insertions(+) create mode 100644 cli/release-automation/cmd/check-dispatcher-pin-freshness/main.go create mode 100644 cli/release-automation/internal/automation/dispatcher_pin_freshness.go create mode 100644 cli/release-automation/internal/automation/dispatcher_pin_freshness_test.go 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) + } +} From 3ad487e711dc59f29fdbc0592ad764edbd095b4a Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 31 Aug 2026 23:55:13 +0900 Subject: [PATCH 2/2] chore: fail CI on main while the dispatcher pin lags the newest stable release The freshness command only helps if something runs it. Wire it into build-and-test.yml for every event except pull_request, so main goes red for as long as the pin trails the newest published stable dispatcher release and the lag cannot pass unnoticed until someone happens to look. Pull requests are excluded on purpose: while the automated pin-stamp pull request is open, every branch carries the same lagging pin, so failing there would redden all open work over a state only main can resolve. --- .github/workflows/build-and-test.yml | 10 ++++++++++ docs/dispatcher-pin-release-order.md | 8 ++++++++ docs/project-runner-pin.md | 3 ++- 3 files changed, 20 insertions(+), 1 deletion(-) 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/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