From 90c8c4fd8066f4a9f0bab76591f05b0658f85869 Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 31 Mar 2026 19:42:30 -0700 Subject: [PATCH] feat: automate artifact release attestation --- Cargo.lock | 7 + Cargo.toml | 1 + crates/auths-cli/Cargo.toml | 1 + .../src/commands/artifact/batch_sign.rs | 141 +++++++++ crates/auths-cli/src/commands/artifact/mod.rs | 58 ++++ crates/auths-cli/src/commands/init/gather.rs | 9 +- crates/auths-cli/src/commands/init/helpers.rs | 139 +++++++++ crates/auths-cli/src/commands/init/mod.rs | 14 + crates/auths-cli/tests/cases/init.rs | 72 +++++ .../auths-sdk/src/domains/ci/environment.rs | 56 ++++ crates/auths-sdk/src/domains/ci/error.rs | 75 +++++ crates/auths-sdk/src/domains/ci/mod.rs | 9 + crates/auths-sdk/src/domains/ci/types.rs | 48 +++ .../auths-sdk/src/domains/identity/service.rs | 7 +- .../auths-sdk/src/domains/identity/types.rs | 47 +-- crates/auths-sdk/src/domains/mod.rs | 1 + crates/auths-sdk/src/types.rs | 7 +- .../src/workflows/ci/batch_attest.rs | 281 ++++++++++++++++++ .../workflows/{ => ci}/machine_identity.rs | 0 crates/auths-sdk/src/workflows/ci/mod.rs | 6 + crates/auths-sdk/src/workflows/mod.rs | 4 +- crates/auths-sdk/tests/cases/ci_setup.rs | 3 +- .../tests/sign_commit_attestation.rs | 2 +- 23 files changed, 925 insertions(+), 63 deletions(-) create mode 100644 crates/auths-cli/src/commands/artifact/batch_sign.rs create mode 100644 crates/auths-sdk/src/domains/ci/environment.rs create mode 100644 crates/auths-sdk/src/domains/ci/error.rs create mode 100644 crates/auths-sdk/src/domains/ci/mod.rs create mode 100644 crates/auths-sdk/src/domains/ci/types.rs create mode 100644 crates/auths-sdk/src/workflows/ci/batch_attest.rs rename crates/auths-sdk/src/workflows/{ => ci}/machine_identity.rs (100%) create mode 100644 crates/auths-sdk/src/workflows/ci/mod.rs diff --git a/Cargo.lock b/Cargo.lock index dd479754..5dba22d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,6 +369,7 @@ dependencies = [ "env_logger", "gethostname", "git2", + "glob", "hex", "indicatif", "insta", @@ -3084,6 +3085,12 @@ dependencies = [ "url", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "group" version = "0.12.1" diff --git a/Cargo.toml b/Cargo.toml index 237cbc05..94c4657d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ base64 = "0.22.1" thiserror = "2" uuid = { version = "1", features = ["v4"] } git2 = { version = "0.20.4", default-features = false, features = ["vendored-libgit2"] } +glob = "0.3" parking_lot = "0.12" schemars = "0.8" subtle = "2.6" diff --git a/crates/auths-cli/Cargo.toml b/crates/auths-cli/Cargo.toml index 5401ed33..44d8f95d 100644 --- a/crates/auths-cli/Cargo.toml +++ b/crates/auths-cli/Cargo.toml @@ -32,6 +32,7 @@ dialoguer = "0.12.0" anyhow = "1" hex = "0.4.3" gethostname = "0.4" +glob.workspace = true auths-core = { workspace = true, features = ["witness-server"] } auths-id = { workspace = true, features = ["witness-client", "indexed-storage"] } auths-storage = { workspace = true, features = ["backend-git"] } diff --git a/crates/auths-cli/src/commands/artifact/batch_sign.rs b/crates/auths-cli/src/commands/artifact/batch_sign.rs new file mode 100644 index 00000000..69258d67 --- /dev/null +++ b/crates/auths-cli/src/commands/artifact/batch_sign.rs @@ -0,0 +1,141 @@ +//! Handler for `auths artifact batch-sign`. + +use anyhow::{Context, Result}; +use std::path::PathBuf; +use std::sync::Arc; + +use auths_core::config::EnvironmentConfig; +use auths_core::signing::PassphraseProvider; +use auths_sdk::workflows::ci::batch_attest::{ + BatchEntry, BatchEntryResult, BatchSignConfig, batch_sign_artifacts, default_attestation_path, +}; + +use super::file::FileArtifact; +use crate::factories::storage::build_auths_context; + +/// Execute the `artifact batch-sign` command. +/// +/// Args: +/// * `pattern`: Glob pattern matching artifact files. +/// * `device_key`: Device key alias for signing. +/// * `key`: Optional identity key alias. +/// * `attestation_dir`: Optional directory to collect attestation files. +/// * `expires_in`: Optional TTL in seconds. +/// * `note`: Optional note for attestations. +/// * `repo_opt`: Optional identity repo path. +/// * `passphrase_provider`: Passphrase provider for key decryption. +/// * `env_config`: Environment configuration. +/// +/// Usage: +/// ```ignore +/// handle_batch_sign("dist/*.tar.gz", "ci-device", None, Some(".auths/releases"), ...)?; +/// ``` +#[allow(clippy::too_many_arguments)] +pub fn handle_batch_sign( + pattern: &str, + device_key: &str, + key: Option<&str>, + attestation_dir: Option, + expires_in: Option, + note: Option, + repo_opt: Option, + passphrase_provider: Arc, + env_config: &EnvironmentConfig, +) -> Result<()> { + let repo_path = auths_id::storage::layout::resolve_repo_path(repo_opt)?; + let ctx = build_auths_context(&repo_path, env_config, Some(passphrase_provider))?; + + let paths = expand_glob(pattern)?; + if paths.is_empty() { + println!("No files match pattern: {}", pattern); + return Ok(()); + } + + let entries: Vec = paths + .iter() + .map(|p| BatchEntry { + source: Arc::new(FileArtifact::new(p)), + output_path: default_attestation_path(p), + }) + .collect(); + + println!("Signing {} artifact(s)...", entries.len()); + + let config = BatchSignConfig { + entries, + device_key: device_key.to_string(), + identity_key: key.map(|s| s.to_string()), + expires_in, + note, + }; + + let result = batch_sign_artifacts(config, &ctx) + .with_context(|| format!("Batch signing failed for pattern: {}", pattern))?; + + // Write attestation files and collect to directory (file I/O is CLI's job) + for entry in &result.results { + if let BatchEntryResult::Signed(s) = entry { + std::fs::write(&s.output_path, &s.attestation_json) + .with_context(|| format!("Failed to write {}", s.output_path.display()))?; + println!( + " Signed: {} (sha256:{})", + s.output_path.display(), + s.digest + ); + } + if let BatchEntryResult::Failed(f) = entry { + eprintln!(" FAILED: {}: {}", f.output_path.display(), f.error); + } + } + + if let Some(ref dir) = attestation_dir { + collect_to_dir(&result.results, dir)?; + println!("Collected attestations to: {}", dir.display()); + } + + println!( + "{} signed, {} failed", + result.signed_count(), + result.failed_count() + ); + + if result.failed_count() > 0 { + anyhow::bail!( + "{} of {} artifact(s) failed to sign", + result.failed_count(), + result.signed_count() + result.failed_count() + ); + } + + Ok(()) +} + +fn expand_glob(pattern: &str) -> Result> { + let paths: Vec = glob::glob(pattern) + .with_context(|| format!("Invalid glob pattern: {}", pattern))? + .filter_map(|entry| entry.ok()) + .filter(|p| p.is_file()) + .collect(); + Ok(paths) +} + +fn collect_to_dir(results: &[BatchEntryResult], dir: &std::path::Path) -> Result<()> { + std::fs::create_dir_all(dir) + .with_context(|| format!("Failed to create attestation directory: {}", dir.display()))?; + + for entry in results { + if let BatchEntryResult::Signed(s) = entry { + let filename = s + .output_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let dst = dir.join(&filename); + std::fs::write(&dst, &s.attestation_json) + .with_context(|| format!("Failed to write {}", dst.display()))?; + } + } + + Ok(()) +} diff --git a/crates/auths-cli/src/commands/artifact/mod.rs b/crates/auths-cli/src/commands/artifact/mod.rs index 6c9e8955..b2fcae22 100644 --- a/crates/auths-cli/src/commands/artifact/mod.rs +++ b/crates/auths-cli/src/commands/artifact/mod.rs @@ -1,3 +1,4 @@ +pub mod batch_sign; pub mod core; pub mod file; pub mod publish; @@ -111,6 +112,36 @@ pub enum ArtifactSubcommand { note: Option, }, + /// Sign multiple artifacts matching a glob pattern. + /// + /// Signs each file, writes `.auths.json` attestations, and optionally + /// collects them into a target directory. + BatchSign { + /// Glob pattern matching artifact files (e.g. "dist/*.tar.gz"). + #[arg(help = "Glob pattern matching artifact files to sign.")] + pattern: String, + + /// Local alias of the device key. + #[arg(long)] + device_key: Option, + + /// Local alias of the identity key. Omit for device-only CI signing. + #[arg(long)] + key: Option, + + /// Directory to collect attestation files into. + #[arg(long, value_name = "DIR")] + attestation_dir: Option, + + /// Duration in seconds until expiration. + #[arg(long = "expires-in", value_name = "N")] + expires_in: Option, + + /// Optional note to embed in each attestation. + #[arg(long)] + note: Option, + }, + /// Verify an artifact's signature against an Auths identity. Verify { /// Path to the artifact file to verify. @@ -218,6 +249,33 @@ pub fn handle_artifact( }; publish::handle_publish(&sig_path, package.as_deref(), ®istry) } + ArtifactSubcommand::BatchSign { + pattern, + device_key, + key, + attestation_dir, + expires_in, + note, + } => { + let resolved_alias = match device_key { + Some(alias) => alias, + None => crate::commands::key_detect::auto_detect_device_key( + repo_opt.as_deref(), + env_config, + )?, + }; + batch_sign::handle_batch_sign( + &pattern, + &resolved_alias, + key.as_deref(), + attestation_dir, + expires_in, + note, + repo_opt, + passphrase_provider, + env_config, + ) + } ArtifactSubcommand::Verify { file, signature, diff --git a/crates/auths-cli/src/commands/init/gather.rs b/crates/auths-cli/src/commands/init/gather.rs index bcb5659d..09971a81 100644 --- a/crates/auths-cli/src/commands/init/gather.rs +++ b/crates/auths-cli/src/commands/init/gather.rs @@ -236,12 +236,5 @@ pub(crate) fn check_keychain_access(out: &Output) -> Result) -> CiEnvironment { - match detected.as_deref() { - Some("GitHub Actions") => CiEnvironment::GitHubActions, - Some("GitLab CI") => CiEnvironment::GitLabCi, - Some(name) => CiEnvironment::Custom { - name: name.to_string(), - }, - None => CiEnvironment::Unknown, - } + auths_sdk::domains::ci::map_ci_environment(detected) } diff --git a/crates/auths-cli/src/commands/init/helpers.rs b/crates/auths-cli/src/commands/init/helpers.rs index d579f57d..76c1c6ef 100644 --- a/crates/auths-cli/src/commands/init/helpers.rs +++ b/crates/auths-cli/src/commands/init/helpers.rs @@ -148,6 +148,145 @@ fn set_git_config(key: &str, value: &str, scope: &str) -> Result<()> { Ok(()) } +// --- GitHub Action Scaffolding --- + +const GITHUB_ACTION_WORKFLOW_TEMPLATE: &str = r#"# Auths release workflow — signs artifacts and commits attestations to the repo. +# Generated by: auths init --github-action +# +# Required secrets (generate with `just ci-setup`): +# AUTHS_CI_PASSPHRASE — passphrase for the CI keychain +# AUTHS_CI_KEYCHAIN — base64-encoded keychain file +# AUTHS_CI_IDENTITY_BUNDLE — base64-encoded identity bundle + +name: Auths Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build artifacts + run: | + # Replace this with your build step + echo "Build your artifacts here" + + - name: Sign artifacts and commit attestations + uses: auths-dev/attest-action@v1 + env: + # These secrets must be set in your repository settings + AUTHS_PASSPHRASE: ${{ secrets.AUTHS_CI_PASSPHRASE }} + AUTHS_CI_KEYCHAIN_B64: ${{ secrets.AUTHS_CI_KEYCHAIN }} + AUTHS_CI_IDENTITY_BUNDLE_B64: ${{ secrets.AUTHS_CI_IDENTITY_BUNDLE }} + with: + # Glob pattern matching your release artifacts + artifacts: 'dist/*.tar.gz' + + # Directory in your repo where attestation files are committed + attestation-path: '.auths/releases' + + # "direct" pushes to the current branch; "pr" opens a pull request + commit-strategy: 'direct' +"#; + +/// Scaffolds a GitHub Actions workflow for attestation signing. +/// +/// Args: +/// * `out`: Output formatter for consistent terminal output. +/// +/// Usage: +/// ```ignore +/// scaffold_github_action(&out)?; +/// ``` +pub(crate) fn scaffold_github_action(out: &Output) -> Result<()> { + out.print_heading("GitHub Action Scaffolding"); + out.newline(); + + // Check we're in a git repo + let git_root = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + .context("Failed to run git rev-parse")?; + + if !git_root.status.success() { + return Err(anyhow!( + "Not inside a git repository. Run this from a git repo root." + )); + } + + let root = PathBuf::from(String::from_utf8_lossy(&git_root.stdout).trim()); + + // Check for GitHub remote + let remote_output = Command::new("git") + .args(["remote", "get-url", "origin"]) + .output(); + + match remote_output { + Ok(ref output) if output.status.success() => { + let url = String::from_utf8_lossy(&output.stdout); + if !url.contains("github.com") { + out.print_warn( + "Origin remote does not appear to be a GitHub repository — workflow may not work as expected", + ); + } + } + _ => { + out.print_warn( + "No 'origin' remote found — you may need to add one before the workflow can push", + ); + } + } + + // Create .github/workflows/ + let workflows_dir = root.join(".github/workflows"); + std::fs::create_dir_all(&workflows_dir) + .with_context(|| format!("Failed to create {}", workflows_dir.display()))?; + + // Write workflow file + let workflow_path = workflows_dir.join("auths-release.yml"); + if workflow_path.exists() { + out.print_warn(&format!( + "{} already exists — skipping (delete it first to regenerate)", + workflow_path.display() + )); + } else { + std::fs::write(&workflow_path, GITHUB_ACTION_WORKFLOW_TEMPLATE) + .with_context(|| format!("Failed to write {}", workflow_path.display()))?; + out.print_success(&format!("Created {}", workflow_path.display())); + } + + // Create .auths/ directory with .gitkeep + let auths_dir = root.join(".auths"); + std::fs::create_dir_all(&auths_dir) + .with_context(|| format!("Failed to create {}", auths_dir.display()))?; + + let gitkeep_path = auths_dir.join(".gitkeep"); + if !gitkeep_path.exists() { + std::fs::write(&gitkeep_path, "") + .with_context(|| format!("Failed to write {}", gitkeep_path.display()))?; + out.print_success(&format!("Created {}", gitkeep_path.display())); + } + + // Print next steps + out.newline(); + out.print_heading("Next steps"); + out.println(" 1. Set up CI secrets: just ci-setup"); + out.println(" 2. Add the generated secrets to GitHub repository settings"); + out.println(" 3. Customize the workflow's build step and artifact glob pattern"); + out.println(" 4. Commit and push: git add .github/workflows/auths-release.yml .auths/"); + out.newline(); + + Ok(()) +} + // --- Agent Capability Helpers --- #[derive(Debug, Clone)] diff --git a/crates/auths-cli/src/commands/init/mod.rs b/crates/auths-cli/src/commands/init/mod.rs index f771f891..3be0dce3 100644 --- a/crates/auths-cli/src/commands/init/mod.rs +++ b/crates/auths-cli/src/commands/init/mod.rs @@ -125,6 +125,10 @@ pub struct InitCommand { /// Skip automatic registry registration during setup #[clap(long)] pub skip_registration: bool, + + /// Scaffold a GitHub Actions workflow using the auths attest-action + #[clap(long)] + pub github_action: bool, } fn resolve_interactive(cmd: &InitCommand) -> Result { @@ -158,6 +162,11 @@ pub fn handle_init( now: chrono::DateTime, ) -> Result<()> { let out = Output::new(); + + if cmd.github_action { + return helpers::scaffold_github_action(&out); + } + let interactive = resolve_interactive(&cmd)?; let profile = match cmd.profile { @@ -382,6 +391,7 @@ mod tests { dry_run: false, registry: DEFAULT_REGISTRY_URL.to_string(), skip_registration: false, + github_action: false, }; assert!(!cmd.interactive); assert!(!cmd.non_interactive); @@ -391,6 +401,7 @@ mod tests { assert!(!cmd.dry_run); assert_eq!(cmd.registry, "https://auths-registry.fly.dev"); assert!(!cmd.skip_registration); + assert!(!cmd.github_action); } #[test] @@ -404,6 +415,7 @@ mod tests { dry_run: false, registry: DEFAULT_REGISTRY_URL.to_string(), skip_registration: false, + github_action: false, }; assert!(cmd.non_interactive); assert!(matches!(cmd.profile, Some(InitProfile::Ci))); @@ -435,6 +447,7 @@ mod tests { dry_run: false, registry: DEFAULT_REGISTRY_URL.to_string(), skip_registration: false, + github_action: false, }; assert!(!resolve_interactive(&cmd).unwrap()); } @@ -450,6 +463,7 @@ mod tests { dry_run: false, registry: DEFAULT_REGISTRY_URL.to_string(), skip_registration: false, + github_action: false, }; // Auto-detect returns is_terminal() — result depends on environment let result = resolve_interactive(&cmd).unwrap(); diff --git a/crates/auths-cli/tests/cases/init.rs b/crates/auths-cli/tests/cases/init.rs index 390ed9f8..3b45026d 100644 --- a/crates/auths-cli/tests/cases/init.rs +++ b/crates/auths-cli/tests/cases/init.rs @@ -1,5 +1,77 @@ use super::helpers::TestEnv; +#[test] +fn test_init_github_action_scaffold() { + let env = TestEnv::new(); + + // Set up a GitHub-like remote so the command doesn't warn + let mut git = env.git_cmd(); + git.args([ + "remote", + "add", + "origin", + "https://github.com/test-org/test-repo.git", + ]); + let output = git.output().unwrap(); + assert!(output.status.success(), "failed to add remote"); + + let output = env + .cmd("auths") + .args(["init", "--github-action"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "init --github-action should succeed, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Workflow file should exist + let workflow = env.repo_path.join(".github/workflows/auths-release.yml"); + assert!( + workflow.exists(), + "workflow file should be created at .github/workflows/auths-release.yml" + ); + + let content = std::fs::read_to_string(&workflow).unwrap(); + assert!( + content.contains("auths-dev/attest-action@v1"), + "workflow should reference attest-action" + ); + assert!( + content.contains("AUTHS_CI_PASSPHRASE"), + "workflow should reference secrets" + ); + + // .auths/.gitkeep should exist + let gitkeep = env.repo_path.join(".auths/.gitkeep"); + assert!(gitkeep.exists(), ".auths/.gitkeep should be created"); +} + +#[test] +fn test_init_github_action_idempotent() { + let env = TestEnv::new(); + + // Run twice — second run should not fail + let output1 = env + .cmd("auths") + .args(["init", "--github-action"]) + .output() + .unwrap(); + assert!(output1.status.success()); + + let output2 = env + .cmd("auths") + .args(["init", "--github-action"]) + .output() + .unwrap(); + assert!( + output2.status.success(), + "second run should succeed (idempotent)" + ); +} + #[test] fn test_init_happy_path() { let env = TestEnv::new(); diff --git a/crates/auths-sdk/src/domains/ci/environment.rs b/crates/auths-sdk/src/domains/ci/environment.rs new file mode 100644 index 00000000..7a147bde --- /dev/null +++ b/crates/auths-sdk/src/domains/ci/environment.rs @@ -0,0 +1,56 @@ +//! CI environment detection. +//! +//! Detects the CI platform from environment variables. This is domain logic +//! that agents and servers need — not just the CLI. + +use super::types::CiEnvironment; + +/// Detect the CI platform from well-known environment variables. +/// +/// Args: +/// * `detected_name`: Optional CI platform name string (e.g. from prior detection). +/// If `None`, returns `CiEnvironment::Unknown`. +/// +/// Usage: +/// ```ignore +/// let env = map_ci_environment(&Some("GitHub Actions".into())); +/// ``` +pub fn map_ci_environment(detected_name: &Option) -> CiEnvironment { + match detected_name.as_deref() { + Some("GitHub Actions") => CiEnvironment::GitHubActions, + Some("GitLab CI") => CiEnvironment::GitLabCi, + Some(name) => CiEnvironment::Custom { + name: name.to_string(), + }, + None => CiEnvironment::Unknown, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_github_actions() { + let env = map_ci_environment(&Some("GitHub Actions".into())); + assert!(matches!(env, CiEnvironment::GitHubActions)); + } + + #[test] + fn detects_gitlab_ci() { + let env = map_ci_environment(&Some("GitLab CI".into())); + assert!(matches!(env, CiEnvironment::GitLabCi)); + } + + #[test] + fn detects_custom_ci() { + let env = map_ci_environment(&Some("Buildkite".into())); + assert!(matches!(env, CiEnvironment::Custom { name } if name == "Buildkite")); + } + + #[test] + fn returns_unknown_for_none() { + let env = map_ci_environment(&None); + assert!(matches!(env, CiEnvironment::Unknown)); + } +} diff --git a/crates/auths-sdk/src/domains/ci/error.rs b/crates/auths-sdk/src/domains/ci/error.rs new file mode 100644 index 00000000..902e4099 --- /dev/null +++ b/crates/auths-sdk/src/domains/ci/error.rs @@ -0,0 +1,75 @@ +//! CI domain errors shared across CI workflows. + +use std::path::PathBuf; + +/// Errors from CI domain operations. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CiError { + /// No CI platform could be detected from environment variables. + #[error("CI environment not detected")] + EnvironmentNotDetected, + + /// The identity bundle at the given path is not a valid git repository. + #[error("identity bundle invalid at {path}: {reason}")] + IdentityBundleInvalid { + /// Path to the invalid identity bundle. + path: PathBuf, + /// What was wrong with it. + reason: String, + }, + + /// No artifacts were provided to sign. + #[error("no artifacts to sign")] + NoArtifacts, + + /// Failed to create the attestation collection directory. + #[error("failed to create attestation directory {path}: {reason}")] + CollectionDirFailed { + /// Path that could not be created. + path: PathBuf, + /// Underlying error. + reason: String, + }, + + /// Failed to copy an attestation file to the collection directory. + #[error("failed to collect attestation {src} → {dst}: {reason}")] + CollectionCopyFailed { + /// Source attestation file. + src: PathBuf, + /// Destination path. + dst: PathBuf, + /// Underlying error. + reason: String, + }, +} + +impl auths_core::error::AuthsErrorInfo for CiError { + fn error_code(&self) -> &'static str { + match self { + Self::EnvironmentNotDetected => "AUTHS-E7001", + Self::IdentityBundleInvalid { .. } => "AUTHS-E7002", + Self::NoArtifacts => "AUTHS-E7003", + Self::CollectionDirFailed { .. } => "AUTHS-E7004", + Self::CollectionCopyFailed { .. } => "AUTHS-E7005", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::EnvironmentNotDetected => { + Some("Set CI-specific environment variables or pass --ci-environment explicitly") + } + Self::IdentityBundleInvalid { .. } => { + Some("Re-run `just ci-setup` to regenerate the identity bundle secret") + } + Self::NoArtifacts => Some("Check your glob pattern matches at least one file"), + Self::CollectionDirFailed { .. } => { + Some("Check directory permissions and that the path is writable") + } + Self::CollectionCopyFailed { .. } => { + Some("Check file permissions and available disk space") + } + } + } +} diff --git a/crates/auths-sdk/src/domains/ci/mod.rs b/crates/auths-sdk/src/domains/ci/mod.rs new file mode 100644 index 00000000..2163f3bf --- /dev/null +++ b/crates/auths-sdk/src/domains/ci/mod.rs @@ -0,0 +1,9 @@ +//! CI domain — shared types, errors, and environment detection for CI workflows. + +pub mod environment; +pub mod error; +pub mod types; + +pub use environment::map_ci_environment; +pub use error::CiError; +pub use types::{CiEnvironment, CiIdentityConfig}; diff --git a/crates/auths-sdk/src/domains/ci/types.rs b/crates/auths-sdk/src/domains/ci/types.rs new file mode 100644 index 00000000..86debb69 --- /dev/null +++ b/crates/auths-sdk/src/domains/ci/types.rs @@ -0,0 +1,48 @@ +//! CI domain types shared across all CI workflows. + +use std::path::PathBuf; + +/// CI platform environment. +/// +/// Usage: +/// ```ignore +/// let env = CiEnvironment::GitHubActions; +/// ``` +#[derive(Debug, Clone)] +pub enum CiEnvironment { + /// GitHub Actions CI environment. + GitHubActions, + /// GitLab CI/CD environment. + GitLabCi, + /// A custom CI platform with a user-provided name. + Custom { + /// The name of the custom CI platform. + name: String, + }, + /// The CI platform could not be detected. + Unknown, +} + +/// Configuration for CI/ephemeral identity. +/// +/// The keychain and passphrase are passed separately to [`crate::domains::identity::service::initialize`] — +/// this struct carries only the CI-specific configuration values. +/// +/// Args: +/// * `ci_environment`: The detected or specified CI platform. +/// * `registry_path`: Path to the ephemeral auths registry. +/// +/// Usage: +/// ```ignore +/// let config = CiIdentityConfig { +/// ci_environment: CiEnvironment::GitHubActions, +/// registry_path: PathBuf::from("/tmp/.auths"), +/// }; +/// ``` +#[derive(Debug, Clone)] +pub struct CiIdentityConfig { + /// The detected or specified CI platform. + pub ci_environment: CiEnvironment, + /// Path to the ephemeral auths registry directory. + pub registry_path: PathBuf, +} diff --git a/crates/auths-sdk/src/domains/identity/service.rs b/crates/auths-sdk/src/domains/identity/service.rs index 96ac8f7a..13ef2c41 100644 --- a/crates/auths-sdk/src/domains/identity/service.rs +++ b/crates/auths-sdk/src/domains/identity/service.rs @@ -12,11 +12,12 @@ use auths_verifier::types::DeviceDID; use chrono::{DateTime, Utc}; use crate::context::AuthsContext; +use crate::domains::ci::types::{CiEnvironment, CiIdentityConfig}; use crate::domains::identity::error::SetupError; use crate::domains::identity::types::{ - AgentIdentityResult, CiEnvironment, CiIdentityConfig, CiIdentityResult, - CreateAgentIdentityConfig, CreateDeveloperIdentityConfig, DeveloperIdentityResult, - IdentityConfig, IdentityConflictPolicy, InitializeResult, RegistrationOutcome, + AgentIdentityResult, CiIdentityResult, CreateAgentIdentityConfig, + CreateDeveloperIdentityConfig, DeveloperIdentityResult, IdentityConfig, IdentityConflictPolicy, + InitializeResult, RegistrationOutcome, }; use crate::domains::signing::types::PlatformClaimResult; use crate::domains::signing::types::{GitSigningScope, PlatformVerification}; diff --git a/crates/auths-sdk/src/domains/identity/types.rs b/crates/auths-sdk/src/domains/identity/types.rs index f63f4216..21050458 100644 --- a/crates/auths-sdk/src/domains/identity/types.rs +++ b/crates/auths-sdk/src/domains/identity/types.rs @@ -3,6 +3,8 @@ use auths_verifier::Capability; use auths_verifier::types::DeviceDID; use std::path::PathBuf; +use crate::domains::ci::types::{CiEnvironment, CiIdentityConfig}; + /// Policy for handling an existing identity during developer setup. /// /// Replaces interactive CLI prompts with a typed enum that headless consumers @@ -25,27 +27,6 @@ pub enum IdentityConflictPolicy { ForceNew, } -/// CI platform environment. -/// -/// Usage: -/// ```ignore -/// let env = CiEnvironment::GitHubActions; -/// ``` -#[derive(Debug, Clone)] -pub enum CiEnvironment { - /// GitHub Actions CI environment. - GitHubActions, - /// GitLab CI/CD environment. - GitLabCi, - /// A custom CI platform with a user-provided name. - Custom { - /// The name of the custom CI platform. - name: String, - }, - /// The CI platform could not be detected. - Unknown, -} - /// Configuration for provisioning a new developer identity. /// /// Use [`CreateDeveloperIdentityConfigBuilder`] to construct this with optional fields. @@ -269,30 +250,6 @@ impl CreateDeveloperIdentityConfigBuilder { } } -/// Configuration for CI/ephemeral identity. -/// -/// The keychain and passphrase are passed separately to [`crate::setup::initialize`] — -/// this struct carries only the CI-specific configuration values. -/// -/// Args: -/// * `ci_environment`: The detected or specified CI platform. -/// * `registry_path`: Path to the ephemeral auths registry. -/// -/// Usage: -/// ```ignore -/// let config = CiIdentityConfig { -/// ci_environment: CiEnvironment::GitHubActions, -/// registry_path: PathBuf::from("/tmp/.auths"), -/// }; -/// ``` -#[derive(Debug, Clone)] -pub struct CiIdentityConfig { - /// The detected or specified CI platform. - pub ci_environment: CiEnvironment, - /// Path to the ephemeral auths registry directory. - pub registry_path: PathBuf, -} - /// Selects which identity persona to provision via [`crate::setup::initialize`]. /// /// Usage: diff --git a/crates/auths-sdk/src/domains/mod.rs b/crates/auths-sdk/src/domains/mod.rs index 7bf351de..57d226b3 100644 --- a/crates/auths-sdk/src/domains/mod.rs +++ b/crates/auths-sdk/src/domains/mod.rs @@ -11,6 +11,7 @@ pub mod agents; pub mod auth; +pub mod ci; pub mod compliance; pub mod device; pub mod diagnostics; diff --git a/crates/auths-sdk/src/types.rs b/crates/auths-sdk/src/types.rs index 1a3c514b..52c101bc 100644 --- a/crates/auths-sdk/src/types.rs +++ b/crates/auths-sdk/src/types.rs @@ -1,10 +1,11 @@ //! Re-exports of domain configuration types for backwards compatibility. // Re-export all identity config types +pub use crate::domains::ci::types::{CiEnvironment, CiIdentityConfig}; pub use crate::domains::identity::types::{ - CiEnvironment, CiIdentityConfig, CreateAgentIdentityConfig, CreateAgentIdentityConfigBuilder, - CreateDeveloperIdentityConfig, CreateDeveloperIdentityConfigBuilder, IdentityConfig, - IdentityConflictPolicy, IdentityRotationConfig, + CreateAgentIdentityConfig, CreateAgentIdentityConfigBuilder, CreateDeveloperIdentityConfig, + CreateDeveloperIdentityConfigBuilder, IdentityConfig, IdentityConflictPolicy, + IdentityRotationConfig, }; // Re-export signing types diff --git a/crates/auths-sdk/src/workflows/ci/batch_attest.rs b/crates/auths-sdk/src/workflows/ci/batch_attest.rs new file mode 100644 index 00000000..e454db90 --- /dev/null +++ b/crates/auths-sdk/src/workflows/ci/batch_attest.rs @@ -0,0 +1,281 @@ +//! Batch artifact signing and attestation collection workflow. +//! +//! Provides the domain logic for CI attestation pipelines: sign multiple +//! artifacts in one pass, collect attestation files to a target directory, +//! and report per-file results. Used by the CLI `artifact batch-sign` command +//! and the `auths-dev/attest-action` GitHub Action. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::context::AuthsContext; +use crate::domains::ci::error::CiError; +use crate::domains::signing::service::{ArtifactSigningParams, SigningKeyMaterial, sign_artifact}; +use crate::ports::artifact::ArtifactSource; +use auths_core::storage::keychain::KeyAlias; + +/// A single artifact to sign in a batch operation. +/// +/// Args: +/// * `source`: The artifact data source (implements digest/metadata). +/// * `output_path`: Where to write the `.auths.json` attestation file. +/// +/// Usage: +/// ```ignore +/// let entry = BatchEntry { +/// source: Arc::new(my_artifact), +/// output_path: PathBuf::from("release.tar.gz.auths.json"), +/// }; +/// ``` +pub struct BatchEntry { + /// Artifact source providing digest and metadata. + pub source: Arc, + /// Destination path for the attestation JSON file. + pub output_path: PathBuf, +} + +/// Configuration for a batch signing operation. +/// +/// Args: +/// * `entries`: List of artifacts to sign with their output paths. +/// * `device_key`: Device key alias used for dual-signing. +/// * `identity_key`: Optional identity key alias (omit for device-only CI signing). +/// * `expires_in`: Optional TTL in seconds for attestation expiry. +/// * `note`: Optional annotation embedded in each attestation. +/// * `attestation_dir`: If set, attestation files are also copied here. +/// +/// Usage: +/// ```ignore +/// let config = BatchSignConfig { +/// entries: vec![entry1, entry2], +/// device_key: "ci-release-device".to_string(), +/// identity_key: None, +/// expires_in: None, +/// note: Some("release v1.0".to_string()), +/// attestation_dir: Some(PathBuf::from(".auths/releases")), +/// }; +/// ``` +pub struct BatchSignConfig { + /// Artifacts to sign. + pub entries: Vec, + /// Device key alias for signing. + pub device_key: String, + /// Optional identity key alias. + pub identity_key: Option, + /// Optional TTL in seconds. + pub expires_in: Option, + /// Optional note for all attestations. + pub note: Option, +} + +/// Outcome for a single artifact in a batch. +#[derive(Debug)] +pub enum BatchEntryResult { + /// Signing succeeded. + Signed(SignedArtifact), + /// Signing failed for this artifact (other artifacts may still succeed). + Failed(FailedArtifact), +} + +/// A successfully signed artifact. +/// +/// Usage: +/// ```ignore +/// println!("Signed {} (sha256:{})", result.rid, result.digest); +/// ``` +#[derive(Debug)] +pub struct SignedArtifact { + /// Intended output path for the attestation file. + pub output_path: PathBuf, + /// Canonical JSON of the signed attestation. + pub attestation_json: String, + /// Resource identifier from the attestation. + pub rid: String, + /// Hex-encoded SHA-256 digest of the artifact. + pub digest: String, +} + +/// An artifact that failed to sign. +#[derive(Debug)] +pub struct FailedArtifact { + /// Output path that would have been written. + pub output_path: PathBuf, + /// The error that prevented signing. + pub error: String, +} + +/// Result of a batch signing operation. +/// +/// Usage: +/// ```ignore +/// let result = batch_sign_artifacts(config, &ctx)?; +/// println!("{} signed, {} failed", result.signed_count(), result.failed_count()); +/// ``` +#[derive(Debug)] +pub struct BatchSignResult { + /// Per-artifact outcomes. + pub results: Vec, +} + +impl BatchSignResult { + /// Number of successfully signed artifacts. + pub fn signed_count(&self) -> usize { + self.results + .iter() + .filter(|r| matches!(r, BatchEntryResult::Signed(_))) + .count() + } + + /// Number of failed artifacts. + pub fn failed_count(&self) -> usize { + self.results + .iter() + .filter(|r| matches!(r, BatchEntryResult::Failed(_))) + .count() + } + + /// Whether all artifacts were signed successfully. + pub fn all_succeeded(&self) -> bool { + self.failed_count() == 0 + } +} + +// Errors are defined in crate::domains::ci::error::CiError + +/// Derive the default attestation output path for an artifact. +/// +/// Args: +/// * `artifact_path`: Path to the original artifact file. +/// +/// Usage: +/// ```ignore +/// let out = default_attestation_path(Path::new("release.tar.gz")); +/// assert_eq!(out, PathBuf::from("release.tar.gz.auths.json")); +/// ``` +pub fn default_attestation_path(artifact_path: &Path) -> PathBuf { + let mut p = artifact_path.to_path_buf(); + let new_name = format!( + "{}.auths.json", + p.file_name().unwrap_or_default().to_string_lossy() + ); + p.set_file_name(new_name); + p +} + +/// Sign multiple artifacts in a single batch and optionally collect attestations. +/// +/// Each artifact is signed independently — a failure on one does not prevent +/// signing the others. Results are returned per-artifact so callers can decide +/// how to handle partial failures. +/// +/// Args: +/// * `config`: Batch configuration with artifact entries, keys, and options. +/// * `ctx`: Runtime context providing identity storage, keychain, and clock. +/// +/// Usage: +/// ```ignore +/// let result = batch_sign_artifacts(config, &ctx)?; +/// for entry in &result.results { +/// match entry { +/// BatchEntryResult::Signed(s) => println!("OK: {}", s.output_path.display()), +/// BatchEntryResult::Failed(f) => eprintln!("FAIL: {}: {}", f.output_path.display(), f.error), +/// } +/// } +/// ``` +pub fn batch_sign_artifacts( + config: BatchSignConfig, + ctx: &AuthsContext, +) -> Result { + if config.entries.is_empty() { + return Err(CiError::NoArtifacts); + } + + let mut results = Vec::with_capacity(config.entries.len()); + + for entry in &config.entries { + let params = ArtifactSigningParams { + artifact: Arc::clone(&entry.source), + identity_key: config + .identity_key + .as_ref() + .map(|k| SigningKeyMaterial::Alias(KeyAlias::new_unchecked(k))), + device_key: SigningKeyMaterial::Alias(KeyAlias::new_unchecked(&config.device_key)), + expires_in: config.expires_in, + note: config.note.clone(), + }; + + match sign_artifact(params, ctx) { + Ok(result) => results.push(BatchEntryResult::Signed(SignedArtifact { + output_path: entry.output_path.clone(), + attestation_json: result.attestation_json, + rid: result.rid.to_string(), + digest: result.digest, + })), + Err(e) => results.push(BatchEntryResult::Failed(FailedArtifact { + output_path: entry.output_path.clone(), + error: e.to_string(), + })), + } + } + + Ok(BatchSignResult { results }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_attestation_path_appends_suffix() { + let p = default_attestation_path(Path::new("/tmp/release.tar.gz")); + assert_eq!(p, PathBuf::from("/tmp/release.tar.gz.auths.json")); + } + + #[test] + fn default_attestation_path_handles_bare_name() { + let p = default_attestation_path(Path::new("artifact.bin")); + assert_eq!(p, PathBuf::from("artifact.bin.auths.json")); + } + + #[test] + fn batch_sign_result_counts() { + let result = BatchSignResult { + results: vec![ + BatchEntryResult::Signed(SignedArtifact { + output_path: PathBuf::from("a.auths.json"), + attestation_json: "{}".to_string(), + rid: "sha256:abc".to_string(), + digest: "abc".to_string(), + }), + BatchEntryResult::Failed(FailedArtifact { + output_path: PathBuf::from("b.auths.json"), + error: "test error".to_string(), + }), + BatchEntryResult::Signed(SignedArtifact { + output_path: PathBuf::from("c.auths.json"), + attestation_json: "{}".to_string(), + rid: "sha256:def".to_string(), + digest: "def".to_string(), + }), + ], + }; + + assert_eq!(result.signed_count(), 2); + assert_eq!(result.failed_count(), 1); + assert!(!result.all_succeeded()); + } + + #[test] + fn batch_sign_result_all_succeeded() { + let result = BatchSignResult { + results: vec![BatchEntryResult::Signed(SignedArtifact { + output_path: PathBuf::from("a.auths.json"), + attestation_json: "{}".to_string(), + rid: "sha256:abc".to_string(), + digest: "abc".to_string(), + })], + }; + + assert!(result.all_succeeded()); + } +} diff --git a/crates/auths-sdk/src/workflows/machine_identity.rs b/crates/auths-sdk/src/workflows/ci/machine_identity.rs similarity index 100% rename from crates/auths-sdk/src/workflows/machine_identity.rs rename to crates/auths-sdk/src/workflows/ci/machine_identity.rs diff --git a/crates/auths-sdk/src/workflows/ci/mod.rs b/crates/auths-sdk/src/workflows/ci/mod.rs new file mode 100644 index 00000000..6d600733 --- /dev/null +++ b/crates/auths-sdk/src/workflows/ci/mod.rs @@ -0,0 +1,6 @@ +//! CI workflow orchestration — batch signing, OIDC machine identity, and future CI automations. + +/// Batch artifact signing and attestation collection. +pub mod batch_attest; +/// OIDC machine identity creation from CI platform tokens. +pub mod machine_identity; diff --git a/crates/auths-sdk/src/workflows/mod.rs b/crates/auths-sdk/src/workflows/mod.rs index a1b28b4b..a3facd52 100644 --- a/crates/auths-sdk/src/workflows/mod.rs +++ b/crates/auths-sdk/src/workflows/mod.rs @@ -4,10 +4,10 @@ pub mod artifact; pub mod audit; /// DID-based authentication challenge signing workflow. pub mod auth; +/// CI workflows — batch attestation, OIDC machine identity, and future CI automations. +pub mod ci; pub mod diagnostics; pub mod git_integration; -/// Machine identity creation from OIDC tokens for ephemeral CI/CD identities. -pub mod machine_identity; #[cfg(feature = "mcp")] pub mod mcp; pub mod namespace; diff --git a/crates/auths-sdk/tests/cases/ci_setup.rs b/crates/auths-sdk/tests/cases/ci_setup.rs index 56a758aa..a0f05d00 100644 --- a/crates/auths-sdk/tests/cases/ci_setup.rs +++ b/crates/auths-sdk/tests/cases/ci_setup.rs @@ -3,9 +3,10 @@ use std::sync::Arc; use auths_core::PrefilledPassphraseProvider; use auths_core::signing::StorageSigner; use auths_core::storage::memory::{MEMORY_KEYCHAIN, MemoryKeychainHandle}; +use auths_sdk::domains::ci::types::{CiEnvironment, CiIdentityConfig}; use auths_sdk::domains::identity::service::initialize; +use auths_sdk::domains::identity::types::IdentityConfig; use auths_sdk::domains::identity::types::InitializeResult; -use auths_sdk::domains::identity::types::{CiEnvironment, CiIdentityConfig, IdentityConfig}; use crate::cases::helpers::build_test_context; diff --git a/crates/auths-sdk/tests/sign_commit_attestation.rs b/crates/auths-sdk/tests/sign_commit_attestation.rs index ef990140..69aefb29 100644 --- a/crates/auths-sdk/tests/sign_commit_attestation.rs +++ b/crates/auths-sdk/tests/sign_commit_attestation.rs @@ -1,7 +1,7 @@ //! Integration tests for commit signing and attestation verification. use auths_crypto::testing::gen_keypair; -use auths_sdk::workflows::machine_identity::{ +use auths_sdk::workflows::ci::machine_identity::{ OidcMachineIdentity, SignCommitParams, sign_commit_with_identity, }; use chrono::Utc;