Skip to content
Merged
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
15 changes: 9 additions & 6 deletions src/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
}
Expand Down
19 changes: 19 additions & 0 deletions src/github/secrets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
let path = format!("/repos/{owner}/{repo}/actions/organization-secrets?per_page=100");
client
.get::<SecretsListResponse>(&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,
Expand Down Expand Up @@ -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":[]}"#;
Expand Down
43 changes: 43 additions & 0 deletions src/templates/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -567,6 +580,36 @@ pub fn load_secrets(language: &str) -> Vec<SecretSpec> {
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");
Expand Down
10 changes: 9 additions & 1 deletion src/wizard/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,15 +333,23 @@ 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,
c: &WizardConfig,
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)
Expand Down
Loading