diff --git a/src/apply.rs b/src/apply.rs index da006fd..8d460cb 100644 --- a/src/apply.rs +++ b/src/apply.rs @@ -515,19 +515,22 @@ fn apply_team_access_step( } /// Step 6 — secrets from the template (detected from the repo's marker -/// files), skipping the ones the repo already has. +/// files), skipping the ones the repo already has or its organization +/// provides. fn apply_missing_secrets_step(target: &ApplyTarget) -> Result<()> { let secret_specs = detect_template_secrets(&target.client, &target.owner, &target.repo_name); if secret_specs.is_empty() { return Ok(()); } - let existing = secrets::list_secret_names(&target.client, &target.owner, &target.repo_name) + let mut present = secrets::list_secret_names(&target.client, &target.owner, &target.repo_name) .unwrap_or_default(); - let missing: Vec<_> = secret_specs - .iter() - .filter(|s| !existing.iter().any(|e| e == &s.name)) - .collect(); + present.extend(secrets::list_org_secret_names( + &target.client, + &target.owner, + &target.repo_name, + )); + let missing = crate::templates::secrets_to_configure(&secret_specs, &present); if missing.is_empty() { return Ok(()); } diff --git a/src/github/secrets.rs b/src/github/secrets.rs index e99a1f6..61c5fe0 100644 --- a/src/github/secrets.rs +++ b/src/github/secrets.rs @@ -68,6 +68,17 @@ pub fn list_secret_names(client: &GithubClient, owner: &str, repo: &str) -> Resu Ok(resp.secrets.into_iter().map(|s| s.name).collect()) } +/// Names of the organization secrets the repo can read. A repo owned by a +/// user has no organization (the API answers 422); that, like any failure, +/// reads as none, so the caller falls back to asking for the value. +pub fn list_org_secret_names(client: &GithubClient, owner: &str, repo: &str) -> Vec { + let path = format!("/repos/{owner}/{repo}/actions/organization-secrets?per_page=100"); + client + .get::(&path) + .map(|resp| resp.secrets.into_iter().map(|s| s.name).collect()) + .unwrap_or_default() +} + /// Encrypts `value` with the repo's public key and stores it as a secret. pub fn set_secret( client: &GithubClient, @@ -123,6 +134,14 @@ mod tests { assert_eq!(resp.secrets[1].name, "SECRET2"); } + #[test] + fn test_org_secrets_list_response_deserialize() { + let json = r#"{"total_count":1,"secrets":[{"name":"CARGO_REGISTRY_TOKEN","created_at":"2026-10-06T00:47:23Z","updated_at":"2026-10-06T00:47:23Z","visibility":"all"}]}"#; + let resp: SecretsListResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.secrets.len(), 1); + assert_eq!(resp.secrets[0].name, "CARGO_REGISTRY_TOKEN"); + } + #[test] fn test_secrets_list_response_empty() { let json = r#"{"secrets":[]}"#; diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 53fa3a7..90ce2c0 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -536,6 +536,19 @@ pub struct SecretSpec { pub required: bool, } +/// The template secrets that still need a value: those neither on the repo +/// already nor provided to it by its organization. A repo-level copy of an +/// organization secret would shadow the shared one, so ghscaff never writes it. +pub fn secrets_to_configure<'a>( + specs: &'a [SecretSpec], + present: &[String], +) -> Vec<&'a SecretSpec> { + specs + .iter() + .filter(|s| !present.iter().any(|p| p == &s.name)) + .collect() +} + fn default_true() -> bool { true } @@ -567,6 +580,36 @@ pub fn load_secrets(language: &str) -> Vec { mod tests { use super::*; use crate::templates::rust::RustTemplate; + + fn spec(name: &str) -> SecretSpec { + SecretSpec { + name: name.to_string(), + description: String::new(), + required: true, + } + } + + #[test] + fn secrets_to_configure_skips_org_provided_and_existing() { + let specs = [ + spec("CARGO_REGISTRY_TOKEN"), + spec("PYPI_API_TOKEN"), + spec("DEPLOY_KEY"), + ]; + let present = ["CARGO_REGISTRY_TOKEN".to_string(), "DEPLOY_KEY".to_string()]; + let names: Vec<_> = secrets_to_configure(&specs, &present) + .iter() + .map(|s| s.name.as_str()) + .collect(); + assert_eq!(names, ["PYPI_API_TOKEN"]); + } + + #[test] + fn secrets_to_configure_asks_for_everything_without_an_org() { + let specs = [spec("CARGO_REGISTRY_TOKEN")]; + assert_eq!(secrets_to_configure(&specs, &[]).len(), 1); + } + #[test] fn test_available_languages() { assert!(!AVAILABLE.is_empty(), "Should have at least one language"); diff --git a/src/wizard/execution.rs b/src/wizard/execution.rs index 46d0d59..47b49c1 100644 --- a/src/wizard/execution.rs +++ b/src/wizard/execution.rs @@ -333,7 +333,8 @@ fn grant_wizard_team_access_step( Ok(()) } -/// Step 9 — template secrets: env → vault → prompt. +/// Step 9 — template secrets: organization → vault → prompt. A secret the +/// organization already provides is never copied onto the repo. fn configure_wizard_secrets_step( progress: &mut StepCounter, client: &GithubClient, @@ -341,7 +342,14 @@ fn configure_wizard_secrets_step( passphrase: &str, secret_specs: &[templates::SecretSpec], ) -> Result<()> { + let org_secrets = secrets::list_org_secret_names(client, &c.owner, &c.name); + let to_configure = templates::secrets_to_configure(secret_specs, &org_secrets); for spec in secret_specs { + if !to_configure.iter().any(|s| s.name == spec.name) { + println!(" ◆ Secret {}: provided by the organization", spec.name); + progress.skip(); + continue; + } let value = if let Some(val) = crate::vault::resolve_secret(&spec.name, passphrase)? { println!(" ◆ Secret {}: found", spec.name); Some(val)