Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/cli/repos.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/getting-started/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ For organizations that separate GCP and GitHub responsibilities across teams, fu
| Fleet Admin | `fullsend repos migrate <org> --project <gcp-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 <repos...>` | 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 <key> <value>` | Set or remove a platform-level default in the manifest |

| Developer | `fullsend agent add <url-or-path>` | Register an agent in config (URL auto-pinned to commit SHA) |
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/getting-started/repo-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions internal/repos/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Comment thread
ggallen marked this conversation as resolved.
// 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.
Expand Down
88 changes: 88 additions & 0 deletions internal/repos/status.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package repos

import (
"bytes"
"context"
"fmt"
"slices"
"strings"
"sync"

Expand Down Expand Up @@ -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 —
Comment thread
ggallen marked this conversation as resolved.
// 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 {
Expand Down Expand Up @@ -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",
})
}
}
}
Loading
Loading