diff --git a/Cargo.lock b/Cargo.lock index ba1455824..e39f6fd01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -664,7 +664,7 @@ dependencies = [ [[package]] name = "capsem" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "blake3", @@ -699,7 +699,7 @@ dependencies = [ [[package]] name = "capsem-admin" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "blake3", @@ -721,7 +721,7 @@ dependencies = [ [[package]] name = "capsem-agent" -version = "0.6.3" +version = "0.6.4" dependencies = [ "capsem-foundation", "capsem-proto", @@ -735,7 +735,7 @@ dependencies = [ [[package]] name = "capsem-api" -version = "0.6.3" +version = "0.6.4" dependencies = [ "base64 0.22.1", "serde", @@ -745,7 +745,7 @@ dependencies = [ [[package]] name = "capsem-app" -version = "0.6.3" +version = "0.6.4" dependencies = [ "capsem-proto", "filetime", @@ -765,7 +765,7 @@ dependencies = [ [[package]] name = "capsem-archive" -version = "0.6.3" +version = "0.6.4" dependencies = [ "blake3", "capsem-foundation", @@ -778,7 +778,7 @@ dependencies = [ [[package]] name = "capsem-assets" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "axum", @@ -798,7 +798,7 @@ dependencies = [ [[package]] name = "capsem-bench" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "capsem-foundation", @@ -815,7 +815,7 @@ dependencies = [ [[package]] name = "capsem-config" -version = "0.6.3" +version = "0.6.4" dependencies = [ "capsem-proto", "regex", @@ -828,7 +828,7 @@ dependencies = [ [[package]] name = "capsem-core" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "async-compression", @@ -893,7 +893,7 @@ dependencies = [ [[package]] name = "capsem-credentials" -version = "0.6.3" +version = "0.6.4" dependencies = [ "capsem-foundation", "capsem-proto", @@ -905,7 +905,7 @@ dependencies = [ [[package]] name = "capsem-foundation" -version = "0.6.3" +version = "0.6.4" dependencies = [ "blake3", "capsem-proto", @@ -923,7 +923,7 @@ dependencies = [ [[package]] name = "capsem-gateway" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "axum", @@ -954,7 +954,7 @@ dependencies = [ [[package]] name = "capsem-guard" -version = "0.6.3" +version = "0.6.4" dependencies = [ "capsem-foundation", "tempfile", @@ -964,7 +964,7 @@ dependencies = [ [[package]] name = "capsem-logger" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "blake3", @@ -990,7 +990,7 @@ dependencies = [ [[package]] name = "capsem-mcp-aggregator" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "axum", @@ -1014,7 +1014,7 @@ dependencies = [ [[package]] name = "capsem-mcp-builtin" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "capsem-core", @@ -1033,7 +1033,7 @@ dependencies = [ [[package]] name = "capsem-mock-server" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "base64 0.22.1", @@ -1060,7 +1060,7 @@ dependencies = [ [[package]] name = "capsem-network" -version = "0.6.3" +version = "0.6.4" dependencies = [ "capsem-proto", "tokio", @@ -1068,7 +1068,7 @@ dependencies = [ [[package]] name = "capsem-process" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "axum", @@ -1091,7 +1091,7 @@ dependencies = [ [[package]] name = "capsem-proto" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "blake3", @@ -1108,7 +1108,7 @@ dependencies = [ [[package]] name = "capsem-router" -version = "0.6.3" +version = "0.6.4" dependencies = [ "bytes", "capsem-foundation", @@ -1126,7 +1126,7 @@ dependencies = [ [[package]] name = "capsem-sdk" -version = "0.6.3" +version = "0.6.4" dependencies = [ "axum", "capsem-api", @@ -1139,7 +1139,7 @@ dependencies = [ [[package]] name = "capsem-service" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "axum", @@ -1179,7 +1179,7 @@ dependencies = [ [[package]] name = "capsem-telemetry" -version = "0.6.3" +version = "0.6.4" dependencies = [ "async-trait", "bytes", @@ -1195,7 +1195,7 @@ dependencies = [ [[package]] name = "capsem-tray" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "capsem-foundation", @@ -1215,7 +1215,7 @@ dependencies = [ [[package]] name = "capsem-tui" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "capsem-sdk", diff --git a/Cargo.toml b/Cargo.toml index 88d22faaa..e8e2e4dcd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ members = [ ] [workspace.package] -version = "0.6.3" +version = "0.6.4" edition = "2021" rust-version = "1.91" license = "Apache-2.0" diff --git a/build_system/pyproject.toml b/build_system/pyproject.toml index 906f038ed..6dc3f4611 100644 --- a/build_system/pyproject.toml +++ b/build_system/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "capsem-builder" -version = "0.6.3" +version = "0.6.4" readme = "README.md" requires-python = ">=3.11" dependencies = [ diff --git a/build_system/tests/test_project_boundary.py b/build_system/tests/test_project_boundary.py index f89aefc36..b931ff0bc 100644 --- a/build_system/tests/test_project_boundary.py +++ b/build_system/tests/test_project_boundary.py @@ -17,4 +17,4 @@ def test_editable_install_maps_the_direct_source_directory() -> None: def test_installed_distribution_uses_the_reserved_builder_name() -> None: - assert version("capsem-builder") == "0.6.3" + assert version("capsem-builder") == "0.6.4" diff --git a/build_system/uv.lock b/build_system/uv.lock index ff631ecd8..ba88b28c2 100644 --- a/build_system/uv.lock +++ b/build_system/uv.lock @@ -238,7 +238,7 @@ wheels = [ [[package]] name = "capsem-builder" -version = "0.6.3" +version = "0.6.4" source = { editable = "." } dependencies = [ { name = "blake3" }, diff --git a/config/gate.toml b/config/gate.toml index f26ccb3a6..a80cdcd77 100644 --- a/config/gate.toml +++ b/config/gate.toml @@ -1099,7 +1099,7 @@ must_stay_below_lines = 3000 [boundary.rust.oversized_line_counts] "crates/capsem-admin/src/assets_channel_render.rs" = 1719 "crates/capsem-admin/src/assets_channel_validation.rs" = 1412 -"crates/capsem-admin/src/main.rs" = 2182 +"crates/capsem-admin/src/main.rs" = 2144 "crates/capsem-admin/src/profile_images.rs" = 2188 "crates/capsem-admin/src/release_graph.rs" = 1089 "crates/capsem-admin/src/release_graph/tests.rs" = 1323 diff --git a/config/profiles/code/profile.toml b/config/profiles/code/profile.toml index 092b1bef4..f95417a67 100644 --- a/config/profiles/code/profile.toml +++ b/config/profiles/code/profile.toml @@ -8,7 +8,7 @@ id = "code" name = "Code" description = "Optimized for coding and long-running agents." icon_svg = "" -revision = "0.6.2" +revision = "0.6.3" refresh_policy = "24h" # The runtimes this profile is the default for; the gateway publishes both in # GET /status so no client compiles a profile name into itself. Today one diff --git a/crates/capsem-admin/src/main.rs b/crates/capsem-admin/src/main.rs index 5514c1545..a0b17734f 100644 --- a/crates/capsem-admin/src/main.rs +++ b/crates/capsem-admin/src/main.rs @@ -4,7 +4,6 @@ use std::{ io::Read, path::{Path, PathBuf}, process::{Command, Stdio}, - thread, }; use anyhow::{anyhow, Context, Result}; @@ -26,6 +25,7 @@ mod channel_bootstrap; mod manifest_generation; mod package_inspection; mod profile_images; +mod release_github; #[allow(dead_code)] mod release_graph; mod source_commit; @@ -37,6 +37,7 @@ use manifest_generation::*; use profile_images::*; use package_inspection::binary_files_from_artifacts; +use release_github::{ensure_publication_identity_is_free, GhProfileWorkflowRunner, ProfileWorkflowRunner}; use source_commit::SourceCommit; #[derive(Debug, Parser)] @@ -629,47 +630,6 @@ struct ProfileWorkflowRun { conclusion: String, } -trait ProfileWorkflowRunner { - fn run(&mut self, args: &[String]) -> Result<()>; - fn output(&mut self, args: &[String]) -> Result; - fn wait_before_poll(&mut self); -} - -struct GhProfileWorkflowRunner; - -impl ProfileWorkflowRunner for GhProfileWorkflowRunner { - fn run(&mut self, args: &[String]) -> Result<()> { - let status = Command::new("gh") - .args(args) - .status() - .with_context(|| format!("run gh {}", args.join(" ")))?; - if !status.success() { - return Err(anyhow!("gh {} failed with {}", args.join(" "), status)); - } - Ok(()) - } - - fn output(&mut self, args: &[String]) -> Result { - let output = Command::new("gh") - .args(args) - .output() - .with_context(|| format!("run gh {}", args.join(" ")))?; - if !output.status.success() { - return Err(anyhow!( - "gh {} failed with {}: {}", - args.join(" "), - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )); - } - String::from_utf8(output.stdout).context("gh workflow listing was not UTF-8") - } - - fn wait_before_poll(&mut self) { - thread::sleep(std::time::Duration::from_secs(2)); - } -} - fn dispatch_profile_workflow( runner: &mut R, workflow: &str, @@ -1376,6 +1336,8 @@ fn release_command(args: ReleaseArgs) -> Result<()> { let run_id = if args.dry_run { None } else { + let identity = &selection.publication_identity; + ensure_publication_identity_is_free(&mut GhProfileWorkflowRunner, identity, &args.source_commit)?; let dispatch_id = format!( "capsem-admin-{}-{}", std::process::id(), diff --git a/crates/capsem-admin/src/release_github.rs b/crates/capsem-admin/src/release_github.rs new file mode 100644 index 000000000..a98f40031 --- /dev/null +++ b/crates/capsem-admin/src/release_github.rs @@ -0,0 +1,117 @@ +//! The GitHub side of `capsem-admin release`: the `gh` runner that dispatches +//! and watches profile workflows, and the check that a publication identity is +//! still free before any of that starts. + +use std::process::Command; +use std::thread; + +use anyhow::{anyhow, bail, Context, Result}; + +use crate::source_commit::SourceCommit; + +pub(crate) trait ProfileWorkflowRunner { + fn run(&mut self, args: &[String]) -> Result<()>; + fn output(&mut self, args: &[String]) -> Result; + fn wait_before_poll(&mut self); +} + +/// Which commit an existing immutable release was published from. +pub(crate) trait ReleaseLookup { + /// The source commit of the release tagged `tag`, or `None` if none exists. + fn release_commit(&mut self, tag: &str) -> Result>; +} + +/// Refuse to dispatch a publication whose identity is already taken by a +/// different source commit. +/// +/// Releases are immutable, so the hosted lane would build and verify every +/// asset and only then refuse at publication, forty minutes in: +/// `stable/code 0.6.2` had been published from an earlier commit and never +/// activated, and a release of the same revision from main found out at its +/// last step. The same commit is a resume, which publication supports. +pub(crate) fn ensure_publication_identity_is_free( + lookup: &mut impl ReleaseLookup, + publication_identity: &str, + source_commit: &SourceCommit, +) -> Result<()> { + match lookup.release_commit(publication_identity)? { + None => Ok(()), + Some(existing) if existing == source_commit.to_string() => Ok(()), + Some(existing) => bail!( + "{publication_identity} is already published from {existing}; releases are immutable, \ + so advance the profile's `revision` in its profile.toml before releasing {source_commit}" + ), + } +} + +pub(crate) struct GhProfileWorkflowRunner; + +impl ProfileWorkflowRunner for GhProfileWorkflowRunner { + fn run(&mut self, args: &[String]) -> Result<()> { + let status = Command::new("gh") + .args(args) + .status() + .with_context(|| format!("run gh {}", args.join(" ")))?; + if !status.success() { + return Err(anyhow!("gh {} failed with {}", args.join(" "), status)); + } + Ok(()) + } + + fn output(&mut self, args: &[String]) -> Result { + let output = Command::new("gh") + .args(args) + .output() + .with_context(|| format!("run gh {}", args.join(" ")))?; + if !output.status.success() { + return Err(anyhow!( + "gh {} failed with {}: {}", + args.join(" "), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + String::from_utf8(output.stdout).context("gh workflow listing was not UTF-8") + } + + fn wait_before_poll(&mut self) { + thread::sleep(std::time::Duration::from_secs(2)); + } +} + +/// What `gh release view` prints when the tag has no release. +const RELEASE_NOT_FOUND: &str = "release not found"; + +impl ReleaseLookup for GhProfileWorkflowRunner { + fn release_commit(&mut self, tag: &str) -> Result> { + let output = Command::new("gh") + .args([ + "release", + "view", + tag, + "--json", + "targetCommitish", + "--jq", + ".targetCommitish", + ]) + .output() + .with_context(|| format!("run gh release view {tag}"))?; + if output.status.success() { + let commit = String::from_utf8(output.stdout).context("gh release view was not UTF-8")?; + return Ok(Some(commit.trim().to_string())); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains(RELEASE_NOT_FOUND) { + return Ok(None); + } + // Anything else -- no network, no auth -- is not evidence the identity is free. + Err(anyhow!( + "gh release view {tag} failed with {}: {}", + output.status, + stderr.trim() + )) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/capsem-admin/src/release_github/tests.rs b/crates/capsem-admin/src/release_github/tests.rs new file mode 100644 index 000000000..e39597f56 --- /dev/null +++ b/crates/capsem-admin/src/release_github/tests.rs @@ -0,0 +1,50 @@ +use super::*; + +const IDENTITY: &str = "profile-stable-code-0.6.2"; +const EARLIER: &str = "2368c2a80280a6ae1cff5917a8f62fbdab09c7f2"; +const MAIN: &str = "0da05c6bce4807bf7f73398d14ab7eee6b525198"; + +struct Existing(Option<&'static str>); + +impl ReleaseLookup for Existing { + fn release_commit(&mut self, tag: &str) -> Result> { + assert_eq!(tag, IDENTITY); + Ok(self.0.map(str::to_string)) + } +} + +struct Unreachable; + +impl ReleaseLookup for Unreachable { + fn release_commit(&mut self, _tag: &str) -> Result> { + Err(anyhow!("gh: could not resolve host")) + } +} + +fn commit(sha: &str) -> SourceCommit { + sha.parse().unwrap() +} + +#[test] +fn an_unpublished_identity_is_free() { + ensure_publication_identity_is_free(&mut Existing(None), IDENTITY, &commit(MAIN)).unwrap(); +} + +#[test] +fn the_same_commit_resumes_its_own_publication() { + ensure_publication_identity_is_free(&mut Existing(Some(MAIN)), IDENTITY, &commit(MAIN)).unwrap(); +} + +#[test] +fn an_identity_published_from_another_commit_is_refused_before_any_build() { + let error = ensure_publication_identity_is_free(&mut Existing(Some(EARLIER)), IDENTITY, &commit(MAIN)) + .unwrap_err() + .to_string(); + assert!(error.contains(EARLIER), "{error}"); + assert!(error.contains("advance the profile's `revision`"), "{error}"); +} + +#[test] +fn a_lookup_that_cannot_answer_is_not_taken_as_free() { + assert!(ensure_publication_identity_is_free(&mut Unreachable, IDENTITY, &commit(MAIN)).is_err()); +} diff --git a/crates/capsem-app/tauri.conf.json b/crates/capsem-app/tauri.conf.json index c3981d977..a3a121c54 100644 --- a/crates/capsem-app/tauri.conf.json +++ b/crates/capsem-app/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json", "productName": "Capsem", - "version": "0.6.3", + "version": "0.6.4", "identifier": "com.capsem.capsem", "build": { "beforeDevCommand": "pnpm dev", diff --git a/tests/citadel/cargo_lock_identity.toml b/tests/citadel/cargo_lock_identity.toml index b15d1cd12..6e2ddc60d 100644 --- a/tests/citadel/cargo_lock_identity.toml +++ b/tests/citadel/cargo_lock_identity.toml @@ -7,5 +7,5 @@ # each moved the lock. `test_cargo_lock_changes_are_deliberate` fails until # this record matches the lock again, so the cost is chosen, reviewed and # batched rather than discovered twenty minutes into a gate. -sha256 = "299a531675fe27901fdcde8815cf78fac7a1cf70eceddbe84b5abebbcccbd974" -reason = "security: drop rustls-pemfile (one test, now rustls pki-types) and bincode 1 (one bench of the retired IPC encoding), both unmaintained advisories we had been accepting" +sha256 = "03a6091a012794ed64ec2e69ccbd1b35cc8b6b305197e77cf434c967f7219067" +reason = "release: workspace version 0.6.3 -> 0.6.4 for the binary release that activates the snapshot-free stable profiles; only first-party package versions move"