Skip to content
Open
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
4 changes: 2 additions & 2 deletions docs/guides/dev/cli-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ Both modes call the same functions (`runAppSetup`, `gcf.NewProvisioner`, `Provis
| **2. App setup** | `runAppSetup()` → PEMs + App IDs | All 7 roles by default | Excludes "fullsend" role |
| **3. Mint** | `gcf.Provision()` or `EnsureOrgInMint()` | — | + `RegisterPerRepoWIF()` |
| **4. WIF** | `ProvisionWIF()` | Org-wide provider ID | `mintcore.BuildRepoProviderID()` (repo-scoped) |
| **5. Scaffold** | `scaffold.PerRepoCustomizedDirs()` / `WalkFullsendRepo()` | Creates `.fullsend` repo, pushes workflows + optional binary | Writes `.fullsend/` dir + shim workflow + optional binary in target repo |
| **5. Scaffold** | `repos.BuildScaffoldFiles()` (via `scaffold.CollectPerRepoInstallFiles()`) | Creates `.fullsend` repo, pushes workflows + optional binary | Writes `.fullsend/` dir + shim workflow + optional binary in target repo |
| **6. Secrets** | Same secret names, same API calls | Config repo + org variable | Target repo + `PER_REPO_GUARD` |
| **7. Enrollment** | — | `EnrollmentLayer` enables repos | No-op (self-contained) |

Expand All @@ -267,7 +267,7 @@ Install: process 1→8 (forward)
Uninstall: process 8→1 (reverse)
```

Per-repo mode does not use the layer stack — it runs the same phases inline in `runPerRepoInstall()` and `runGitHubSetupPerRepo()` since there's no need for composable uninstall ordering with a single repo. Vendoring (when `--vendor` is set) and stale asset cleanup are handled inline or via shared helpers; per-org mode uses `VendorBinaryLayer`.
Per-repo mode does not use the layer stack — `runPerRepoInstall()` delegates to `repos.Install()` (from `internal/repos`) for the core install logic (guard check, WIF provisioning, scaffold commit, variable/secret writes), while `runGitHubSetupPerRepo()` handles GitHub-specific setup. There's no need for composable uninstall ordering with a single repo. Vendoring (when `--vendor` is set) and stale asset cleanup are handled inline or via shared helpers; per-org mode uses `VendorBinaryLayer`.

### Binary acquisition (`internal/binary`)

Expand Down
6 changes: 4 additions & 2 deletions docs/plans/repos-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,9 +336,11 @@ in parallel with PRs 4–8.

## Phase 1: Foundation

### PR 1: Extract per-repo install logic into reusable package
### PR 1: Extract per-repo install logic into reusable package

**Scope:** Refactor only. Zero behavioral change.
**Status:** Implemented in [#3003](https://github.com/fullsend-ai/fullsend/pull/3003).

**Scope:** Refactor only. Preserves install semantics.

The existing `runPerRepoInstall()` in `internal/cli/admin.go` is ~450
lines mixing install logic with CLI concerns (interactive prompts,
Expand Down
254 changes: 189 additions & 65 deletions internal/cli/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import (
"github.com/fullsend-ai/fullsend/internal/inference/vertex"
"github.com/fullsend-ai/fullsend/internal/layers"
"github.com/fullsend-ai/fullsend/internal/mintcore"
"github.com/fullsend-ai/fullsend/internal/scaffold"
"github.com/fullsend-ai/fullsend/internal/repos"
"github.com/fullsend-ai/fullsend/internal/ui"
)

Expand Down Expand Up @@ -156,6 +156,12 @@ type perRepoInstallConfig struct {
FullsendBinary string
FullsendSource string
Direct bool

// Testing overrides — when non-nil, used instead of resolving from
// the environment. Not set by CLI flag parsing.
testClient forge.Client
testPrinter *ui.Printer
testWIFProvisioner repos.WIFProvisioner
}

// wifProviderPattern validates the full WIF provider resource name format
Expand Down Expand Up @@ -638,14 +644,20 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
return err
}

token, err := resolveToken()
if err != nil {
return err
var client forge.Client
var printer *ui.Printer
if c.testClient != nil {
client = c.testClient
printer = c.testPrinter
} else {
token, tokenErr := resolveToken()
if tokenErr != nil {
return tokenErr
}
client = gh.New(token)
printer = ui.New(os.Stdout)
}

client := gh.New(token)
printer := ui.New(os.Stdout)

printer.Banner(Version())
printer.Blank()
printer.Header("Installing per-repo fullsend for " + repoFullName)
Expand All @@ -655,35 +667,7 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
printer.StepWarn("Using provided WIF provider value — skipping inference provider auto-provisioning")
}

cfg := config.NewPerRepoConfig(roles, repoFullName)
if err := cfg.Validate(); err != nil {
return fmt.Errorf("invalid config: %w", err)
}

cfgYAML, err := cfg.Marshal()
if err != nil {
return fmt.Errorf("marshaling per-repo config: %w", err)
}

upstreamRef, upstreamTag := resolveUpstreamRef()
installFiles, err := scaffold.CollectPerRepoInstallFiles(vendor, upstreamRef, upstreamTag)
if err != nil {
return fmt.Errorf("collecting per-repo scaffold files: %w", err)
}

var files []forge.TreeFile
for _, f := range installFiles {
files = append(files, forge.TreeFile{
Path: f.Path,
Content: f.Content,
Mode: f.Mode,
})
}
files = append(files, forge.TreeFile{
Path: ".fullsend/config.yaml",
Content: cfgYAML,
Mode: "100644",
})

needsWIFProvision := inferenceWIFProvider == ""

Expand Down Expand Up @@ -823,7 +807,14 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
printer.StepInfo(fmt.Sprintf(" Repo restriction: %s/%s", owner, repo))
printer.Blank()
}
for _, f := range files {
dryRunFiles, dryRunErr := repos.BuildScaffoldFiles(repos.InstallConfig{
Owner: owner, Repo: repo, Roles: roles,
VendorBinary: vendor, UpstreamRef: upstreamRef, UpstreamTag: upstreamTag,
})
if dryRunErr != nil {
return fmt.Errorf("generating scaffold files for dry run: %w", dryRunErr)
}
for _, f := range dryRunFiles {
printer.StepDone(fmt.Sprintf("Would write: %s (%d bytes)", f.Path, len(f.Content)))
}
printer.Blank()
Expand Down Expand Up @@ -970,46 +961,127 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
printer.StepDone("Mint validated and org registered")
}

if needsWIFProvision {
printer.StepStart("Provisioning WIF infrastructure")
provisioner := gcf.NewProvisioner(gcf.Config{
ProjectID: inferenceProject,
GitHubOrgs: []string{owner},
Repo: owner + "/" + repo,
WIFPoolName: gcf.DefaultInferencePool,
}, gcf.NewLiveGCFClient(inferenceProject))
var provErr error
inferenceWIFProvider, provErr = provisioner.ProvisionWIF(ctx)
if provErr != nil {
printer.StepFail("WIF provisioning failed")
return fmt.Errorf("provisioning WIF: %w", provErr)
// Delegate WIF provisioning, scaffold commit, and variable/secret
// writes to the reusable repos.Install function. This enables the
// future `fullsend repos install` command to share the same logic.
var wifProvisioner repos.WIFProvisioner
if c.testWIFProvisioner != nil {
wifProvisioner = c.testWIFProvisioner
} else if needsWIFProvision {
wifProvisioner = &gcfWIFAdapter{
provisioner: gcf.NewProvisioner(gcf.Config{
ProjectID: inferenceProject,
GitHubOrgs: []string{owner},
Repo: owner + "/" + repo,
WIFPoolName: gcf.DefaultInferencePool,
}, gcf.NewLiveGCFClient(inferenceProject)),
}
}

// Scaffold commit function wrapping layers.CommitScaffoldFiles, which
// provides retry on non-fast-forward errors, branch-protection fallback
// to PR delivery, and fork-based PR support for non-owner users.
scaffoldCommitFn := func(ctx context.Context, owner, repo string, files []forge.TreeFile, direct bool) error {
targetRepo, repoErr := client.GetRepo(ctx, owner, repo)
if repoErr != nil {
return fmt.Errorf("getting repo info: %w", repoErr)
}
commitMsg := fmt.Sprintf("chore: initialize fullsend-%s per-repo installation", version)
prTitle := "chore: initialize fullsend per-repo installation"
prBody := "This PR adds the fullsend scaffold files for per-repo installation.\n\n" +
"Merge this PR to activate fullsend workflows."
if direct {
printer.StepStart(fmt.Sprintf("Committing scaffold files to %s/%s (%s branch)",
owner, repo, targetRepo.DefaultBranch))
} else {
printer.StepStart(fmt.Sprintf("Creating scaffold PR for %s/%s (target: %s)",
owner, repo, targetRepo.DefaultBranch))
}
_, err := layers.CommitScaffoldFiles(ctx, client, printer, owner, repo,
targetRepo.DefaultBranch, commitMsg, prTitle, prBody, files, direct, os.Stdin)
return err
}

installCfg := repos.InstallConfig{
Owner: owner,
Repo: repo,
Roles: roles,
MintURL: mintURL,
InferenceProject: inferenceProject,
InferenceRegion: inferenceRegion,
UpstreamRef: upstreamRef,
UpstreamTag: upstreamTag,
SkipMintCheck: true, // already handled above
SkipAppSetup: true, // already handled above
SkipGuardCheck: true, // admin.go handles guard check itself
SkipWIF: !needsWIFProvision,
WIFProvider: inferenceWIFProvider,
VendorBinary: vendor,
Direct: c.Direct,
SkipScaffoldAndConfig: vendor, // vendor path commits scaffold+vendor atomically below
}

progressFn := func(_ string, phase, msg string) {
switch phase {
case "wif":
if strings.Contains(msg, "Provisioning") {
printer.StepStart(msg)
} else if strings.Contains(msg, "ready") {
printer.StepDone(msg)
printer.StepInfo("IAM policy changes may take up to 7 minutes to propagate")
printer.StepInfo("Agent workflows that authenticate via WIF may fail until propagation completes")
}
case "scaffold":
if strings.Contains(msg, "Committing") || strings.Contains(msg, "Generating") {
printer.StepStart(msg)
} else {
printer.StepDone(msg)
}
case "vars":
if strings.Contains(msg, "Configuring") {
printer.StepStart(msg)
} else {
printer.StepDone(msg)
}
case "secrets":
if strings.Contains(msg, "Configuring") {
printer.StepStart(msg)
} else {
printer.StepDone(msg)
}
}
printer.StepDone("WIF infrastructure ready")
printer.StepInfo("IAM policy changes may take up to 7 minutes to propagate")
printer.StepInfo("Agent workflows that authenticate via WIF may fail until propagation completes")
}

repoVars := map[string]string{
"FULLSEND_MINT_URL": mintURL,
"FULLSEND_GCP_REGION": inferenceRegion,
forge.PerRepoGuardVar: "true",
installResult, installErr := repos.Install(ctx, installCfg, client, wifProvisioner, scaffoldCommitFn, progressFn)
if installErr != nil {
return installErr
}

repoSecrets := map[string]string{
"FULLSEND_GCP_PROJECT_ID": inferenceProject,
"FULLSEND_GCP_WIF_PROVIDER": inferenceWIFProvider,
if installResult.WIFProvider != "" {
inferenceWIFProvider = installResult.WIFProvider
}

if vendor {
var vendorErr error
files, _, vendorErr = appendVendorTreeFiles(printer, owner, repo, files, vendor, fullsendBinary, fullsendSource)
scaffoldFiles, buildErr := repos.BuildScaffoldFiles(installCfg)
if buildErr != nil {
return fmt.Errorf("building scaffold files for vendor: %w", buildErr)
}
vendorFiles, _, vendorErr := appendVendorTreeFiles(printer, owner, repo, scaffoldFiles, vendor, fullsendBinary, fullsendSource)
if vendorErr != nil {
return fmt.Errorf("collecting vendored assets: %w", vendorErr)
}
}

if err := applyPerRepoScaffold(ctx, client, printer, owner, repo, files, repoVars, repoSecrets, c.Direct); err != nil {
return err
repoVars := map[string]string{
"FULLSEND_MINT_URL": mintURL,
"FULLSEND_GCP_REGION": inferenceRegion,
forge.PerRepoGuardVar: "true",
}
repoSecrets := map[string]string{
"FULLSEND_GCP_PROJECT_ID": inferenceProject,
"FULLSEND_GCP_WIF_PROVIDER": inferenceWIFProvider,
}
if err := applyPerRepoScaffold(ctx, client, printer, owner, repo, vendorFiles, repoVars, repoSecrets, c.Direct); err != nil {
return err
}
Comment thread
ggallen marked this conversation as resolved.
}

if !vendor {
Expand All @@ -1023,6 +1095,58 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
return nil
}

// gcfWIFAdapter wraps a gcf.Provisioner to implement repos.WIFProvisioner,
// bridging the GCF-specific provisioner to the package-agnostic interface.
type gcfWIFAdapter struct {
provisioner *gcf.Provisioner
}

func (a *gcfWIFAdapter) DiscoverMint(ctx context.Context) (*repos.MintDiscovery, error) {
if a.provisioner == nil {
return nil, repos.ErrMintNotFound
}
d, err := a.provisioner.DiscoverMint(ctx)
if err != nil {
if errors.Is(err, gcf.ErrFunctionNotFound) {
return nil, fmt.Errorf("%w: %w", repos.ErrMintNotFound, err)
}
return nil, err
}
return &repos.MintDiscovery{
URL: d.URL,
RoleAppIDs: d.RoleAppIDs,
PerRepoWIFRepos: d.PerRepoWIFRepos,
}, nil
}

func (a *gcfWIFAdapter) ProvisionWIF(ctx context.Context) (string, error) {
if a.provisioner == nil {
return "", fmt.Errorf("WIF provisioner not configured")
}
return a.provisioner.ProvisionWIF(ctx)
}

func (a *gcfWIFAdapter) RegisterPerRepoWIF(ctx context.Context, repo string) error {
if a.provisioner == nil {
return fmt.Errorf("WIF provisioner not configured")
}
return a.provisioner.RegisterPerRepoWIF(ctx, repo)
}

func (a *gcfWIFAdapter) EnsureOrgInMint(ctx context.Context, expectedURL string, org string) error {
if a.provisioner == nil {
return fmt.Errorf("WIF provisioner not configured")
}
return a.provisioner.EnsureOrgInMint(ctx, expectedURL, org)
}

func (a *gcfWIFAdapter) DeletePerRepoWIF(ctx context.Context, repo string) error {
if a.provisioner == nil {
return fmt.Errorf("WIF provisioner not configured")
}
return a.provisioner.RemoveRepoFromMint(ctx, repo)
}

// applyPerRepoScaffold commits scaffold files to the repo's default branch
// and configures the repository variables and secrets needed for fullsend.
func applyPerRepoScaffold(ctx context.Context, client forge.Client, printer *ui.Printer,
Expand Down
Loading
Loading