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
5 changes: 5 additions & 0 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ slow-timeout = { period = "30s", terminate-after = 1 }
fail-fast = false
retries = 2

# reqwest/rustls spawns background TLS threads on crate load; allow the leak.
[[profile.default.overrides]]
filter = 'package(auths-infra-http)'
leak-timeout = "500ms"

# Integration tests: serial execution to avoid global state races.
# Matches any test binary built from tests/ dirs.
[[profile.default.overrides]]
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,7 @@ my-artifact.txt.auths.json
# Stale E2E test artifacts (nested git repos created by test runs)
tests/e2e/.auths-ci/
.capsec-cache

# jnkn
.jnkn/
jnkn.db
14 changes: 14 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,27 @@ repos:
types: [rust]
pass_filenames: false

- id: cargo-fmt-packages
name: cargo fmt (packages/)
entry: bash -c 'for d in packages/auths-node packages/auths-python packages/auths-verifier-swift; do [ -f "$d/Cargo.toml" ] && cargo fmt --manifest-path "$d/Cargo.toml" --all; done'
language: system
types: [rust]
pass_filenames: false

- id: cargo-clippy
name: cargo clippy
entry: cargo clippy --all-targets --all-features -- -D warnings
language: system
types: [rust]
pass_filenames: false

- id: cargo-clippy-packages
name: cargo clippy (packages/)
entry: bash -c 'for d in packages/auths-node packages/auths-python packages/auths-verifier-swift; do [ -f "$d/Cargo.toml" ] && CARGO_TARGET_DIR=../../target cargo clippy --manifest-path "$d/Cargo.toml" --all-targets -- -D warnings || exit 1; done'
language: system
types: [rust]
pass_filenames: false

# - id: gen-docs
# name: cargo xtask gen-docs (auto-fix)
# entry: bash -c 'cargo run --package xtask -- gen-docs && git add docs/cli/commands/'
Expand Down
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/auths-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ git2.workspace = true
dirs = "6.0.0"
chrono = "0.4.40"
jsonschema = { version = "0.45.0", default-features = false }
rand = "0.9"
rpassword = "7.3.1"
log = "0.4.27"
serde = { version = "1.0", features = ["derive"] }
Expand Down
15 changes: 4 additions & 11 deletions crates/auths-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use crate::commands::approval::ApprovalCommand;
use crate::commands::artifact::ArtifactCommand;
use crate::commands::audit::AuditCommand;
use crate::commands::auth::AuthCommand;
use crate::commands::ci::CiCommand;
use crate::commands::commit::CommitCmd;
use crate::commands::completions::CompletionsCommand;
use crate::commands::config::ConfigCommand;
Expand Down Expand Up @@ -37,7 +38,6 @@ use crate::commands::trust::TrustCommand;
use crate::commands::unified_verify::UnifiedVerifyCommand;
use crate::commands::whoami::WhoamiCommand;
use crate::commands::witness::WitnessCommand;
use crate::config::OutputFormat;

fn cli_styles() -> Styles {
Styles::styled()
Expand Down Expand Up @@ -65,16 +65,6 @@ pub struct AuthsCli {
#[clap(long, help = "Show all commands including advanced ones")]
pub help_all: bool,

#[clap(
long,
value_enum,
default_value = "text",
global = true,
hide = true,
help = "Output format (text or json)"
)]
pub format: OutputFormat,

#[clap(short = 'j', long, global = true, help = "Emit machine-readable JSON")]
pub json: bool,

Expand Down Expand Up @@ -111,6 +101,9 @@ pub enum RootCommand {
Config(ConfigCommand),
Completions(CompletionsCommand),

// ── CI/CD ──
Ci(CiCommand),

// ── Advanced (visible via --help-all) ──
#[command(hide = true)]
Reset(ResetCommand),
Expand Down
140 changes: 140 additions & 0 deletions crates/auths-cli/src/commands/ci/forge_backend.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//! Forge backend implementations for setting CI secrets.

use anyhow::{Context, Result, anyhow};
use auths_sdk::domains::ci::forge::Forge;
use std::io::Write;
use std::process::{Command, Stdio};

/// Abstraction over forge-specific secret-setting operations.
///
/// Usage:
/// ```ignore
/// let backend = backend_for_forge(&forge);
/// backend.set_secret("AUTHS_CI_TOKEN", &token_json)?;
/// backend.print_ci_template();
/// ```
pub trait ForgeBackend {
/// Set a CI secret/variable on the forge.
fn set_secret(&self, name: &str, value: &str) -> Result<()>;

/// Human-readable forge name.
fn name(&self) -> &str;

/// Print CI workflow template for this forge.
fn print_ci_template(&self);
}

/// GitHub backend — sets secrets via `gh secret set`.
pub struct GitHubBackend {
pub owner_repo: String,
}

impl ForgeBackend for GitHubBackend {
fn set_secret(&self, name: &str, value: &str) -> Result<()> {
// Check gh is available and authenticated (strip GH_TOKEN to avoid stale tokens)
let auth_status = Command::new("gh")
.args(["auth", "status"])
.env_remove("GH_TOKEN")
.env_remove("GITHUB_TOKEN")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.context("gh CLI not found — install it from https://cli.github.com")?;

if !auth_status.success() {
return Err(anyhow!(
"gh CLI is not authenticated. Run `gh auth login` first."
));
}

let mut child = Command::new("gh")
.args(["secret", "set", name, "--repo", &self.owner_repo])
.env_remove("GH_TOKEN")
.env_remove("GITHUB_TOKEN")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn gh secret set")?;

if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(value.as_bytes())
.context("Failed to write secret to gh stdin")?;
}

let output = child
.wait_with_output()
.context("Failed to wait for gh secret set")?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("gh secret set failed: {}", stderr.trim()));
}

Ok(())
}

fn name(&self) -> &str {
"GitHub"
}

fn print_ci_template(&self) {
println!("Add to your release workflow:");
println!();
println!(" - uses: auths-dev/attest-action@v1");
println!(" with:");
println!(" token: ${{{{ secrets.AUTHS_CI_TOKEN }}}}");
println!(" files: 'dist/*.tar.gz'");
}
}

/// Fallback backend for unsupported forges — prints values for manual setup.
pub struct ManualBackend {
pub forge_name: String,
}

impl ForgeBackend for ManualBackend {
fn set_secret(&self, _name: &str, _value: &str) -> Result<()> {
// No-op — values are printed by the caller on failure
Ok(())
}

fn name(&self) -> &str {
&self.forge_name
}

fn print_ci_template(&self) {
println!("Set AUTHS_CI_TOKEN as a masked CI variable in your forge's settings.");
println!("See https://docs.auths.dev/ci for forge-specific instructions.");
}
}

/// Create the appropriate backend for a detected forge.
///
/// Args:
/// * `forge`: The detected forge variant.
///
/// Usage:
/// ```ignore
/// let backend = backend_for_forge(&forge);
/// ```
pub fn backend_for_forge(forge: &Forge) -> Box<dyn ForgeBackend> {
match forge {
Forge::GitHub { owner_repo } => Box::new(GitHubBackend {
owner_repo: owner_repo.clone(),
}),
Forge::GitLab { .. } => Box::new(ManualBackend {
forge_name: "GitLab".into(),
}),
Forge::Bitbucket { .. } => Box::new(ManualBackend {
forge_name: "Bitbucket".into(),
}),
Forge::Radicle { .. } => Box::new(ManualBackend {
forge_name: "Radicle".into(),
}),
Forge::Unknown { .. } => Box::new(ManualBackend {
forge_name: "Unknown".into(),
}),
}
}
105 changes: 105 additions & 0 deletions crates/auths-cli/src/commands/ci/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//! CI/CD integration commands — setup and rotate CI signing secrets.

pub mod forge_backend;
pub mod rotate;
pub mod setup;

use anyhow::Result;
use clap::{Args, Subcommand};
use std::sync::Arc;

use auths_core::signing::PassphraseProvider;
use auths_id::storage::layout;

use crate::commands::executable::ExecutableCommand;
use crate::config::CliConfig;

/// CI/CD integration (setup, rotate secrets).
#[derive(Args, Debug, Clone)]
#[command(
about = "CI/CD integration — set up and rotate CI signing secrets.",
after_help = "Examples:
auths ci setup # Auto-detect forge, set AUTHS_CI_TOKEN
auths ci setup --repo owner/repo
# Specify target repo
auths ci rotate # Refresh token, reuse device key
auths ci rotate --max-age-secs 7776000
# Rotate with 90-day TTL

Related:
auths device — Manage device authorizations
auths key — Manage cryptographic keys
auths init — Set up identity"
)]
pub struct CiCommand {
#[command(subcommand)]
pub command: CiSubcommand,
}

#[derive(Subcommand, Debug, Clone)]
pub enum CiSubcommand {
/// Set up CI secrets for release artifact signing and verification.
Setup {
/// Target repo. Accepts `owner/repo`, HTTPS URL, or SSH URL.
/// Defaults to git remote origin.
#[arg(long)]
repo: Option<String>,

/// Max age for the verification bundle in seconds (default: 1 year).
#[arg(long, default_value = "31536000")]
max_age_secs: u64,

/// Disable auto-generated passphrase and prompt interactively instead.
#[arg(long)]
manual_passphrase: bool,
},

/// Rotate an existing CI token (regenerate bundle, reuse device key).
Rotate {
/// Target repo override.
#[arg(long)]
repo: Option<String>,

/// Max age for the verification bundle in seconds (default: 1 year).
#[arg(long, default_value = "31536000")]
max_age_secs: u64,

/// Disable auto-generated passphrase and prompt interactively instead.
#[arg(long)]
manual_passphrase: bool,
},
}

impl ExecutableCommand for CiCommand {
fn execute(&self, ctx: &CliConfig) -> Result<()> {
let repo_path = layout::resolve_repo_path(ctx.repo_path.clone())?;
let pp: Arc<dyn PassphraseProvider + Send + Sync> = Arc::clone(&ctx.passphrase_provider);

match &self.command {
CiSubcommand::Setup {
repo,
max_age_secs,
manual_passphrase,
} => setup::run_setup(
repo.clone(),
*max_age_secs,
!manual_passphrase,
pp,
&ctx.env_config,
&repo_path,
),
CiSubcommand::Rotate {
repo,
max_age_secs,
manual_passphrase,
} => rotate::run_rotate(
repo.clone(),
*max_age_secs,
!manual_passphrase,
pp,
&ctx.env_config,
&repo_path,
),
}
}
}
Loading
Loading