diff --git a/.github/workflows/reusable-dispatch.yml b/.github/workflows/reusable-dispatch.yml index e5fab0193..4c49eeb22 100644 --- a/.github/workflows/reusable-dispatch.yml +++ b/.github/workflows/reusable-dispatch.yml @@ -63,7 +63,7 @@ on: FULLSEND_GCP_WIF_PROVIDER: required: false FULLSEND_GCP_PROJECT_ID: - required: true + required: false OTEL_EXPORTER_OTLP_TRACES_HEADERS: description: >- OTLP headers for ADR 0050 Level 2 trace export, forwarded to all diff --git a/docs/guides/infrastructure/mint-administration.md b/docs/guides/infrastructure/mint-administration.md index e04e6201b..080e27db8 100644 --- a/docs/guides/infrastructure/mint-administration.md +++ b/docs/guides/infrastructure/mint-administration.md @@ -32,6 +32,49 @@ https://fullsend-mint-gljhbkcloq-uc.a.run.app Pass this URL as `--mint-url` when running `fullsend github setup`, or set the `FULLSEND_MINT_URL` repository/org variable in GitHub. If you are using the hosted mint, the rest of this guide (deploying, enrolling, troubleshooting) is handled by the fullsend team — you do not need to manage mint infrastructure yourself. +## Credential modes + +Fullsend supports three credential modes that control how a repository authenticates to the token mint. The mode is set per forge section or per repo in `repos.yaml` via `credential_mode`, or automatically resolved during `repos install` based on the provided flags. + +| Mode | Forge | Authentication path | Required secrets/variables | +|------|-------|--------------------|----| +| `wif` | GitHub, GitLab | OIDC token exchanged via GCP Workload Identity Federation | `FULLSEND_GCP_WIF_PROVIDER`, `FULLSEND_GCP_PROJECT_ID`, `FULLSEND_MINT_URL` | +| `oidc` | GitHub | OIDC token sent directly to a public mint | `FULLSEND_MINT_URL` | +| `token` | GitLab | Bot PAT stored as a CI/CD variable | CI/CD variable with the PAT | + +### When each mode applies + +**`wif` (Workload Identity Federation)** — the default when `--inference-project` is provided during installation. The workflow authenticates to GCP via WIF, then exchanges the WIF-issued token for a GitHub App installation token at the mint. This mode requires a GCP project with WIF infrastructure provisioned via `mint deploy` and `mint enroll`. Use this mode for private mints or when GCP inference (Vertex AI) is configured. + +**`oidc` (direct OIDC)** — the default for GitHub repos when no `--inference-project` is provided. The workflow sends the GitHub Actions OIDC token directly to a public mint (one deployed with `--public`). No GCP WIF infrastructure is needed on the repo side. The generated shim workflow omits `FULLSEND_GCP_WIF_PROVIDER` and `FULLSEND_GCP_PROJECT_ID` secrets since they are not used in this mode. + +**`token` (bot PAT)** — the default for GitLab repos when no `--inference-project` is provided. The workflow uses a bot personal access token stored as a CI/CD variable. No OIDC or WIF infrastructure is needed. + +### Public vs private mint credentials + +**Public mint** (`ALLOWED_ORGS=*`): deployed with `mint deploy --public`. Any GitHub organization can authenticate using `oidc` mode — no per-org enrollment is required. Repos installed against a public mint default to `oidc` mode unless the operator explicitly sets `credential_mode: wif` with a GCP project. + +**Private (tight) mint**: deployed without `--public`. Only organizations listed in `ALLOWED_ORGS` can authenticate. Enrollment via `mint enroll` is required for each org or repo, and WIF infrastructure must be provisioned. Repos installed against a private mint use `wif` mode. + +### Overriding credential mode + +Set `credential_mode` in the forge section of `repos.yaml` to apply a default to all repos under that forge: + +```yaml +forge: + github: + mint_url: https://mint.example.com + credential_mode: oidc +``` + +Override per repo when a specific repo needs a different mode: + +```yaml +repos: + - repo: acme/special-repo + credential_mode: wif +``` + ## Prerequisites - **GCP project** with the following APIs enabled: diff --git a/internal/cli/github.go b/internal/cli/github.go index f280f5f33..f95976ce7 100644 --- a/internal/cli/github.go +++ b/internal/cli/github.go @@ -344,7 +344,7 @@ func runGitHubSetupPerRepo(ctx context.Context, client forge.Client, printer *ui } upstreamRef, upstreamTag := resolveUpstreamRef() - installFiles, err := scaffold.CollectPerRepoInstallFiles(cfg.vendor, upstreamRef, upstreamTag) + installFiles, err := scaffold.CollectPerRepoInstallFiles(cfg.vendor, upstreamRef, upstreamTag, "") if err != nil { return fmt.Errorf("collecting per-repo scaffold files: %w", err) } diff --git a/internal/repos/batch_install.go b/internal/repos/batch_install.go index b1d76c8e3..9107dc191 100644 --- a/internal/repos/batch_install.go +++ b/internal/repos/batch_install.go @@ -446,10 +446,12 @@ func BatchInstall(ctx context.Context, cfg BatchInstallConfig, // running binary, fetch scaffold templates from the repo // at the pinned ref instead of using embedded templates. if manifestRef != "" && refResolver != nil { + resolvedCredMode := resolveCredentialMode(dr.resolved.Forge, dr.resolved.CredentialMode, cfg.InferenceProject, dr.discoveredCredMode) scaffoldFiles, fetchErr := FetchRemoteScaffold( ctx, refResolver.client, manifestRef, ref, dr.resolved.Forge, cfg.Manifest.Forge.GitLab.RunnerTags, + resolvedCredMode, ) if fetchErr == nil { installCfg.PrebuiltScaffoldFiles = scaffoldFiles diff --git a/internal/repos/batch_install_test.go b/internal/repos/batch_install_test.go index ce7b7635b..bfbb725d1 100644 --- a/internal/repos/batch_install_test.go +++ b/internal/repos/batch_install_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/fullsend-ai/fullsend/internal/forge" + "github.com/fullsend-ai/fullsend/internal/scaffold" ) func newBatchManifest(repos ...string) *Manifest { @@ -1281,7 +1282,7 @@ func TestFetchRemoteScaffold_GitLab(t *testing.T) { fc.FileContentsRef[shimOwner+"/"+shimRepo+"/"+sp.repoPath+"@"+ref] = []byte(content) } - files, err := FetchRemoteScaffold(context.Background(), fc, ref, sha, ForgeGitLab, []string{"docker"}) + files, err := FetchRemoteScaffold(context.Background(), fc, ref, sha, ForgeGitLab, []string{"docker"}, "") if err != nil { t.Fatalf("FetchRemoteScaffold() error: %v", err) } @@ -1302,9 +1303,37 @@ func TestFetchRemoteScaffold_GitLab(t *testing.T) { } } +func TestFetchRemoteScaffold_GitHubOIDCMode(t *testing.T) { + fc := forge.NewFakeClient() + ref := "v0.35.0" + sha := "deadbeef1234567890abcdef1234567890abcdef" + + raw, err := scaffold.PerRepoShimTemplate() + if err != nil { + t.Fatalf("PerRepoShimTemplate() error: %v", err) + } + fc.FileContentsRef[shimOwner+"/"+shimRepo+"/"+scaffoldGitHubShimPath+"@"+ref] = raw + + files, err := FetchRemoteScaffold(context.Background(), fc, ref, sha, ForgeGitHub, nil, "oidc") + if err != nil { + t.Fatalf("FetchRemoteScaffold() error: %v", err) + } + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d", len(files)) + } + + content := string(files[0].Content) + if strings.Contains(content, "FULLSEND_GCP_WIF_PROVIDER") { + t.Error("OIDC mode should strip FULLSEND_GCP_WIF_PROVIDER from remote scaffold") + } + if strings.Contains(content, "FULLSEND_GCP_PROJECT_ID") { + t.Error("OIDC mode should strip FULLSEND_GCP_PROJECT_ID from remote scaffold") + } +} + func TestFetchRemoteScaffold_UnsupportedForge(t *testing.T) { fc := forge.NewFakeClient() - _, err := FetchRemoteScaffold(context.Background(), fc, "v1.0.0", "sha", "unsupported", nil) + _, err := FetchRemoteScaffold(context.Background(), fc, "v1.0.0", "sha", "unsupported", nil, "") if err == nil { t.Fatal("expected error for unsupported forge") } diff --git a/internal/repos/install.go b/internal/repos/install.go index 378828f12..2ab4b7c05 100644 --- a/internal/repos/install.go +++ b/internal/repos/install.go @@ -313,7 +313,8 @@ func BuildScaffoldFiles(cfg InstallConfig) ([]forge.TreeFile, error) { } else { switch cfg.Forge { case ForgeGitHub: - installFiles, err = scaffold.CollectPerRepoInstallFiles(cfg.VendorBinary, cfg.UpstreamRef, cfg.UpstreamTag) + credMode := resolveCredentialMode(cfg.Forge, cfg.CredentialMode, cfg.InferenceProject, cfg.DiscoveredCredMode) + installFiles, err = scaffold.CollectPerRepoInstallFiles(cfg.VendorBinary, cfg.UpstreamRef, cfg.UpstreamTag, credMode) case ForgeGitLab: installFiles, err = scaffold.CollectGitLabPerRepoInstallFiles(cfg.RunnerTags, cfg.UpstreamRef, cfg.UpstreamTag) default: diff --git a/internal/repos/remote_scaffold.go b/internal/repos/remote_scaffold.go index 7aec46c4e..e108ec53c 100644 --- a/internal/repos/remote_scaffold.go +++ b/internal/repos/remote_scaffold.go @@ -34,10 +34,11 @@ var scaffoldGitLabPaths = []struct { func FetchRemoteScaffold(ctx context.Context, ghClient forge.Client, manifestRef, resolvedSHA, forgeName string, runnerTags []string, + credentialMode string, ) (scaffold.InstallFiles, error) { switch forgeName { case ForgeGitHub: - return fetchRemoteGitHubScaffold(ctx, ghClient, manifestRef, resolvedSHA) + return fetchRemoteGitHubScaffold(ctx, ghClient, manifestRef, resolvedSHA, credentialMode) case ForgeGitLab: return fetchRemoteGitLabScaffold(ctx, ghClient, manifestRef, resolvedSHA, runnerTags) default: @@ -46,7 +47,7 @@ func FetchRemoteScaffold(ctx context.Context, ghClient forge.Client, } func fetchRemoteGitHubScaffold(ctx context.Context, client forge.Client, - manifestRef, resolvedSHA string, + manifestRef, resolvedSHA, credentialMode string, ) (scaffold.InstallFiles, error) { content, err := client.GetFileContentAtRef(ctx, shimOwner, shimRepo, scaffoldGitHubShimPath, manifestRef) if err != nil { @@ -54,6 +55,7 @@ func fetchRemoteGitHubScaffold(ctx context.Context, client forge.Client, } opts := scaffold.RenderOptionsForInstall(false, true, resolvedSHA, manifestRef) + opts.CredentialMode = credentialMode rendered, err := scaffold.RenderTemplate("templates/shim-per-repo.yaml", content, opts) if err != nil { return nil, fmt.Errorf("rendering remote GitHub shim: %w", err) diff --git a/internal/scaffold/installfiles.go b/internal/scaffold/installfiles.go index 391e7e735..3fa9ab2bf 100644 --- a/internal/scaffold/installfiles.go +++ b/internal/scaffold/installfiles.go @@ -44,8 +44,11 @@ func CollectInstallFiles(opts CollectInstallFilesOptions) (InstallFiles, error) } // CollectPerRepoInstallFiles gathers files for per-repo installation. -func CollectPerRepoInstallFiles(vendored bool, upstreamRef, upstreamTag string) (InstallFiles, error) { +// credentialMode controls WIF secret inclusion in the shim template: +// "oidc" omits WIF secrets; "wif" or "" includes them. +func CollectPerRepoInstallFiles(vendored bool, upstreamRef, upstreamTag, credentialMode string) (InstallFiles, error) { opts := RenderOptionsForInstall(vendored, true, upstreamRef, upstreamTag) + opts.CredentialMode = credentialMode shimRaw, err := PerRepoShimTemplate() if err != nil { diff --git a/internal/scaffold/installfiles_test.go b/internal/scaffold/installfiles_test.go index 648f1c7c7..b8d28b76a 100644 --- a/internal/scaffold/installfiles_test.go +++ b/internal/scaffold/installfiles_test.go @@ -41,7 +41,7 @@ func TestCollectInstallFiles_PerRepoPrefix(t *testing.T) { } func TestCollectPerRepoInstallFiles(t *testing.T) { - files, err := CollectPerRepoInstallFiles(false, "", "") + files, err := CollectPerRepoInstallFiles(false, "", "", "") require.NoError(t, err) require.NotEmpty(t, files) assert.Equal(t, ".github/workflows/fullsend.yaml", files[0].Path) @@ -72,12 +72,32 @@ func TestCollectInstallFiles_Vendored(t *testing.T) { } func TestCollectPerRepoInstallFiles_Vendored(t *testing.T) { - files, err := CollectPerRepoInstallFiles(true, "", "") + files, err := CollectPerRepoInstallFiles(true, "", "", "") require.NoError(t, err) require.NotEmpty(t, files) assert.Contains(t, string(files[0].Content), "reusable-") } +func TestCollectPerRepoInstallFiles_OIDCMode(t *testing.T) { + files, err := CollectPerRepoInstallFiles(false, "", "", "oidc") + require.NoError(t, err) + require.NotEmpty(t, files) + assert.Equal(t, ".github/workflows/fullsend.yaml", files[0].Path) + content := string(files[0].Content) + assert.NotContains(t, content, "FULLSEND_GCP_WIF_PROVIDER") + assert.NotContains(t, content, "FULLSEND_GCP_PROJECT_ID") + assert.Contains(t, content, "OTEL_EXPORTER_OTLP_TRACES_HEADERS") +} + +func TestCollectPerRepoInstallFiles_WIFMode(t *testing.T) { + files, err := CollectPerRepoInstallFiles(false, "", "", "wif") + require.NoError(t, err) + require.NotEmpty(t, files) + content := string(files[0].Content) + assert.Contains(t, content, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}") + assert.Contains(t, content, "FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}") +} + func TestNoCustomizedDirsInInstallFiles(t *testing.T) { files, err := CollectInstallFiles(CollectInstallFilesOptions{}) require.NoError(t, err) @@ -86,7 +106,7 @@ func TestNoCustomizedDirsInInstallFiles(t *testing.T) { "install files should not include deprecated customized/ paths, got: %s", f.Path) } - prFiles, err := CollectPerRepoInstallFiles(false, "", "") + prFiles, err := CollectPerRepoInstallFiles(false, "", "", "") require.NoError(t, err) for _, f := range prFiles { assert.False(t, strings.Contains(f.Path, "customized/"), diff --git a/internal/scaffold/render.go b/internal/scaffold/render.go index 5e49f1026..ebbc4fab7 100644 --- a/internal/scaffold/render.go +++ b/internal/scaffold/render.go @@ -9,11 +9,12 @@ import ( // RenderOptions controls install-time substitution for shim and thin-caller templates. type RenderOptions struct { - Vendored bool - PerRepo bool - UpstreamRef string // commit SHA to pin workflow refs to; empty = use DefaultUpstreamRef - UpstreamTag string // version tag for traceability comment (e.g. "v0.19.0") - RunnerImage string // GitHub Actions runner image; empty = use DefaultGHRunner + Vendored bool + PerRepo bool + UpstreamRef string // commit SHA to pin workflow refs to; empty = use DefaultUpstreamRef + UpstreamTag string // version tag for traceability comment (e.g. "v0.19.0") + RunnerImage string // GitHub Actions runner image; empty = use DefaultGHRunner + CredentialMode string // credential mode (wif/oidc); controls WIF secret inclusion in per-repo shim } // RenderOptionsForInstall builds render options from the --vendor flag. @@ -50,6 +51,7 @@ func RenderTemplate(path string, content []byte, opts RenderOptions) ([]byte, er out = strings.ReplaceAll(out, "__REUSABLE_WORKFLOW__", reusableWorkflowUses(stage, opts)) case path == "templates/shim-per-repo.yaml": out = strings.ReplaceAll(out, "__REUSABLE_DISPATCH__", reusableDispatchUses(opts)) + out = stripWIFSecrets(out, opts) } out = strings.ReplaceAll(out, "__FULLSEND_AI_REF__", resolvedRefWithComment(opts)) @@ -110,6 +112,21 @@ func reusableWorkflowUses(stage string, opts RenderOptions) string { return uses } +// stripWIFSecrets removes WIF secret references from the per-repo shim +// when credential mode is "oidc". OIDC repos authenticate directly to +// a public mint without GCP WIF, so these secrets are unnecessary and +// their presence in the generated workflow is confusing. +func stripWIFSecrets(content string, opts RenderOptions) string { + if opts.CredentialMode != "oidc" { + return content + } + content = strings.ReplaceAll(content, + " FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}\n", "") + content = strings.ReplaceAll(content, + " FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}\n", "") + return content +} + func reusableDispatchUses(opts RenderOptions) string { if opts.Vendored { return "./.github/workflows/reusable-dispatch.yml" diff --git a/internal/scaffold/render_test.go b/internal/scaffold/render_test.go index f02476141..67f63811c 100644 --- a/internal/scaffold/render_test.go +++ b/internal/scaffold/render_test.go @@ -210,6 +210,55 @@ func TestRenderPerRepoShimRunner(t *testing.T) { assert.NotContains(t, out, "__GH_RUNNER__") } +func TestRenderPerRepoShimWIFMode(t *testing.T) { + raw, err := PerRepoShimTemplate() + require.NoError(t, err) + + rendered, err := RenderTemplate("templates/shim-per-repo.yaml", raw, RenderOptions{ + PerRepo: true, + CredentialMode: "wif", + }) + require.NoError(t, err) + out := string(rendered) + assert.Contains(t, out, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}") + assert.Contains(t, out, "FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}") + assert.Contains(t, out, "OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_TRACES_HEADERS }}") + assertFreeOfRenderPlaceholders(t, out) +} + +func TestRenderPerRepoShimOIDCMode(t *testing.T) { + raw, err := PerRepoShimTemplate() + require.NoError(t, err) + + rendered, err := RenderTemplate("templates/shim-per-repo.yaml", raw, RenderOptions{ + PerRepo: true, + CredentialMode: "oidc", + }) + require.NoError(t, err) + out := string(rendered) + assert.NotContains(t, out, "FULLSEND_GCP_WIF_PROVIDER") + assert.NotContains(t, out, "FULLSEND_GCP_PROJECT_ID") + // OTEL secrets should still be present + assert.Contains(t, out, "OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_TRACES_HEADERS }}") + assert.Contains(t, out, "OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}") + assertFreeOfRenderPlaceholders(t, out) +} + +func TestRenderPerRepoShimDefaultCredMode(t *testing.T) { + raw, err := PerRepoShimTemplate() + require.NoError(t, err) + + // Empty credential mode defaults to including WIF secrets + rendered, err := RenderTemplate("templates/shim-per-repo.yaml", raw, RenderOptions{ + PerRepo: true, + }) + require.NoError(t, err) + out := string(rendered) + assert.Contains(t, out, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}") + assert.Contains(t, out, "FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}") + assertFreeOfRenderPlaceholders(t, out) +} + func TestRenderFallbackToDefaultRef(t *testing.T) { raw, err := FullsendRepoFile(".github/workflows/triage.yml") require.NoError(t, err)