diff --git a/cmd/wfctl/infra_bootstrap.go b/cmd/wfctl/infra_bootstrap.go index e7bc3c92..af2e17a1 100644 --- a/cmd/wfctl/infra_bootstrap.go +++ b/cmd/wfctl/infra_bootstrap.go @@ -563,6 +563,7 @@ var bootstrapSecrets = func(ctx context.Context, provider secrets.Provider, cfg // the provider once and subsequent lookups are O(1). Resolved lazily on // the first write-only Get. var listSet map[string]struct{} + var githubListFoldSet map[string]struct{} var listErr error var listDone bool lookupViaList := func(key string) (bool, error) { @@ -571,8 +572,14 @@ var bootstrapSecrets = func(ctx context.Context, provider secrets.Provider, cfg listErr = err if err == nil { listSet = make(map[string]struct{}, len(names)) + if provider.Name() == "github" { + githubListFoldSet = make(map[string]struct{}, len(names)) + } for _, n := range names { listSet[n] = struct{}{} + if githubListFoldSet != nil { + githubListFoldSet[strings.ToUpper(n)] = struct{}{} + } } } listDone = true @@ -580,8 +587,17 @@ var bootstrapSecrets = func(ctx context.Context, provider secrets.Provider, cfg if listErr != nil && !errors.Is(listErr, secrets.ErrUnsupported) { return false, fmt.Errorf("list secrets to check %q: %w", key, listErr) } - _, ok := listSet[key] - return ok, nil + if _, ok := listSet[key]; ok { + return true, nil + } + // GitHub Actions secret names are case-insensitive and are commonly + // reported uppercased by the API. Generated subkeys such as + // SPACES_access_key must still match SPACES_ACCESS_KEY. + if githubListFoldSet != nil { + _, ok := githubListFoldSet[strings.ToUpper(key)] + return ok, nil + } + return false, nil } secretExists := func(key string) (bool, error) { _, err := provider.Get(ctx, key) @@ -667,6 +683,7 @@ var bootstrapSecrets = func(ctx context.Context, provider secrets.Provider, cfg // write-only providers (GitHub Actions) where Get is unsupported. listDone = false listSet = nil + githubListFoldSet = nil } else { // Normal path: check that EVERY expected stored key is already present // before skipping. provider_credential writes multiple sub-keys; if a diff --git a/cmd/wfctl/infra_bootstrap_secrets_test.go b/cmd/wfctl/infra_bootstrap_secrets_test.go index ef511526..927ac1c1 100644 --- a/cmd/wfctl/infra_bootstrap_secrets_test.go +++ b/cmd/wfctl/infra_bootstrap_secrets_test.go @@ -16,9 +16,15 @@ type writeOnlyProvider struct { getCalls int listCalls int listOK bool + name string } -func (p *writeOnlyProvider) Name() string { return "write-only-fake" } +func (p *writeOnlyProvider) Name() string { + if p.name != "" { + return p.name + } + return "write-only-fake" +} func (p *writeOnlyProvider) Get(_ context.Context, _ string) (string, error) { p.getCalls++ @@ -81,6 +87,35 @@ func TestBootstrapSecrets_WriteOnlyProviderSkipsExisting(t *testing.T) { } } +// TestBootstrapSecrets_GitHubProviderCredentialMatchesUppercaseList verifies +// GitHub's write-only secret list can satisfy mixed-case generated key probes. +// GitHub Actions secret names are case-insensitive, and the API reports common +// subkey names as uppercase (SPACES_ACCESS_KEY / SPACES_SECRET_KEY). Without +// this, auto-bootstrap attempts to recreate an existing upstream provider +// credential and DigitalOcean refuses the duplicate name. +func TestBootstrapSecrets_GitHubProviderCredentialMatchesUppercaseList(t *testing.T) { + withStubGenerator(t, func(_ context.Context, _ string, _ map[string]any) (string, error) { + t.Fatal("generator must not be called when GitHub-listed sub-keys already exist") + return "", nil + }) + p := &writeOnlyProvider{ + name: "github", + existing: []string{"SPACES_ACCESS_KEY", "SPACES_SECRET_KEY"}, + listOK: true, + } + cfg := &SecretsConfig{ + Generate: []SecretGen{ + {Key: "SPACES", Type: "provider_credential", Source: "digitalocean.spaces"}, + }, + } + if _, _, err := bootstrapSecrets(context.Background(), p, cfg, nil); err != nil { + t.Fatalf("bootstrapSecrets: %v", err) + } + if len(p.stored) != 0 { + t.Fatalf("stored = %v, want empty (GitHub-listed secrets already exist)", p.stored) + } +} + // TestBootstrapSecrets_WriteOnlyProviderGeneratesWhenMissing verifies the // fallback still generates when List shows the name is absent. func TestBootstrapSecrets_WriteOnlyProviderGeneratesWhenMissing(t *testing.T) {