@@ -29,7 +29,8 @@ import (
2929 "github.com/fullsend-ai/fullsend/internal/inference/vertex"
3030 "github.com/fullsend-ai/fullsend/internal/layers"
3131 "github.com/fullsend-ai/fullsend/internal/mintcore"
32- "github.com/fullsend-ai/fullsend/internal/scaffold"
32+ "github.com/fullsend-ai/fullsend/internal/repos"
33+
3334 "github.com/fullsend-ai/fullsend/internal/ui"
3435)
3536
@@ -655,35 +656,7 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
655656 printer .StepWarn ("Using provided WIF provider value — skipping inference provider auto-provisioning" )
656657 }
657658
658- cfg := config .NewPerRepoConfig (roles , repoFullName )
659- if err := cfg .Validate (); err != nil {
660- return fmt .Errorf ("invalid config: %w" , err )
661- }
662-
663- cfgYAML , err := cfg .Marshal ()
664- if err != nil {
665- return fmt .Errorf ("marshaling per-repo config: %w" , err )
666- }
667-
668659 upstreamRef , upstreamTag := resolveUpstreamRef ()
669- installFiles , err := scaffold .CollectPerRepoInstallFiles (vendor , upstreamRef , upstreamTag )
670- if err != nil {
671- return fmt .Errorf ("collecting per-repo scaffold files: %w" , err )
672- }
673-
674- var files []forge.TreeFile
675- for _ , f := range installFiles {
676- files = append (files , forge.TreeFile {
677- Path : f .Path ,
678- Content : f .Content ,
679- Mode : f .Mode ,
680- })
681- }
682- files = append (files , forge.TreeFile {
683- Path : ".fullsend/config.yaml" ,
684- Content : cfgYAML ,
685- Mode : "100644" ,
686- })
687660
688661 needsWIFProvision := inferenceWIFProvider == ""
689662
@@ -823,7 +796,14 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
823796 printer .StepInfo (fmt .Sprintf (" Repo restriction: %s/%s" , owner , repo ))
824797 printer .Blank ()
825798 }
826- for _ , f := range files {
799+ dryRunFiles , dryRunErr := repos .BuildScaffoldFiles (repos.InstallConfig {
800+ Owner : owner , Repo : repo , Roles : roles ,
801+ VendorBinary : vendor , UpstreamRef : upstreamRef , UpstreamTag : upstreamTag ,
802+ })
803+ if dryRunErr != nil {
804+ return fmt .Errorf ("generating scaffold files for dry run: %w" , dryRunErr )
805+ }
806+ for _ , f := range dryRunFiles {
827807 printer .StepDone (fmt .Sprintf ("Would write: %s (%d bytes)" , f .Path , len (f .Content )))
828808 }
829809 printer .Blank ()
@@ -970,46 +950,125 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
970950 printer .StepDone ("Mint validated and org registered" )
971951 }
972952
953+ // Delegate WIF provisioning, scaffold commit, and variable/secret
954+ // writes to the reusable repos.Install function. This enables the
955+ // future `fullsend repos install` command to share the same logic.
956+ var wifProv repos.WIFProvisioner
973957 if needsWIFProvision {
974- printer .StepStart ("Provisioning WIF infrastructure" )
975- provisioner := gcf .NewProvisioner (gcf.Config {
976- ProjectID : inferenceProject ,
977- GitHubOrgs : []string {owner },
978- Repo : owner + "/" + repo ,
979- WIFPoolName : gcf .DefaultInferencePool ,
980- }, gcf .NewLiveGCFClient (inferenceProject ))
981- var provErr error
982- inferenceWIFProvider , provErr = provisioner .ProvisionWIF (ctx )
983- if provErr != nil {
984- printer .StepFail ("WIF provisioning failed" )
985- return fmt .Errorf ("provisioning WIF: %w" , provErr )
958+ wifProv = & gcfWIFAdapter {
959+ provisioner : gcf .NewProvisioner (gcf.Config {
960+ ProjectID : inferenceProject ,
961+ GitHubOrgs : []string {owner },
962+ Repo : owner + "/" + repo ,
963+ WIFPoolName : gcf .DefaultInferencePool ,
964+ }, gcf .NewLiveGCFClient (inferenceProject )),
965+ }
966+ }
967+
968+ // Scaffold commit function wrapping layers.CommitScaffoldFiles, which
969+ // provides retry on non-fast-forward errors, branch-protection fallback
970+ // to PR delivery, and fork-based PR support for non-owner users.
971+ scaffoldCommitFn := func (ctx context.Context , owner , repo string , files []forge.TreeFile , direct bool ) (string , error ) {
972+ targetRepo , repoErr := client .GetRepo (ctx , owner , repo )
973+ if repoErr != nil {
974+ return "" , fmt .Errorf ("getting repo info: %w" , repoErr )
975+ }
976+ commitMsg := fmt .Sprintf ("chore: initialize fullsend-%s per-repo installation" , version )
977+ prTitle := "chore: initialize fullsend per-repo installation"
978+ prBody := "This PR adds the fullsend scaffold files for per-repo installation.\n \n " +
979+ "Merge this PR to activate fullsend workflows."
980+ if direct {
981+ printer .StepStart (fmt .Sprintf ("Committing scaffold files to %s/%s (%s branch)" ,
982+ owner , repo , targetRepo .DefaultBranch ))
983+ } else {
984+ printer .StepStart (fmt .Sprintf ("Creating scaffold PR for %s/%s (target: %s)" ,
985+ owner , repo , targetRepo .DefaultBranch ))
986+ }
987+ _ , err := layers .CommitScaffoldFiles (ctx , client , printer , owner , repo ,
988+ targetRepo .DefaultBranch , commitMsg , prTitle , prBody , files , direct , os .Stdin )
989+ return "" , err
990+ }
991+
992+ installCfg := repos.InstallConfig {
993+ Owner : owner ,
994+ Repo : repo ,
995+ Roles : roles ,
996+ MintURL : mintURL ,
997+ InferenceProject : inferenceProject ,
998+ InferenceRegion : inferenceRegion ,
999+ UpstreamRef : upstreamRef ,
1000+ UpstreamTag : upstreamTag ,
1001+ SkipMintCheck : true , // already handled above
1002+ SkipAppSetup : true , // already handled above
1003+ SkipGuardCheck : true , // admin.go handles guard check itself
1004+ SkipWIF : ! needsWIFProvision ,
1005+ WIFProvider : inferenceWIFProvider ,
1006+ VendorBinary : vendor ,
1007+ Direct : c .Direct ,
1008+ SkipScaffoldAndConfig : vendor , // vendor path commits scaffold+vendor atomically below
1009+ }
1010+
1011+ progressFn := func (_ string , phase , msg string ) {
1012+ switch phase {
1013+ case "wif" :
1014+ if strings .Contains (msg , "Provisioning" ) {
1015+ printer .StepStart (msg )
1016+ } else if strings .Contains (msg , "ready" ) {
1017+ printer .StepDone (msg )
1018+ printer .StepInfo ("IAM policy changes may take up to 7 minutes to propagate" )
1019+ printer .StepInfo ("Agent workflows that authenticate via WIF may fail until propagation completes" )
1020+ }
1021+ case "scaffold" :
1022+ if strings .Contains (msg , "Committing" ) || strings .Contains (msg , "Generating" ) {
1023+ printer .StepStart (msg )
1024+ } else {
1025+ printer .StepDone (msg )
1026+ }
1027+ case "vars" :
1028+ if strings .Contains (msg , "Configuring" ) {
1029+ printer .StepStart (msg )
1030+ } else {
1031+ printer .StepDone (msg )
1032+ }
1033+ case "secrets" :
1034+ if strings .Contains (msg , "Configuring" ) {
1035+ printer .StepStart (msg )
1036+ } else {
1037+ printer .StepDone (msg )
1038+ }
9861039 }
987- printer .StepDone ("WIF infrastructure ready" )
988- printer .StepInfo ("IAM policy changes may take up to 7 minutes to propagate" )
989- printer .StepInfo ("Agent workflows that authenticate via WIF may fail until propagation completes" )
9901040 }
9911041
992- repoVars := map [string ]string {
993- "FULLSEND_MINT_URL" : mintURL ,
994- "FULLSEND_GCP_REGION" : inferenceRegion ,
995- forge .PerRepoGuardVar : "true" ,
1042+ installResult , installErr := repos .Install (ctx , installCfg , client , wifProv , scaffoldCommitFn , progressFn )
1043+ if installErr != nil {
1044+ return installErr
9961045 }
9971046
998- repoSecrets := map [string ]string {
999- "FULLSEND_GCP_PROJECT_ID" : inferenceProject ,
1000- "FULLSEND_GCP_WIF_PROVIDER" : inferenceWIFProvider ,
1047+ if installResult .WIFProvider != "" {
1048+ inferenceWIFProvider = installResult .WIFProvider
10011049 }
10021050
10031051 if vendor {
1004- var vendorErr error
1005- files , _ , vendorErr = appendVendorTreeFiles (printer , owner , repo , files , vendor , fullsendBinary , fullsendSource )
1052+ scaffoldFiles , buildErr := repos .BuildScaffoldFiles (installCfg )
1053+ if buildErr != nil {
1054+ return fmt .Errorf ("building scaffold files for vendor: %w" , buildErr )
1055+ }
1056+ vendorFiles , _ , vendorErr := appendVendorTreeFiles (printer , owner , repo , scaffoldFiles , vendor , fullsendBinary , fullsendSource )
10061057 if vendorErr != nil {
10071058 return fmt .Errorf ("collecting vendored assets: %w" , vendorErr )
10081059 }
1009- }
1010-
1011- if err := applyPerRepoScaffold (ctx , client , printer , owner , repo , files , repoVars , repoSecrets , c .Direct ); err != nil {
1012- return err
1060+ repoVars := map [string ]string {
1061+ "FULLSEND_MINT_URL" : mintURL ,
1062+ "FULLSEND_GCP_REGION" : inferenceRegion ,
1063+ forge .PerRepoGuardVar : "true" ,
1064+ }
1065+ repoSecrets := map [string ]string {
1066+ "FULLSEND_GCP_PROJECT_ID" : inferenceProject ,
1067+ "FULLSEND_GCP_WIF_PROVIDER" : inferenceWIFProvider ,
1068+ }
1069+ if err := applyPerRepoScaffold (ctx , client , printer , owner , repo , vendorFiles , repoVars , repoSecrets , c .Direct ); err != nil {
1070+ return err
1071+ }
10131072 }
10141073
10151074 if ! vendor {
@@ -1023,6 +1082,55 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
10231082 return nil
10241083}
10251084
1085+ // gcfWIFAdapter wraps a gcf.Provisioner to implement repos.WIFProvisioner,
1086+ // bridging the GCF-specific provisioner to the package-agnostic interface.
1087+ type gcfWIFAdapter struct {
1088+ provisioner * gcf.Provisioner
1089+ }
1090+
1091+ func (a * gcfWIFAdapter ) DiscoverMint (ctx context.Context ) (* repos.MintDiscovery , error ) {
1092+ if a .provisioner == nil {
1093+ return nil , repos .ErrMintNotFound
1094+ }
1095+ d , err := a .provisioner .DiscoverMint (ctx )
1096+ if err != nil {
1097+ return nil , err
1098+ }
1099+ return & repos.MintDiscovery {
1100+ URL : d .URL ,
1101+ RoleAppIDs : d .RoleAppIDs ,
1102+ PerRepoWIFRepos : d .PerRepoWIFRepos ,
1103+ }, nil
1104+ }
1105+
1106+ func (a * gcfWIFAdapter ) ProvisionWIF (ctx context.Context ) (string , error ) {
1107+ if a .provisioner == nil {
1108+ return "" , fmt .Errorf ("WIF provisioner not configured" )
1109+ }
1110+ return a .provisioner .ProvisionWIF (ctx )
1111+ }
1112+
1113+ func (a * gcfWIFAdapter ) RegisterPerRepoWIF (ctx context.Context , repo string ) error {
1114+ if a .provisioner == nil {
1115+ return fmt .Errorf ("WIF provisioner not configured" )
1116+ }
1117+ return a .provisioner .RegisterPerRepoWIF (ctx , repo )
1118+ }
1119+
1120+ func (a * gcfWIFAdapter ) EnsureOrgInMint (ctx context.Context , expectedURL string , org string ) error {
1121+ if a .provisioner == nil {
1122+ return fmt .Errorf ("WIF provisioner not configured" )
1123+ }
1124+ return a .provisioner .EnsureOrgInMint (ctx , expectedURL , org )
1125+ }
1126+
1127+ func (a * gcfWIFAdapter ) DeletePerRepoWIF (ctx context.Context , repo string ) error {
1128+ if a .provisioner == nil {
1129+ return fmt .Errorf ("WIF provisioner not configured" )
1130+ }
1131+ return a .provisioner .RemoveRepoFromMint (ctx , repo )
1132+ }
1133+
10261134// applyPerRepoScaffold commits scaffold files to the repo's default branch
10271135// and configures the repository variables and secrets needed for fullsend.
10281136func applyPerRepoScaffold (ctx context.Context , client forge.Client , printer * ui.Printer ,
0 commit comments