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
2 changes: 1 addition & 1 deletion .github/workflows/reusable-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions docs/guides/infrastructure/mint-administration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions internal/repos/batch_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 31 additions & 2 deletions internal/repos/batch_install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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")
}
Expand Down
3 changes: 2 additions & 1 deletion internal/repos/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions internal/repos/remote_scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ var scaffoldGitLabPaths = []struct {
func FetchRemoteScaffold(ctx context.Context, ghClient forge.Client,
manifestRef, resolvedSHA, forgeName string,
runnerTags []string,
credentialMode string,
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
) (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:
Expand All @@ -46,14 +47,15 @@ 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 {
return nil, fmt.Errorf("fetching GitHub shim template at %s: %w", manifestRef, err)
}

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)
Expand Down
5 changes: 4 additions & 1 deletion internal/scaffold/installfiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
opts := RenderOptionsForInstall(vendored, true, upstreamRef, upstreamTag)
opts.CredentialMode = credentialMode

shimRaw, err := PerRepoShimTemplate()
if err != nil {
Expand Down
26 changes: 23 additions & 3 deletions internal/scaffold/installfiles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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/"),
Expand Down
27 changes: 22 additions & 5 deletions internal/scaffold/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 {
Comment thread
ggallen marked this conversation as resolved.
if opts.CredentialMode != "oidc" {
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
return content
}
content = strings.ReplaceAll(content,
" FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}\n", "")
Comment thread
ggallen marked this conversation as resolved.
content = strings.ReplaceAll(content,
" FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}\n", "")
Comment thread
ggallen marked this conversation as resolved.
return content
}

func reusableDispatchUses(opts RenderOptions) string {
if opts.Vendored {
return "./.github/workflows/reusable-dispatch.yml"
Expand Down
49 changes: 49 additions & 0 deletions internal/scaffold/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading