diff --git a/docs/cli/repos.md b/docs/cli/repos.md index fddb45c27f..471043ce69 100644 --- a/docs/cli/repos.md +++ b/docs/cli/repos.md @@ -189,7 +189,7 @@ fullsend repos status --repo "acme/*" --json - **REPO** — `owner/repo` name - **REF** — Current workflow ref. Named refs (tags, branches) display as-is (e.g., `v2.3.0`, `main`). When the ref is a commit SHA, shows a truncated 7-character SHA with the expected ref in parentheses (e.g., `6f8b968 (main)`). - **STATUS** — `installed`, `not installed`, or `error` -- **DRIFT** — Fields that differ from the manifest, or `none` +- **DRIFT** — Fields that differ from the manifest or scaffold files whose template content has changed, or `none` **JSON output** (`--json`) returns the full `StatusResult` object with per-repo details and aggregate summary counts. diff --git a/docs/guides/getting-started/operations.md b/docs/guides/getting-started/operations.md index 5c2c58acaf..708115731b 100644 --- a/docs/guides/getting-started/operations.md +++ b/docs/guides/getting-started/operations.md @@ -108,7 +108,7 @@ For organizations that separate GCP and GitHub responsibilities across teams, fu | Fleet Admin | `fullsend repos migrate --project ` | Migrate an org from per-org to per-repo install, generating a `repos.yaml` manifest | | Platform Admin | `fullsend repos install [repos...]` | Converge repos to desired state: provision new, repair component drift (workflow, thin callers, variables, secrets), upgrade refs | | Platform Admin | `fullsend repos uninstall ` | Tear down fullsend from repos and remove from manifest | -| Fleet Admin | `fullsend repos status` | Compare manifest against actual per-repo state: detect missing or drifted components and ref drift | +| Fleet Admin | `fullsend repos status` | Compare manifest against actual per-repo state: detect missing or drifted components, ref drift, and scaffold content drift | | Fleet Admin | `fullsend repos set-default ` | Set or remove a platform-level default in the manifest | | Developer | `fullsend agent add ` | Register an agent in config (URL auto-pinned to commit SHA) | diff --git a/docs/guides/getting-started/repo-management.md b/docs/guides/getting-started/repo-management.md index 9b5f49d969..a8b62f52f6 100644 --- a/docs/guides/getting-started/repo-management.md +++ b/docs/guides/getting-started/repo-management.md @@ -212,8 +212,8 @@ fullsend repos status -f repos.yaml --json ### Detecting and reconciling configuration drift Run `repos install` to detect and fix component drift (workflow, thin -callers, variables, secrets) and scaffold ref drift across all manifest -repos: +callers, variables, secrets), scaffold ref drift, and scaffold content +drift across all manifest repos: ```bash fullsend repos install -f repos.yaml diff --git a/internal/repos/install.go b/internal/repos/install.go index 54f6b17d91..053d49ddcb 100644 --- a/internal/repos/install.go +++ b/internal/repos/install.go @@ -240,6 +240,45 @@ func Install(ctx context.Context, cfg InstallConfig, return result, nil } +// ExpectedScaffoldContent renders the scaffold files that a fresh install +// would produce for the given resolved config. The status path uses this +// to compare against installed files for content drift detection — when +// the rendered template differs from the installed file, the repo's +// scaffold is stale even if the ref string matches. +// +// The converge path builds equivalent files via BuildScaffoldFiles with +// additional context (upstream SHA resolution, remote scaffold fetch, +// runner tags). Both paths share BuildScaffoldFiles as the underlying +// renderer. +// +// Limitation: this function omits InstallConfig fields that the converge +// path populates at runtime (RunnerTags, PrebuiltScaffoldFiles, +// VendorBinary). Currently only GitHub is wired, so the omission has no +// effect. When GitLab status support is added or vendor-mode status is +// needed, these fields will need to be resolved here as well. +// +// Returns (nil, nil) when FullsendRef is empty — there is no expected +// ref to compare against. +func ExpectedScaffoldContent(resolved ResolvedConfig) ([]forge.TreeFile, error) { + ref := resolved.FullsendRef + if ref == "" { + return nil, nil + } + + installCfg := InstallConfig{ + Owner: resolved.Owner, + Repo: resolved.Repo, + Forge: resolved.Forge, + Roles: defaultRoles(nil), + MintURL: resolved.MintURL, + UpstreamRef: ref, + UpstreamTag: ref, + Runtime: resolved.Runtime, + } + + return BuildScaffoldFiles(installCfg) +} + // BuildScaffoldFiles generates the scaffold tree files for a per-repo install. // Exported so the CLI dry-run path can display the file list without running // the full install. diff --git a/internal/repos/status.go b/internal/repos/status.go index ec0048d676..c163e39c6b 100644 --- a/internal/repos/status.go +++ b/internal/repos/status.go @@ -1,8 +1,10 @@ package repos import ( + "bytes" "context" "fmt" + "slices" "strings" "sync" @@ -273,6 +275,17 @@ func checkRepoStatus(ctx context.Context, cfg ResolvedConfig, resolver *RefResol } } + // Content drift: compare installed scaffold content against expected + // template output. This catches template changes (new jobs, permissions, + // restructured thin callers) that are invisible to the ref-string and + // presence checks above. Refs are normalized before comparison so that + // ref-format differences do not produce false content-drift reports — + // ref drift is already detected separately. + checkScaffoldContentDrift(ctx, client, cfg, &status) + if status.Error != "" { + return status + } + // Read display-only variable not covered by required vars. region, _, regionErr := client.GetRepoVariable(ctx, owner, repo, "FULLSEND_GCP_REGION") if regionErr != nil { @@ -353,3 +366,78 @@ func filterRepos(repos []ResolvedRepo, filter []string) ([]ResolvedRepo, []strin return result, unmatched, nil } + +// checkScaffoldContentDrift compares installed scaffold file content +// against expected template output and appends content drift entries to +// status.Drifts for any mismatches. Refs are normalized with +// replaceShimRef before comparison so that ref-format differences +// (tag vs SHA, annotation presence) do not produce false positives — +// ref drift is detected separately by the fullsend_ref check. +func checkScaffoldContentDrift(ctx context.Context, client forge.Client, cfg ResolvedConfig, status *RepoStatus) { + expectedFiles, err := ExpectedScaffoldContent(cfg) + if err != nil { + status.Error = fmt.Sprintf("rendering expected scaffold for %s/%s: %v", cfg.Owner, cfg.Repo, err) + return + } + if expectedFiles == nil { + return + } + + fc := cfg.ForgeConfig + + for _, ef := range expectedFiles { + // Skip config.yaml — role configuration is not tracked by status. + if ef.Path == ".fullsend/config.yaml" { + continue + } + + // For workflow files the installed copy may use a different + // extension (.yml vs .yaml), so try all known workflow paths. + var installed []byte + var installedPath string + if slices.Contains(fc.WorkflowPaths, ef.Path) { + for _, path := range fc.WorkflowPaths { + content, readErr := client.GetFileContent(ctx, cfg.Owner, cfg.Repo, path) + if readErr == nil { + installed = content + installedPath = path + break + } + if !forge.IsNotFound(readErr) { + // Propagate unexpected errors (rate limiting, server + // errors) instead of silently skipping the file. + status.Error = fmt.Sprintf("reading scaffold file %s for %s/%s: %v", path, cfg.Owner, cfg.Repo, readErr) + return + } + } + } else { + content, readErr := client.GetFileContent(ctx, cfg.Owner, cfg.Repo, ef.Path) + if readErr == nil { + installed = content + installedPath = ef.Path + } else if !forge.IsNotFound(readErr) { + status.Error = fmt.Sprintf("reading scaffold file %s for %s/%s: %v", ef.Path, cfg.Owner, cfg.Repo, readErr) + return + } + } + + if installed == nil { + // File not found — presence drift is already reported by + // the component probe; content comparison is not applicable. + continue + } + + // Normalize refs to a placeholder so that ref-string differences + // do not cause false content drift. + installedNorm, _ := replaceShimRef(installed, "NORMALIZED_REF", "", fc, cfg.Forge) + expectedNorm, _ := replaceShimRef(ef.Content, "NORMALIZED_REF", "", fc, cfg.Forge) + + if !bytes.Equal(installedNorm, expectedNorm) { + status.Drifts = append(status.Drifts, Drift{ + Field: installedPath, + Expected: "current template", + Actual: "installed content differs", + }) + } + } +} diff --git a/internal/repos/status_test.go b/internal/repos/status_test.go index c7669421f7..340722546a 100644 --- a/internal/repos/status_test.go +++ b/internal/repos/status_test.go @@ -1,10 +1,13 @@ package repos import ( + "bytes" "context" "fmt" + "regexp" "testing" + "github.com/fullsend-ai/fullsend/internal/config" "github.com/fullsend-ai/fullsend/internal/forge" ) @@ -30,7 +33,8 @@ jobs: uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v2.3.0 ` -func populateInstalledRepo(fc *forge.FakeClient, owner, repo, ref, mintURL, region string) { +func populateInstalledRepo(t testing.TB, fc *forge.FakeClient, owner, repo, ref, mintURL, region string) { + t.Helper() fc.VariableValues[owner+"/"+repo+"/FULLSEND_MINT_URL"] = mintURL fc.VariableValues[owner+"/"+repo+"/FULLSEND_GCP_REGION"] = region @@ -40,20 +44,30 @@ func populateInstalledRepo(fc *forge.FakeClient, owner, repo, ref, mintURL, regi fc.Secrets[owner+"/"+repo+"/FULLSEND_GCP_PROJECT_ID"] = true fc.Secrets[owner+"/"+repo+"/FULLSEND_GCP_WIF_PROVIDER"] = true - workflow := fmt.Sprintf(`name: fullsend -on: - workflow_dispatch: -jobs: - dispatch: - uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@%s -`, ref) - fc.FileContents[owner+"/"+repo+"/.github/workflows/fullsend.yml"] = []byte(workflow) - addThinCallerFiles(fc, owner, repo) + // Generate scaffold files from the same templates that status + // compares against, so content-drift detection is accurate. + files, err := BuildScaffoldFiles(InstallConfig{ + Owner: owner, + Repo: repo, + Forge: ForgeGitHub, + Roles: config.PerRepoDefaultRoles(), + MintURL: mintURL, + UpstreamRef: ref, + UpstreamTag: ref, + }) + if err != nil { + t.Fatalf("populateInstalledRepo: BuildScaffoldFiles: %v", err) + } + + fullName := owner + "/" + repo + for _, f := range files { + fc.FileContents[fullName+"/"+f.Path] = f.Content + } } func TestProbeRepoState_Installed(t *testing.T) { fc := forge.NewFakeClient() - populateInstalledRepo(fc, "acme", "api", "v2.3.0", "https://mint.example.com", "us-east1") + populateInstalledRepo(t, fc, "acme", "api", "v2.3.0", "https://mint.example.com", "us-east1") state, err := ProbeRepoState(context.Background(), fc, "acme", "api", ForgeGitHub, defaultForgeConfig) if err != nil { @@ -104,9 +118,9 @@ func TestStatus_AllInstalled_NoDrift(t *testing.T) { fc := forge.NewFakeClient() m := newTestManifest() - populateInstalledRepo(fc, "acme-corp", "api-server", "v2.3.0", + populateInstalledRepo(t, fc, "acme-corp", "api-server", "v2.3.0", "https://mint.example.com", "us-central1") - populateInstalledRepo(fc, "acme-corp", "web-frontend", "v2.3.0", + populateInstalledRepo(t, fc, "acme-corp", "web-frontend", "v2.3.0", "https://mint.example.com", "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) @@ -141,7 +155,7 @@ func TestStatus_RepoNotInstalled(t *testing.T) { fc := forge.NewFakeClient() m := newTestManifest() - populateInstalledRepo(fc, "acme-corp", "api-server", "v2.3.0", + populateInstalledRepo(t, fc, "acme-corp", "api-server", "v2.3.0", "https://mint.example.com", "us-central1") // web-frontend has no variables — not installed. @@ -170,9 +184,9 @@ func TestStatus_MintURLDrift(t *testing.T) { fc := forge.NewFakeClient() m := newTestManifest() - populateInstalledRepo(fc, "acme-corp", "api-server", "v2.3.0", + populateInstalledRepo(t, fc, "acme-corp", "api-server", "v2.3.0", "https://mint.example.com", "us-central1") - populateInstalledRepo(fc, "acme-corp", "web-frontend", "v2.3.0", + populateInstalledRepo(t, fc, "acme-corp", "web-frontend", "v2.3.0", "https://old-mint.example.com", "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) @@ -206,9 +220,9 @@ func TestStatus_RefDrift(t *testing.T) { fc := forge.NewFakeClient() m := newTestManifest() - populateInstalledRepo(fc, "acme-corp", "api-server", "v2.3.0", + populateInstalledRepo(t, fc, "acme-corp", "api-server", "v2.3.0", "https://mint.example.com", "us-central1") - populateInstalledRepo(fc, "acme-corp", "web-frontend", "v2.1.0", + populateInstalledRepo(t, fc, "acme-corp", "web-frontend", "v2.1.0", "https://mint.example.com", "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) @@ -238,7 +252,7 @@ func TestStatus_RegionDrift_NoLongerReported(t *testing.T) { fc := forge.NewFakeClient() m := newTestManifest() - populateInstalledRepo(fc, "acme-corp", "api-server", "v2.3.0", + populateInstalledRepo(t, fc, "acme-corp", "api-server", "v2.3.0", "https://mint.example.com", "us-west1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) @@ -263,7 +277,7 @@ func TestStatus_MultipleDrifts(t *testing.T) { fc := forge.NewFakeClient() m := newTestManifest() - populateInstalledRepo(fc, "acme-corp", "api-server", "v2.1.0", + populateInstalledRepo(t, fc, "acme-corp", "api-server", "v2.1.0", "https://old.example.com", "us-west1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) @@ -344,9 +358,9 @@ func TestStatus_RepoFilter(t *testing.T) { fc := forge.NewFakeClient() m := newTestManifest() - populateInstalledRepo(fc, "acme-corp", "api-server", "v2.3.0", + populateInstalledRepo(t, fc, "acme-corp", "api-server", "v2.3.0", "https://mint.example.com", "us-central1") - populateInstalledRepo(fc, "acme-corp", "web-frontend", "v2.3.0", + populateInstalledRepo(t, fc, "acme-corp", "web-frontend", "v2.3.0", "https://mint.example.com", "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, []string{"acme-corp/api-server"}) @@ -366,7 +380,7 @@ func TestStatus_RepoFilterCaseInsensitive(t *testing.T) { fc := forge.NewFakeClient() m := newTestManifest() - populateInstalledRepo(fc, "acme-corp", "api-server", "v2.3.0", + populateInstalledRepo(t, fc, "acme-corp", "api-server", "v2.3.0", "https://mint.example.com", "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, []string{"ACME-CORP/API-SERVER"}) @@ -425,7 +439,7 @@ func TestStatus_GlobExpansion(t *testing.T) { }, } - populateInstalledRepo(fc, "acme-corp", "api-server", "v2.3.0", + populateInstalledRepo(t, fc, "acme-corp", "api-server", "v2.3.0", "https://mint.example.com", "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) @@ -458,9 +472,9 @@ func TestStatus_PerRepoOverride(t *testing.T) { }, } - populateInstalledRepo(fc, "acme-corp", "api-server", "v2.3.0", + populateInstalledRepo(t, fc, "acme-corp", "api-server", "v2.3.0", "https://mint.example.com", "us-central1") - populateInstalledRepo(fc, "acme-corp", "legacy", "v2.3.0", + populateInstalledRepo(t, fc, "acme-corp", "legacy", "v2.3.0", "https://mint.example.com", "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) @@ -814,8 +828,8 @@ func TestStatus_MultiOrg(t *testing.T) { }, } - populateInstalledRepo(fc, "org-a", "repo1", "v2.3.0", "https://mint.example.com", "us-central1") - populateInstalledRepo(fc, "org-b", "repo2", "v2.3.0", "https://mint.example.com", "us-central1") + populateInstalledRepo(t, fc, "org-a", "repo1", "v2.3.0", "https://mint.example.com", "us-central1") + populateInstalledRepo(t, fc, "org-b", "repo2", "v2.3.0", "https://mint.example.com", "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) if err != nil { @@ -858,7 +872,7 @@ func TestStatus_DefaultMintURL_NoDrift(t *testing.T) { }, } - populateInstalledRepo(fc, "org", "repo", "v2.3.0", DefaultPublicMintURL, "us-central1") + populateInstalledRepo(t, fc, "org", "repo", "v2.3.0", DefaultPublicMintURL, "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) if err != nil { @@ -880,7 +894,7 @@ func TestStatus_EmptyExpectedRef_NoDrift(t *testing.T) { }, } - populateInstalledRepo(fc, "org", "repo", "v2.3.0", "https://mint.example.com", "us-central1") + populateInstalledRepo(t, fc, "org", "repo", "v2.3.0", "https://mint.example.com", "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) if err != nil { @@ -910,7 +924,7 @@ func TestStatus_SHADriftDetection(t *testing.T) { }, } - populateInstalledRepo(fc, "org", "repo", sha, "https://mint.example.com", "us-central1") + populateInstalledRepo(t, fc, "org", "repo", sha, "https://mint.example.com", "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) if err != nil { @@ -937,7 +951,7 @@ func TestStatus_SHADriftDetection(t *testing.T) { }, } - populateInstalledRepo(fc, "org", "repo", "oldsha000000000000000000000000000000000", + populateInstalledRepo(t, fc, "org", "repo", "oldsha000000000000000000000000000000000", "https://mint.example.com", "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) @@ -963,7 +977,7 @@ func TestStatus_SHADriftDetection(t *testing.T) { }, } - populateInstalledRepo(fc, "org", "repo", "stalesha000000000000000000000000000000", + populateInstalledRepo(t, fc, "org", "repo", "stalesha000000000000000000000000000000", "https://mint.example.com", "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) @@ -993,7 +1007,7 @@ func TestStatus_SymbolicRefMatch_NoDrift(t *testing.T) { }, } - populateInstalledRepo(fc, "org", "repo", "v0", "https://mint.example.com", "us-central1") + populateInstalledRepo(t, fc, "org", "repo", "v0", "https://mint.example.com", "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) if err != nil { @@ -1022,7 +1036,7 @@ func TestStatus_DifferentSymbolicRefs_Drift(t *testing.T) { }, } - populateInstalledRepo(fc, "org", "repo", "v0", "https://mint.example.com", "us-central1") + populateInstalledRepo(t, fc, "org", "repo", "v0", "https://mint.example.com", "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) if err != nil { @@ -1055,7 +1069,7 @@ func TestStatus_Concurrency(t *testing.T) { for i := 0; i < 20; i++ { repo := fmt.Sprintf("repo-%d", i) m.GitHub.Repos = append(m.GitHub.Repos, RepoEntry{Name: "org/" + repo}) - populateInstalledRepo(fc, "org", repo, "v2.3.0", "https://mint.example.com", "us-central1") + populateInstalledRepo(t, fc, "org", repo, "v2.3.0", "https://mint.example.com", "us-central1") } result, err := Status(context.Background(), m, newTestClientFactory(fc), 2, nil) @@ -1075,7 +1089,7 @@ func TestStatus_RepoFilterAllUnmatched(t *testing.T) { fc := forge.NewFakeClient() m := newTestManifest() - populateInstalledRepo(fc, "acme-corp", "api-server", "v2.3.0", + populateInstalledRepo(t, fc, "acme-corp", "api-server", "v2.3.0", "https://mint.example.com", "us-central1") _, err := Status(context.Background(), m, newTestClientFactory(fc), 4, []string{"org/nonexistent"}) @@ -1088,7 +1102,7 @@ func TestStatus_RepoFilterPartialUnmatched(t *testing.T) { fc := forge.NewFakeClient() m := newTestManifest() - populateInstalledRepo(fc, "acme-corp", "api-server", "v2.3.0", + populateInstalledRepo(t, fc, "acme-corp", "api-server", "v2.3.0", "https://mint.example.com", "us-central1") result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, @@ -1106,3 +1120,333 @@ func TestStatus_RepoFilterPartialUnmatched(t *testing.T) { t.Errorf("warning = %q, want match message", result.Warnings[0]) } } + +func TestStatus_DetectsContentDrift_Workflow(t *testing.T) { + fc := forge.NewFakeClient() + m := &Manifest{ + Version: 1, + GitHub: &PlatformConfig{ + MintURL: "https://mint.example.com", + FullsendRef: "v2.3.0", + Repos: []RepoEntry{{Name: "org/repo"}}, + }, + } + + // Populate with correct variables and secrets, but write a stale + // workflow whose template content differs from what BuildScaffoldFiles + // would produce. The ref matches the manifest — only the template + // body is outdated. + fc.VariableValues["org/repo/FULLSEND_MINT_URL"] = "https://mint.example.com" + fc.VariableValues["org/repo/FULLSEND_GCP_REGION"] = "us-central1" + if fc.Secrets == nil { + fc.Secrets = make(map[string]bool) + } + fc.Secrets["org/repo/FULLSEND_GCP_PROJECT_ID"] = true + fc.Secrets["org/repo/FULLSEND_GCP_WIF_PROVIDER"] = true + + staleWorkflow := fmt.Sprintf(`name: fullsend +on: + workflow_dispatch: +jobs: + dispatch: + uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@%s +`, "v2.3.0") + fc.FileContents["org/repo/.github/workflows/fullsend.yml"] = []byte(staleWorkflow) + + // Also add a correct thin caller so only the workflow drifts. + files, err := BuildScaffoldFiles(InstallConfig{ + Owner: "org", + Repo: "repo", + Forge: ForgeGitHub, + Roles: config.PerRepoDefaultRoles(), + MintURL: "https://mint.example.com", + UpstreamRef: "v2.3.0", + UpstreamTag: "v2.3.0", + }) + if err != nil { + t.Fatalf("BuildScaffoldFiles: %v", err) + } + for _, f := range files { + if f.Path == ".fullsend/config.yaml" { + fc.FileContents["org/repo/"+f.Path] = f.Content + continue + } + // Only install non-workflow scaffold files (thin callers). + if f.Path != ".github/workflows/fullsend.yaml" { + fc.FileContents["org/repo/"+f.Path] = f.Content + } + } + + result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !result.Repos[0].Installed { + t.Fatal("repo should be installed") + } + + found := false + for _, d := range result.Repos[0].Drifts { + if d.Field == ".github/workflows/fullsend.yml" && + d.Expected == "current template" && + d.Actual == "installed content differs" { + found = true + } + } + if !found { + t.Errorf("expected content drift for workflow, got drifts: %v", result.Repos[0].Drifts) + } +} + +func TestStatus_DetectsContentDrift_ThinCaller(t *testing.T) { + fc := forge.NewFakeClient() + m := &Manifest{ + Version: 1, + GitHub: &PlatformConfig{ + MintURL: "https://mint.example.com", + FullsendRef: "v2.3.0", + Repos: []RepoEntry{{Name: "org/repo"}}, + }, + } + + // Install correct scaffold content first, then overwrite one + // thin caller with stale content. + populateInstalledRepo(t, fc, "org", "repo", "v2.3.0", + "https://mint.example.com", "us-central1") + + // Overwrite thin caller with outdated content. + fc.FileContents["org/repo/.github/workflows/prioritize.yml"] = []byte("name: outdated-thin-caller\n") + + result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + found := false + for _, d := range result.Repos[0].Drifts { + if d.Field == ".github/workflows/prioritize.yml" && + d.Expected == "current template" { + found = true + } + } + if !found { + t.Errorf("expected content drift for thin caller, got drifts: %v", result.Repos[0].Drifts) + } +} + +func TestStatus_NoContentDrift_WhenContentMatches(t *testing.T) { + fc := forge.NewFakeClient() + m := newTestManifest() + + // populateInstalledRepo uses BuildScaffoldFiles, so content + // should match exactly — no content drift expected. + populateInstalledRepo(t, fc, "acme-corp", "api-server", "v2.3.0", + "https://mint.example.com", "us-central1") + populateInstalledRepo(t, fc, "acme-corp", "web-frontend", "v2.3.0", + "https://mint.example.com", "us-central1") + + result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Summary.Drifted != 0 { + t.Errorf("drifted = %d, want 0", result.Summary.Drifted) + } + + for _, s := range result.Repos { + for _, d := range s.Drifts { + if d.Expected == "current template" { + t.Errorf("%s/%s: unexpected content drift: %+v", s.Owner, s.Repo, d) + } + } + } +} + +func TestStatus_ContentDrift_BranchRef(t *testing.T) { + // For branch-ref targets like fullsend_ref: main, template changes + // are the primary signal (the ref string never changes). Verify + // that content drift is detected even when the ref matches. + fc := forge.NewFakeClient() + m := &Manifest{ + Version: 1, + GitHub: &PlatformConfig{ + MintURL: "https://mint.example.com", + FullsendRef: "main", + Repos: []RepoEntry{{Name: "org/repo"}}, + }, + } + + fc.VariableValues["org/repo/FULLSEND_MINT_URL"] = "https://mint.example.com" + fc.VariableValues["org/repo/FULLSEND_GCP_REGION"] = "us-central1" + if fc.Secrets == nil { + fc.Secrets = make(map[string]bool) + } + fc.Secrets["org/repo/FULLSEND_GCP_PROJECT_ID"] = true + fc.Secrets["org/repo/FULLSEND_GCP_WIF_PROVIDER"] = true + + // Write a workflow with the correct ref but outdated template body. + staleWorkflow := `name: fullsend +on: + workflow_dispatch: +jobs: + dispatch: + uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@main +` + fc.FileContents["org/repo/.github/workflows/fullsend.yml"] = []byte(staleWorkflow) + + // Add correct thin callers from templates. + files, err := BuildScaffoldFiles(InstallConfig{ + Owner: "org", + Repo: "repo", + Forge: ForgeGitHub, + Roles: config.PerRepoDefaultRoles(), + MintURL: "https://mint.example.com", + UpstreamRef: "main", + UpstreamTag: "main", + }) + if err != nil { + t.Fatalf("BuildScaffoldFiles: %v", err) + } + for _, f := range files { + if f.Path == ".github/workflows/fullsend.yaml" || f.Path == ".fullsend/config.yaml" { + continue + } + fc.FileContents["org/repo/"+f.Path] = f.Content + } + + result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + found := false + for _, d := range result.Repos[0].Drifts { + if d.Expected == "current template" { + found = true + } + } + if !found { + t.Errorf("expected content drift for branch-ref target, got drifts: %v", result.Repos[0].Drifts) + } +} + +func TestStatus_ContentDrift_RefDifference_NoFalsePositive(t *testing.T) { + // When the ref differs between manifest and installed, ref drift + // is reported separately. Content drift should NOT be reported if + // the template structure is the same (only the ref differs). + fc := forge.NewFakeClient() + m := &Manifest{ + Version: 1, + GitHub: &PlatformConfig{ + MintURL: "https://mint.example.com", + FullsendRef: "v2.4.0", + Repos: []RepoEntry{{Name: "org/repo"}}, + }, + } + + // Install with v2.3.0 — same template, different ref. + populateInstalledRepo(t, fc, "org", "repo", "v2.3.0", + "https://mint.example.com", "us-central1") + + result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Ref drift should be reported. + hasRefDrift := false + for _, d := range result.Repos[0].Drifts { + if d.Field == "fullsend_ref" { + hasRefDrift = true + } + } + if !hasRefDrift { + t.Error("expected fullsend_ref drift when refs differ") + } + + // Content drift should NOT be reported because the template + // structure is the same — only the ref string differs. + for _, d := range result.Repos[0].Drifts { + if d.Expected == "current template" { + t.Errorf("unexpected content drift when only ref differs: %+v", d) + } + } +} + +func TestStatus_NoContentDrift_IndependentInstalledContent(t *testing.T) { + // This test constructs installed scaffold content independently + // (NOT via a second BuildScaffoldFiles call) to verify that the + // ref normalization and content comparison in + // checkScaffoldContentDrift work correctly end-to-end. + // + // The other no-drift tests use populateInstalledRepo which calls + // BuildScaffoldFiles for both installed and expected sides, making + // them tautological for content-drift verification. + fc := forge.NewFakeClient() + m := &Manifest{ + Version: 1, + GitHub: &PlatformConfig{ + MintURL: "https://mint.example.com", + FullsendRef: "v2.3.0", + Repos: []RepoEntry{{Name: "org/repo"}}, + }, + } + + fc.VariableValues["org/repo/FULLSEND_MINT_URL"] = "https://mint.example.com" + fc.VariableValues["org/repo/FULLSEND_GCP_REGION"] = "us-central1" + if fc.Secrets == nil { + fc.Secrets = make(map[string]bool) + } + fc.Secrets["org/repo/FULLSEND_GCP_PROJECT_ID"] = true + fc.Secrets["org/repo/FULLSEND_GCP_WIF_PROVIDER"] = true + + // Render expected scaffold files (this is what ExpectedScaffoldContent + // calls internally). + expectedFiles, err := BuildScaffoldFiles(InstallConfig{ + Owner: "org", + Repo: "repo", + Forge: ForgeGitHub, + Roles: config.PerRepoDefaultRoles(), + MintURL: "https://mint.example.com", + UpstreamRef: "v2.3.0", + UpstreamTag: "v2.3.0", + }) + if err != nil { + t.Fatalf("BuildScaffoldFiles: %v", err) + } + + // Independently construct installed content by taking the rendered + // bytes and replacing @v2.3.0 in uses: lines with a SHA-annotated + // format. This simulates a repo installed with a resolved SHA while + // the manifest still references the tag. The replaceShimRef + // normalization should make both sides equivalent. + shaRef := "abc1234567890def1234567890abc1234567890de # v2.3.0" + usesRefPattern := regexp.MustCompile(`(@)v2\.3\.0([ \t]*(?:#.*)?)?\b`) + + for _, f := range expectedFiles { + content := usesRefPattern.ReplaceAll(f.Content, []byte("@"+shaRef)) + // Verify we actually changed something for non-config files + // that contain uses: lines (workflow + thin callers). + if f.Path != ".fullsend/config.yaml" && bytes.Equal(content, f.Content) { + // Not all scaffold files contain uses: lines; skip the + // assertion for those. + if bytes.Contains(f.Content, []byte("uses:")) { + t.Errorf("regex did not modify %s — test may be vacuous", f.Path) + } + } + fc.FileContents["org/repo/"+f.Path] = content + } + + result, err := Status(context.Background(), m, newTestClientFactory(fc), 4, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, d := range result.Repos[0].Drifts { + if d.Expected == "current template" { + t.Errorf("unexpected content drift with independently constructed content: %+v", d) + } + } +}