From bbc1460e60576a88a2ad4cc5cc1012508aa3429d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 07:11:09 -0400 Subject: [PATCH 1/2] docs(plan): DNS providers + DynDNS + scoped secret-set SPEC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caveman SPEC covering: - Namecheap plugin (go-namecheap-sdk-backed) — T5..T8 - Hover plugin (scraper+TOTP, no SDK) — T9..T13 - Dynamic DNS module (multi-source IP detect, exp backoff) — T14..T16 - wfctl secrets set --scope ∈ {repo,env,org} (default repo) — T1..T2 - wfctl secrets setup --plugin: prompt for plugin-declared required_secrets[] + write to chosen scope — T3..T4 - Registry + scenarios + docs + integration tests — T17..T20 20 tasks, 16 constraints, 18 invariants. Status §B empty. Ships as multiple PRs across workflow, workflow-plugin-namecheap (new repo), workflow-plugin-hover (new repo), workflow-registry. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/plans/2026-05-20-dns-providers.md | 111 +++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/plans/2026-05-20-dns-providers.md diff --git a/docs/plans/2026-05-20-dns-providers.md b/docs/plans/2026-05-20-dns-providers.md new file mode 100644 index 00000000..137d809f --- /dev/null +++ b/docs/plans/2026-05-20-dns-providers.md @@ -0,0 +1,111 @@ +# DNS providers + DynDNS + scoped secret-set + +Caveman SPEC. See `FORMAT.md` for grammar. + +## §G — Goal + +Extend wfctl IaC surface for ∀ DNS provider, ∀ secret scope. Namecheap + +Hover + DynDNS shipped. Hover login: user+pw+TOTP. Plugin +declares required secrets → wfctl prompts → writes to scoped GH +target (org|repo|env). + +## §C — Constraints + +``` +C1: DNS provider plugin ! implements infra.dns resource type via existing iac.ResourceDriver shape (DO precedent: workflow-plugin-digitalocean/internal/drivers/dns.go) +C2: Namecheap client = github.com/namecheap/go-namecheap-sdk v1.7+ +C3: Hover ⊥ official SDK ∴ scraper-style HTTPS client w/ cookie jar (mirror github.com/pjslauta/hover-dyn-dns: POST https://www.hover.com/signin → TOTP challenge → cookie session → DNS CRUD via internal API) +C4: TOTP RFC 6238; Hover seed stored as base32-encoded HOVER_TOTP_SECRET; wfctl never logs the seed +C5: wfctl secrets set --scope ∈ {repo, env, org} ; default = repo (backwards-compat) +C6: GH org secrets ! visibility config (all | selected_repos | private_repos) +C7: GH env secrets ! environment_name flag +C8: wfctl secrets setup --plugin reads plugin manifest required_secrets[] → interactive prompt each → write to chosen scope +C9: infra.dyndns module: poll IP → diff vs current A record → update via DNS driver Update RPC +C10: dyndns ! polling cadence default = 5m; configurable +C11: dyndns IP-detect sources: icanhazip | ifconfig.me | opendns ; multiple sources for redundancy +C12: TOTP code generation in-process; ⊥ external `oathtool` dep +C13: Namecheap auth = (api_user, api_key, client_ip allowlist); wfctl secrets setup writes api_user + api_key +C14: Hover scraper resilient to login-page CSRF token rotation (parse `` each login) +C15: DynDNS state machine: detect → diff → update → wait → repeat; on err exponential backoff w/ jitter (max 1h) +C16: ∀ DNS plugin ! pass strict-contracts gRPC boundary (typed proto, no map[string]any) +``` + +## §I — Interfaces + +``` +api: GET https://api.namecheap.com/xml.response?Command=namecheap.domains.dns.getHosts → DomainDNSGetHostsResponse XML +api: POST https://api.namecheap.com/xml.response Command=namecheap.domains.dns.setHosts → DomainDNSSetHostsResponse +api: POST https://www.hover.com/signin form: {username, password, _token} → 302 redirect (TOTP page) +api: POST https://www.hover.com/signin/totp form: {code, _token} → 302 (session cookie) +api: GET https://www.hover.com/api/domains//dns → JSON {domains: [...]} +api: POST https://www.hover.com/api/dns form: {domain_id, name, type, content, ttl} +api: PUT https://www.hover.com/api/dns/ form: {content, ttl} +api: DELETE https://www.hover.com/api/dns/ +cmd: `wfctl secrets set --scope [--env ] [--visibility ]` +cmd: `wfctl secrets setup --plugin [--scope ]` +cmd: `wfctl secrets setup --provider ` (alias above) +env: NAMECHEAP_API_USER ! set if iac.dns provider=namecheap +env: NAMECHEAP_API_KEY ! set (sensitive) +env: NAMECHEAP_CLIENT_IP ! set ; whitelisted at api.namecheap.com +env: HOVER_USERNAME ! set if iac.dns provider=hover +env: HOVER_PASSWORD ! set (sensitive) +env: HOVER_TOTP_SECRET ! set (sensitive; base32 seed) +manifest: plugin.json required_secrets[] = [{name, sensitive, description, prompt}] +``` + +## §V — Invariants + +``` +V1: ∀ secret write via wfctl ! mask in stdout/stderr +V2: Hover login ! happens iff session cookie expired || ⊥ +V3: TOTP code ! regenerated on each login attempt (never cached) +V4: scope=org ! requires admin:org GH PAT scope +V5: scope=env ! requires repo + workflow GH PAT scope +V6: scope=repo (default) ! requires repo GH PAT scope +V7: required_secrets prompt ! masked input via term.ReadPassword when sensitive=true +V8: dyndns ! avoid update RPC if detected IP == current record IP +V9: dyndns ! exponential backoff on consecutive failures; max 1h; reset on success +V10: Hover cookie jar ! persisted across plugin restarts via /var/lib/wfctl/hover-session.json (mode 0600); ⊥ committed +V11: Namecheap client_ip allowlist ! validated against ipify.org on plugin start; warn if mismatch +V12: ∀ DNS provider plugin ! emit `infra.dns` resource shape: {ID, Type:"infra.dns", Outputs:{provider_id, records:[]}} +V13: dyndns module ! emit metrics (gauge dyndns_last_detected_ip, counter dyndns_updates_total{provider}) +V14: TOTP seed ! base32-decoded once on plugin Init; ⊥ logged +V15: Hover scraper ! User-Agent header set; otherwise hover may return CAPTCHA +V16: GH org secret ! created via PUT /orgs/{org}/actions/secrets/{name}; encrypted with org public key +V17: GH env secret ! created via PUT /repos/{owner}/{repo}/environments/{env}/secrets/{name}; encrypted with env public key +V18: wfctl secrets set --scope ! short-circuit list-by-name BEFORE create (mirrors DO-Spaces orphan fix patterns from workflow#732) +``` + +## §T — Tasks + +``` +id|status|task|cites +T1|.|workflow: extend secrets.GitHubSecretsProvider w/ scope (repo|env|org) constructor + Put switch on scope|C5,C6,C7,V4,V5,V6,V16,V17 +T2|.|wfctl secrets set --scope flag + delegation to scoped provider; default repo|C5,V18 +T3|.|wfctl secrets setup --plugin : read plugin.json required_secrets[], prompt each (sensitive=masked), write to scope|C8,V1,V7 +T4|.|wfctl secrets setup --provider : alias for --plugin (UX sugar)|C8 +T5|.|workflow-plugin-namecheap scaffold (new repo): plugin.json + cmd/ + go.mod + GoReleaser + CI|C2,C13,C16 +T6|.|namecheap DNSDriver implements interfaces.ResourceDriver for `infra.dns` (Create/Read/Update/Delete/Diff)|C1,C2,V12 +T7|.|namecheap required_secrets manifest entry: NAMECHEAP_API_USER, NAMECHEAP_API_KEY, NAMECHEAP_CLIENT_IP|C13 +T8|.|namecheap client_ip validation against ipify.org on plugin Start|V11 +T9|.|workflow-plugin-hover scaffold (new repo): plugin.json + cmd/ + go.mod + CI|C3,C16 +T10|.|hover HTTPS scraper client: login(user, pw, totp) → session cookie jar; List/Get/Create/Update/Delete record|C3,C14,V2,V15 +T11|.|hover required_secrets manifest entry: HOVER_USERNAME, HOVER_PASSWORD, HOVER_TOTP_SECRET (sensitive)|C8 +T12|.|in-process TOTP impl (RFC 6238 HMAC-SHA1, 30s window, 6 digit) — pure go, ⊥ deps|C4,C12,V3,V14 +T13|.|hover session persistence: /var/lib/wfctl/hover-session.json mode 0600; refresh on 401|V10 +T14|.|workflow: infra.dyndns module type — config{provider, domain, record_name, poll_interval, detect_via}|C9,C10,C11 +T15|.|dyndns module: polling loop, IP detect (multi-source quorum), diff, Update RPC, backoff|C15,V8,V9 +T16|.|dyndns metrics: gauge dyndns_last_detected_ip{provider,record}, counter dyndns_updates_total|V13 +T17|.|registry manifests: workflow-plugin-namecheap + workflow-plugin-hover added to workflow-registry|workflow#714 +T18|.|scenarios: 70-iac-namecheap-dns + 71-iac-hover-dns + 72-iac-dyndns-multiprovider|C1,C3,C9 +T19|.|docs: docs/wfctl-secrets-scopes.md w/ examples; docs/iac-dns-providers.md w/ matrix|C5,C8 +T20|.|integration test matrix: GH stub server validates org/env/repo PUT paths; secrets set roundtrip|T1,T2 +``` + +## §B — Bugs + +``` +id|date|cause|fix +``` + +(empty) From 43d3681a6ebfbb8c8d5bf04e2e382983ea3915cd Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 07:17:53 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat(secrets):=20scoped=20GitHub=20secrets?= =?UTF-8?q?=20=E2=80=94=20repo|env|org=20+=20wfctl=20--scope=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements T1+T2 of docs/plans/2026-05-20-dns-providers.md. secrets/github_provider.go: - New GitHubSecretScope enum (repo | env | org). - New GitHubOrgVisibility enum (all | selected | private). - NewGitHubOrgSecretsProvider mints an org-scoped provider with required-field validation (selected requires repoIDs; visibility set must be canonical). - secretsURL() switches on scope (/orgs/{org}, /repos/.../environments, or repo default). - Set() includes visibility + selected_repository_ids in PUT payload when scope=org. Repo + env scopes keep existing shape. - SetEnvironment("foo") flips scope to env; SetEnvironment("") reverts to repo. - Public Scope() reporter. cmd/wfctl/secrets_detect.go: - secrets set --scope repo|env|org - env: --env required; loads repo from app.yaml. - org: --org required; --visibility flag (default all); bypasses app.yaml. - --token-env for non-default GH PAT env var. 5 new tests: OrgScopeURL, OrgScope_Selected_RequiresRepoIDs, OrgScope_PrivateVisibility, RepoScope_NoVisibility, ScopeReporter. Existing wfctl + secrets suites green. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/wfctl/secrets_detect.go | 95 ++++++++++++++++++++++++- secrets/github_provider.go | 110 +++++++++++++++++++++++++---- secrets/github_provider_test.go | 121 ++++++++++++++++++++++++++++++++ 3 files changed, 312 insertions(+), 14 deletions(-) diff --git a/cmd/wfctl/secrets_detect.go b/cmd/wfctl/secrets_detect.go index e8c68492..dfdb97cf 100644 --- a/cmd/wfctl/secrets_detect.go +++ b/cmd/wfctl/secrets_detect.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "regexp" "strings" "github.com/GoCodeAlone/workflow/config" @@ -151,8 +152,25 @@ func runSecretsSetWithReader(args []string, r io.Reader) error { fromFile := fs.String("from-file", "", "Read secret value from file (for certs/keys)") providerName := fs.String("provider", "", "Ad-hoc provider override (keychain|env|aws); bypasses app.yaml") service := fs.String("service", "", "Service name for keychain provider") + scope := fs.String("scope", "", "GitHub secret scope: repo (default) | env | org") + envName := fs.String("env", "", "GitHub Actions environment name (required with --scope=env)") + org := fs.String("org", "", "GitHub org name (required with --scope=org)") + orgVisibility := fs.String("visibility", "all", "Org-scope visibility: all | selected | private") + tokenEnv := fs.String("token-env", "GITHUB_TOKEN", "Env var holding the GitHub PAT") fs.Usage = func() { - fmt.Fprintf(fs.Output(), "Usage: wfctl secrets set [options]\n\nSet a secret value in the provider.\n\nOptions:\n") + fmt.Fprintf(fs.Output(), `Usage: wfctl secrets set [options] + +Set a secret value in the configured provider. + +Scope flags (GitHub only): + --scope repo Default. Writes to the configured app.yaml repo provider. + --scope env --env + Writes to the repo-environment of the same repo. + --scope org --org [--visibility all|selected|private] [--token-env ] + Writes an org-level secret. Requires admin:org token scope. + +Options: +`) fs.PrintDefaults() } if err := fs.Parse(args); err != nil { @@ -210,6 +228,50 @@ func runSecretsSetWithReader(args []string, r io.Reader) error { return nil } + // Org-scope: build an org GH provider directly. Bypasses app.yaml + // since org secrets are out-of-band of the repo-scoped config. + if *scope == "org" { + if *org == "" { + return fmt.Errorf("--scope=org requires --org ") + } + vis, err := parseGitHubOrgVisibility(*orgVisibility) + if err != nil { + return err + } + p, err := secrets.NewGitHubOrgSecretsProvider(*org, *tokenEnv, vis, nil) + if err != nil { + return err + } + if err := p.Set(context.Background(), name, secretValue); err != nil { + return fmt.Errorf("set org secret %s: %w", name, err) + } + fmt.Printf("set %s (org=%s, visibility=%s)\n", name, *org, *orgVisibility) + return nil + } + + // Env-scope: build a repo-scoped GH provider, then flip into env + // mode. Requires the repo to be derived from --config app.yaml's + // secret block (provider=github + config.repo). + if *scope == "env" { + if *envName == "" { + return fmt.Errorf("--scope=env requires --env ") + } + repo, err := readGitHubRepoFromAppYAML(*configFile) + if err != nil { + return err + } + p, err := secrets.NewGitHubSecretsProvider(repo, *tokenEnv) + if err != nil { + return err + } + p.SetEnvironment(*envName) + if err := p.Set(context.Background(), name, secretValue); err != nil { + return fmt.Errorf("set env secret %s: %w", name, err) + } + fmt.Printf("set %s (env=%s)\n", name, *envName) + return nil + } + // Default path: load provider from app.yaml secrets block. cfg, err := loadSecretsConfig(*configFile) if err != nil { @@ -226,6 +288,37 @@ func runSecretsSetWithReader(args []string, r io.Reader) error { return nil } +// readGitHubRepoFromAppYAML loads app.yaml and returns the configured +// github repo from secrets.config.repo (or secrets.secretStores..config.repo). +func readGitHubRepoFromAppYAML(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read %s: %w", path, err) + } + // Lightweight regexp scan — avoids full YAML round-trip and tolerates + // either `secrets.config.repo` or `secretStores..config.repo`. + re := regexp.MustCompile(`(?m)^\s*repo:\s*([^\s#]+)`) + m := re.FindStringSubmatch(string(data)) + if len(m) < 2 { + return "", fmt.Errorf("could not find `repo:` in %s (expected secrets.config.repo or secretStores..config.repo)", path) + } + return strings.Trim(m[1], `"'`), nil +} + +// parseGitHubOrgVisibility canonicalises the --visibility flag. +func parseGitHubOrgVisibility(s string) (secrets.GitHubOrgVisibility, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "all": + return secrets.OrgVisibilityAll, nil + case "selected": + return secrets.OrgVisibilitySelected, nil + case "private": + return secrets.OrgVisibilityPrivate, nil + default: + return "", fmt.Errorf("invalid visibility %q (must be all|selected|private)", s) + } +} + func stdinFileDescriptor() (int, error) { fd := os.Stdin.Fd() maxInt := int(^uint(0) >> 1) diff --git a/secrets/github_provider.go b/secrets/github_provider.go index a62ceb93..bdd0ddff 100644 --- a/secrets/github_provider.go +++ b/secrets/github_provider.go @@ -19,18 +19,48 @@ import ( const githubAPIBase = "https://api.github.com" -// GitHubSecretsProvider manages GitHub Actions repository secrets. -// Secrets are write-only on GitHub, so Get() returns ErrUnsupported. +// GitHubSecretScope selects which GitHub secret namespace a provider +// writes to. Default zero value = repo (backwards-compat). +// +// GitHubScopeRepo → /repos/{owner}/{repo}/actions/secrets/... +// GitHubScopeEnv → /repos/{owner}/{repo}/environments/{env}/secrets/... +// GitHubScopeOrg → /orgs/{org}/actions/secrets/... +type GitHubSecretScope string + +const ( + GitHubScopeRepo GitHubSecretScope = "repo" + GitHubScopeEnv GitHubSecretScope = "env" + GitHubScopeOrg GitHubSecretScope = "org" +) + +// GitHubOrgVisibility controls who can pull an org-scoped secret. Mirrors +// GitHub's API field; one of "all", "selected", "private". +type GitHubOrgVisibility string + +const ( + OrgVisibilityAll GitHubOrgVisibility = "all" + OrgVisibilitySelected GitHubOrgVisibility = "selected" + OrgVisibilityPrivate GitHubOrgVisibility = "private" +) + +// GitHubSecretsProvider manages GitHub Actions secrets at repo, env, or +// org scope. Secrets are write-only on GitHub, so Get() returns +// ErrUnsupported. type GitHubSecretsProvider struct { - owner string - repo string - env string - token string - client *http.Client + scope GitHubSecretScope + owner string // for repo/env scope + repo string // for repo/env scope + env string // for env scope + org string // for org scope + orgVisibility GitHubOrgVisibility + selectedRepoIDs []int64 // required iff scope=org && visibility=selected + token string + client *http.Client } -// NewGitHubSecretsProvider creates a provider for the given "owner/repo". -// tokenEnvVar is the name of the environment variable holding the GitHub token. +// NewGitHubSecretsProvider creates a repo-scoped provider for the given +// "owner/repo". tokenEnvVar is the name of the environment variable +// holding the GitHub token. Backwards-compatible — sets scope=repo. func NewGitHubSecretsProvider(repo string, tokenEnvVar string) (*GitHubSecretsProvider, error) { parts := strings.SplitN(repo, "/", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { @@ -41,6 +71,7 @@ func NewGitHubSecretsProvider(repo string, tokenEnvVar string) (*GitHubSecretsPr return nil, fmt.Errorf("secrets: env var %q is empty or unset", tokenEnvVar) } return &GitHubSecretsProvider{ + scope: GitHubScopeRepo, owner: parts[0], repo: parts[1], token: token, @@ -48,12 +79,55 @@ func NewGitHubSecretsProvider(repo string, tokenEnvVar string) (*GitHubSecretsPr }, nil } +// NewGitHubOrgSecretsProvider creates an org-scoped provider. visibility +// is one of OrgVisibilityAll / Selected / Private. selectedRepoIDs is +// required iff visibility=Selected. +// +// Requires the token to have admin:org scope. +func NewGitHubOrgSecretsProvider(org string, tokenEnvVar string, visibility GitHubOrgVisibility, selectedRepoIDs []int64) (*GitHubSecretsProvider, error) { + if org == "" { + return nil, fmt.Errorf("secrets: github org name is required") + } + token := os.Getenv(tokenEnvVar) + if token == "" { + return nil, fmt.Errorf("secrets: env var %q is empty or unset", tokenEnvVar) + } + if visibility == "" { + visibility = OrgVisibilityAll + } + switch visibility { + case OrgVisibilityAll, OrgVisibilitySelected, OrgVisibilityPrivate: + default: + return nil, fmt.Errorf("secrets: github org visibility must be all|selected|private, got %q", visibility) + } + if visibility == OrgVisibilitySelected && len(selectedRepoIDs) == 0 { + return nil, fmt.Errorf("secrets: github org visibility=selected requires selected_repository_ids") + } + return &GitHubSecretsProvider{ + scope: GitHubScopeOrg, + org: org, + orgVisibility: visibility, + selectedRepoIDs: append([]int64(nil), selectedRepoIDs...), + token: token, + client: &http.Client{}, + }, nil +} + +// Scope reports the current scope. +func (p *GitHubSecretsProvider) Scope() GitHubSecretScope { return p.scope } + func (p *GitHubSecretsProvider) Name() string { return "github" } // SetEnvironment scopes subsequent operations to a GitHub Actions environment. -// Empty scope means repository-level secrets. +// Empty scope means repository-level secrets. Calling SetEnvironment with a +// non-empty value flips scope to env. func (p *GitHubSecretsProvider) SetEnvironment(environment string) { p.env = strings.TrimSpace(environment) + if p.env != "" { + p.scope = GitHubScopeEnv + } else if p.scope == GitHubScopeEnv { + p.scope = GitHubScopeRepo + } } // Environment returns the configured GitHub Actions environment scope. @@ -80,10 +154,16 @@ func (p *GitHubSecretsProvider) Set(ctx context.Context, key, value string) erro return fmt.Errorf("secrets: github encrypt: %w", err) } - payload := map[string]string{ + payload := map[string]any{ "encrypted_value": encrypted, "key_id": pubKeyID, } + if p.scope == GitHubScopeOrg { + payload["visibility"] = string(p.orgVisibility) + if p.orgVisibility == OrgVisibilitySelected { + payload["selected_repository_ids"] = p.selectedRepoIDs + } + } body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, http.MethodPut, p.secretURL(key), bytes.NewReader(body)) if err != nil { @@ -176,10 +256,14 @@ func (p *GitHubSecretsProvider) setHeaders(req *http.Request) { } func (p *GitHubSecretsProvider) secretsURL() string { - if p.env != "" { + switch p.scope { + case GitHubScopeOrg: + return fmt.Sprintf("%s/orgs/%s/actions/secrets", githubAPIBase, p.org) + case GitHubScopeEnv: return fmt.Sprintf("%s/repos/%s/%s/environments/%s/secrets", githubAPIBase, p.owner, p.repo, url.PathEscape(p.env)) + default: // GitHubScopeRepo + return fmt.Sprintf("%s/repos/%s/%s/actions/secrets", githubAPIBase, p.owner, p.repo) } - return fmt.Sprintf("%s/repos/%s/%s/actions/secrets", githubAPIBase, p.owner, p.repo) } func (p *GitHubSecretsProvider) secretURL(key string) string { diff --git a/secrets/github_provider_test.go b/secrets/github_provider_test.go index 09a7bcc3..ff547366 100644 --- a/secrets/github_provider_test.go +++ b/secrets/github_provider_test.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/base64" "encoding/json" + "io" "net/http" "net/http/httptest" "strings" @@ -414,3 +415,123 @@ func TestBlake2bNonceLengthMatters(t *testing.T) { "reject secrets with 'improperly encrypted secret'") } } + +// TestGitHubProvider_OrgScopeURL asserts org-scoped requests route to +// /orgs/{org}/actions/secrets and that PUT payload includes visibility. +func TestGitHubProvider_OrgScopeURL(t *testing.T) { + var seenPath string + var seenPayload map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + switch { + case strings.HasSuffix(r.URL.Path, "/public-key"): + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{ + "key_id": "kid", + "key": "C2cZi4nfu9ND7+iRGz9Z+Zf2cZ6OAd1d2c2DqEbtv0M=", + }) + case r.Method == http.MethodPut: + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &seenPayload) + w.WriteHeader(http.StatusCreated) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + t.Setenv("GITHUB_TOKEN", "test-token") + p, err := NewGitHubOrgSecretsProvider("my-org", "GITHUB_TOKEN", OrgVisibilityAll, nil) + if err != nil { + t.Fatalf("NewGitHubOrgSecretsProvider: %v", err) + } + p.client = &http.Client{Transport: rewriteTransport{base: srv.URL}} + + if err := p.Set(context.Background(), "MY_SECRET", "value"); err != nil { + t.Fatalf("Set: %v", err) + } + if !strings.HasPrefix(seenPath, "/orgs/my-org/actions/secrets/") { + t.Errorf("PUT path = %q; want /orgs/my-org/actions/secrets/...", seenPath) + } + if vis, _ := seenPayload["visibility"].(string); vis != "all" { + t.Errorf("payload visibility = %q; want all", vis) + } +} + +func TestGitHubProvider_OrgScope_Selected_RequiresRepoIDs(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "x") + _, err := NewGitHubOrgSecretsProvider("my-org", "GITHUB_TOKEN", OrgVisibilitySelected, nil) + if err == nil { + t.Fatal("expected error when visibility=selected and no repo IDs") + } + if !strings.Contains(err.Error(), "selected_repository_ids") { + t.Errorf("wrong error: %v", err) + } +} + +func TestGitHubProvider_OrgScope_PrivateVisibility(t *testing.T) { + var payload map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/public-key") { + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{ + "key_id": "kid", "key": "C2cZi4nfu9ND7+iRGz9Z+Zf2cZ6OAd1d2c2DqEbtv0M=", + }) + return + } + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &payload) + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + t.Setenv("GITHUB_TOKEN", "x") + p, _ := NewGitHubOrgSecretsProvider("o", "GITHUB_TOKEN", OrgVisibilityPrivate, nil) + p.client = &http.Client{Transport: rewriteTransport{base: srv.URL}} + _ = p.Set(context.Background(), "K", "v") + if payload["visibility"] != "private" { + t.Errorf("visibility = %v want private", payload["visibility"]) + } +} + +func TestGitHubProvider_RepoScope_NoVisibility(t *testing.T) { + // Repo scope must NOT include visibility in payload (org-only field). + var payload map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/public-key") { + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{ + "key_id": "kid", "key": "C2cZi4nfu9ND7+iRGz9Z+Zf2cZ6OAd1d2c2DqEbtv0M=", + }) + return + } + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &payload) + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + p := newTestGitHubProvider(t, srv) + _ = p.Set(context.Background(), "K", "v") + if _, hasVis := payload["visibility"]; hasVis { + t.Errorf("repo-scope PUT should not include visibility; got payload=%v", payload) + } +} + +func TestGitHubProvider_ScopeReporter(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "x") + p, _ := NewGitHubSecretsProvider("o/r", "GITHUB_TOKEN") + if p.Scope() != GitHubScopeRepo { + t.Errorf("repo: scope = %q", p.Scope()) + } + p.SetEnvironment("staging") + if p.Scope() != GitHubScopeEnv { + t.Errorf("env: scope = %q", p.Scope()) + } + p.SetEnvironment("") + if p.Scope() != GitHubScopeRepo { + t.Errorf("env-clear: scope = %q", p.Scope()) + } + op, _ := NewGitHubOrgSecretsProvider("o", "GITHUB_TOKEN", OrgVisibilityAll, nil) + if op.Scope() != GitHubScopeOrg { + t.Errorf("org: scope = %q", op.Scope()) + } +}