From fa066bc614394a098f1f025b8c88e13090b97d1a Mon Sep 17 00:00:00 2001 From: magnificentlycursed <190320250+magnificentlycursed@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:00:46 -0700 Subject: [PATCH 01/15] feat(container): default to the agent image that matches the crosslink build Container commands defaulted to `crosslink-agent:latest`. Under Corvidae that tag does not exist (no release has been tagged since the move), and where it does exist it moves on every tag, prereleases included, while a cached copy is never refreshed, so the crosslink inside the container could lag behind or run ahead of the CLI that launched it. The two write to the same hub. The build script now chooses the default tag: an explicit CROSSLINK_AGENT_IMAGE_TAG at build time wins; a build from crosslink's own checkout uses `` when HEAD is exactly `v` with a clean tree and `nightly` otherwise; any other build, such as a crates.io source install, uses ``. One constant carries it to every call site: kickoff's `--image` default, `container start`, the container auth fallback, swarm and sentinel. `container start` gains `--image` and honours CROSSLINK_CONTAINER_IMAGE like the auth commands. Floating tags (`nightly`, `latest`) are launched with `--pull=always`; pinned tags and digests are not. The pull-failure hint explains the build-matched tag when the default image is missing. The container guide describes the default and no longer calls registry tags immutable; only a digest pin is. --- CHANGELOG.md | 9 +++ crosslink/build.rs | 78 ++++++++++++++++++++++++ crosslink/src/commands/container.rs | 33 ++++++---- crosslink/src/commands/kickoff/launch.rs | 38 +++++++++++- crosslink/src/commands/kickoff/mod.rs | 4 +- crosslink/src/commands/kickoff/tests.rs | 39 ++++++++++++ crosslink/src/commands/kickoff/types.rs | 44 ++++++++++++- crosslink/src/main.rs | 12 ++-- docs_src/guides/container-agents.qmd | 16 +++-- 9 files changed, 249 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73cd86f76..bea236156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- Container commands default to the agent image that matches the crosslink + build: released versions use `ghcr.io/corvidae-coding-projects/crosslink-agent:` + and development builds use `:nightly`, replacing the `:latest` default that + could lag behind or run ahead of the CLI (gh#125). `:nightly` and `:latest` + are re-pulled on every launch. `crosslink container start` accepts `--image` + and honours `CROSSLINK_CONTAINER_IMAGE`, and swarm, sentinel and the auth + commands share the same default. Release builds can set + `CROSSLINK_AGENT_IMAGE_TAG` at build time to choose the tag explicitly. + - `crosslink container auth login|status|refresh|logout --provider github` keeps a GitHub CLI login in a per-user docker volume so container agents can publish hub refs over HTTPS (gh#109). Kickoff and `container start` diff --git a/crosslink/build.rs b/crosslink/build.rs index 0344de89a..bcd06dc3a 100644 --- a/crosslink/build.rs +++ b/crosslink/build.rs @@ -5,6 +5,12 @@ use std::path::Path; fn main() { println!("cargo:rerun-if-changed=../.git/HEAD"); println!("cargo:rerun-if-changed=../.git/refs/"); + println!("cargo:rerun-if-changed=../.git/packed-refs"); + println!("cargo:rerun-if-env-changed={AGENT_IMAGE_TAG_ENV}"); + println!( + "cargo:rustc-env={AGENT_IMAGE_TAG_ENV}={}", + agent_image_tag(env!("CARGO_PKG_VERSION")) + ); if let Ok(output) = std::process::Command::new("git") .args(["rev-parse", "--short", "HEAD"]) .output() @@ -82,6 +88,78 @@ the design; GH #429 tracks the broader feature.

} } +/// Build-time override for the agent image tag, and the compile-time variable +/// that carries the chosen tag into the binary. +const AGENT_IMAGE_TAG_ENV: &str = "CROSSLINK_AGENT_IMAGE_TAG"; + +/// The agent image tag this build defaults to, so the container runs the same +/// crosslink as the host CLI that launches it: +/// +/// - an explicit `CROSSLINK_AGENT_IMAGE_TAG` at build time wins; +/// - a build from crosslink's own git checkout uses the release tag +/// `` when HEAD is exactly `v` with a clean tree, and +/// `nightly` (the image built from `develop`) otherwise; +/// - any other build (a crates.io source install, a vendored copy) is a +/// released version and uses ``. +fn agent_image_tag(version: &str) -> String { + if let Ok(tag) = std::env::var(AGENT_IMAGE_TAG_ENV) { + let tag = tag.trim(); + let valid = !tag.is_empty() + && tag.len() <= 128 + && tag + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')); + if valid { + return tag.to_string(); + } + println!("cargo:warning=ignoring invalid {AGENT_IMAGE_TAG_ENV}={tag:?}"); + } + if !in_own_git_checkout() { + return version.to_string(); + } + let git = |args: &[&str]| { + std::process::Command::new("git") + .args(args) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) + }; + let at_release_tag = git(&["tag", "--points-at", "HEAD"]) + .is_some_and(|tags| tags.lines().any(|tag| tag == format!("v{version}"))); + let clean = git(&["status", "--porcelain"]).is_some_and(|status| status.is_empty()); + if at_release_tag && clean { + version.to_string() + } else { + "nightly".to_string() + } +} + +/// Whether this build runs inside crosslink's own repository, rather than a +/// source tree that merely sits inside some other git repository. +fn in_own_git_checkout() -> bool { + let Ok(output) = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + else { + return false; + }; + if !output.status.success() { + return false; + } + let toplevel = Path::new(String::from_utf8_lossy(&output.stdout).trim()).join("crosslink"); + let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") else { + return false; + }; + match ( + fs::canonicalize(&toplevel), + fs::canonicalize(Path::new(&manifest_dir)), + ) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + fn generate_commands_file(commands_dir: &Path) -> Result<(), Box> { let mut cmd_entries: Vec<(String, String)> = Vec::new(); diff --git a/crosslink/src/commands/container.rs b/crosslink/src/commands/container.rs index afd5043fd..fdf4e7ccb 100644 --- a/crosslink/src/commands/container.rs +++ b/crosslink/src/commands/container.rs @@ -24,6 +24,7 @@ pub fn run(command: ContainerCommands) -> Result<()> { prompt, issue, memory, + image, } => { let path = PathBuf::from(&worktree); start( @@ -32,6 +33,7 @@ pub fn run(command: ContainerCommands) -> Result<()> { prompt.as_deref(), issue, memory.as_deref(), + image.as_deref(), ) } ContainerCommands::Ps => ps(), @@ -379,13 +381,15 @@ pub(crate) fn github_login_preflight( } } -/// Environment override for the image that hosts container account login. -/// Forks publish the agent image under their own registry path; the login volume -/// is image-independent, so any image carrying the provider CLI will do. +/// Environment override for the image that `container start` and container +/// account login run. Forks publish the agent image under their own registry +/// path; the login volume is image-independent, so any image carrying the +/// provider CLI will do. const AUTH_IMAGE_ENV: &str = "CROSSLINK_CONTAINER_IMAGE"; -/// Pick the login image: an explicit `--image` wins, then the environment -/// value, then the published default. Blank values count as unset. +/// Pick the image: an explicit `--image` wins, then the environment value, +/// then this build's default (the image matching the CLI's own version). +/// Blank values count as unset. fn resolve_auth_image(explicit: Option<&str>, env_value: Option<&str>) -> String { let non_empty = |value: Option<&str>| { value @@ -395,7 +399,7 @@ fn resolve_auth_image(explicit: Option<&str>, env_value: Option<&str>) -> String }; non_empty(explicit) .or_else(|| non_empty(env_value)) - .unwrap_or_else(|| format!("{IMAGE_NAME}:{IMAGE_TAG}")) + .unwrap_or_else(|| DEFAULT_IMAGE.to_string()) } /// `resolve_auth_image` over the live `CROSSLINK_CONTAINER_IMAGE` value. @@ -416,7 +420,7 @@ fn run_auth_container(provider: &str, status: bool, image: Option<&str>) -> Resu let parsed_provider = provider.parse::()?; let volume = parsed_provider.volume(); let image = auth_image(image)?; - let image_hint = if image == format!("{IMAGE_NAME}:{IMAGE_TAG}") { + let image_hint = if image == DEFAULT_IMAGE { String::new() } else { format!(" --image {image}") @@ -440,6 +444,7 @@ fn run_auth_container(provider: &str, status: bool, image: Option<&str>) -> Resu if let Some(agent_provider) = parsed_provider.agent_provider_name() { command.args(["-e", &format!("CROSSLINK_AGENT_PROVIDER={agent_provider}")]); } + command.args(crate::commands::kickoff::image_pull_args(&image)); command.arg(&image); command.args(parsed_provider.command(status)); if status { @@ -529,9 +534,13 @@ pub(crate) fn github_login_args( ]) } -const IMAGE_NAME: &str = "ghcr.io/corvidae-coding-projects/crosslink-agent"; +const IMAGE_NAME: &str = crate::commands::kickoff::AGENT_IMAGE_REPOSITORY; + +const IMAGE_TAG: &str = crate::commands::kickoff::AGENT_IMAGE_TAG; -const IMAGE_TAG: &str = "latest"; +/// This build's default image: the published image matching the CLI's own +/// version (`:` for releases, `:nightly` for development builds). +const DEFAULT_IMAGE: &str = crate::commands::kickoff::DEFAULT_AGENT_IMAGE; const BUILD_DEFAULT_TAG: &str = "local"; const CONTAINER_PREFIX: &str = "crosslink-task-"; @@ -764,10 +773,12 @@ pub fn start( prompt_file: Option<&str>, issue_id: Option, memory: Option<&str>, + image: Option<&str>, ) -> Result<()> { if !docker_available() { bail!("Docker is not available. Install Docker and ensure the daemon is running."); } + let image = auth_image(image)?; check_staleness(); @@ -845,13 +856,12 @@ pub fn start( let agent_id = format!("container--{worktree_slug}"); - let image = format!("{IMAGE_NAME}:{IMAGE_TAG}"); - println!("Starting task container: {container_name}"); println!(" Worktree: {}", worktree_abs.display()); println!(" Memory: {memory_limit}"); println!(" Agent: {agent_id}"); println!(" Provider: {}", resolved.provider); + println!(" Image: {image}"); let mut cmd = Command::new("docker"); cmd.args(["run", "-d"]); @@ -952,6 +962,7 @@ pub fn start( } } + cmd.args(crate::commands::kickoff::image_pull_args(&image)); cmd.arg(&image); let workspace_arg = crate::utils::shell_escape_arg(&container_workspace.to_string_lossy()); let runtime_dir = crate::utils::shell_escape_arg( diff --git a/crosslink/src/commands/kickoff/launch.rs b/crosslink/src/commands/kickoff/launch.rs index 1f7e188fc..ee301ef67 100644 --- a/crosslink/src/commands/kickoff/launch.rs +++ b/crosslink/src/commands/kickoff/launch.rs @@ -968,6 +968,7 @@ pub(super) fn launch_container( Path::new("/workspaces/repo"), policy, )?; + args.extend(super::types::image_pull_args(image)); args.push(image.to_string()); args.push("bash".to_string()); args.push("-c".to_string()); @@ -1092,12 +1093,22 @@ fn format_container_launch_error(runtime_cmd: &str, image: &str, stderr: &str) - .any(|needle| lowered.contains(needle)); if pull_failure { + let default_note = if image == super::types::DEFAULT_AGENT_IMAGE { + format!( + "\n This is the image matching this crosslink build (tag `{}`). A release's \ + image is published when its `v*` tag is pushed; development builds use `:nightly`.", + super::types::AGENT_IMAGE_TAG + ) + } else { + String::new() + }; format!( "{runtime_cmd} container launch failed: {trimmed}\n\n\ - Hint: the image `{image}` could not be pulled. Either:\n \ + Hint: the image `{image}` could not be pulled.{default_note} Either:\n \ * Build it locally: just build-image (tags as :local)\n \ * Or pick a published tag from {AGENT_IMAGE_PACKAGE_URL}\n \ - and pass it via `--image ghcr.io/corvidae-coding-projects/crosslink-agent:`." + and pass it via `--image {}:`.", + super::types::AGENT_IMAGE_REPOSITORY ) } else { format!("{runtime_cmd} container launch failed: {trimmed}") @@ -1197,6 +1208,29 @@ mod tests { assert!(msg.contains("just build-image")); } + #[test] + fn pull_failure_on_the_default_image_explains_the_build_matched_tag() { + let msg = format_container_launch_error( + "docker", + crate::commands::kickoff::DEFAULT_AGENT_IMAGE, + "Error response from daemon: manifest unknown", + ); + assert!( + msg.contains(crate::commands::kickoff::DEFAULT_AGENT_IMAGE), + "{msg}" + ); + assert!(msg.contains("matching this crosslink build"), "{msg}"); + let custom = format_container_launch_error( + "docker", + "example/agent:1", + "Error response from daemon: manifest unknown", + ); + assert!( + !custom.contains("matching this crosslink build"), + "{custom}" + ); + } + #[test] fn pull_failure_no_such_image_yields_hint() { let stderr = diff --git a/crosslink/src/commands/kickoff/mod.rs b/crosslink/src/commands/kickoff/mod.rs index 82d5c04d7..43720e4bf 100644 --- a/crosslink/src/commands/kickoff/mod.rs +++ b/crosslink/src/commands/kickoff/mod.rs @@ -14,8 +14,8 @@ mod wizard; mod tests; pub use types::{ - ContainerMode, KickoffOpts, KickoffReport, PlanOpts, ReportFormat, VerifyLevel, - DEFAULT_AGENT_IMAGE, EFFORT_LEVELS, + image_pull_args, ContainerMode, KickoffOpts, KickoffReport, PlanOpts, ReportFormat, + VerifyLevel, AGENT_IMAGE_REPOSITORY, AGENT_IMAGE_TAG, DEFAULT_AGENT_IMAGE, EFFORT_LEVELS, }; pub use types::{parse_container_mode, parse_duration, parse_verify_level}; diff --git a/crosslink/src/commands/kickoff/tests.rs b/crosslink/src/commands/kickoff/tests.rs index edab7180f..967bfb00a 100644 --- a/crosslink/src/commands/kickoff/tests.rs +++ b/crosslink/src/commands/kickoff/tests.rs @@ -3392,3 +3392,42 @@ fn test_resolve_kickoff_template_none_without_flag_or_config() { None ); } + +#[test] +fn default_agent_image_matches_this_build() { + assert_eq!( + DEFAULT_AGENT_IMAGE, + format!("{AGENT_IMAGE_REPOSITORY}:{AGENT_IMAGE_TAG}") + ); + assert!( + AGENT_IMAGE_TAG == "nightly" + || AGENT_IMAGE_TAG == env!("CARGO_PKG_VERSION") + || std::env::var("CROSSLINK_AGENT_IMAGE_TAG").is_ok(), + "default tag must be nightly or this build's version, got {AGENT_IMAGE_TAG}" + ); + assert_ne!( + AGENT_IMAGE_TAG, "latest", + "the default must never float to :latest" + ); +} + +#[test] +fn floating_image_tags_are_always_pulled_and_pinned_ones_are_not() { + let repo = AGENT_IMAGE_REPOSITORY; + for floating in [ + format!("{repo}:nightly"), + format!("{repo}:latest"), + repo.to_string(), + "localhost:5000/crosslink-agent".to_string(), + ] { + assert_eq!(image_pull_args(&floating), ["--pull=always"], "{floating}"); + } + for pinned in [ + format!("{repo}:0.10.0"), + format!("{repo}:nightly-388bed8"), + format!("{repo}@sha256:{}", "a".repeat(64)), + "localhost:5000/crosslink-agent:local".to_string(), + ] { + assert!(image_pull_args(&pinned).is_empty(), "{pinned}"); + } +} diff --git a/crosslink/src/commands/kickoff/types.rs b/crosslink/src/commands/kickoff/types.rs index 20e0a93c9..1448147f2 100644 --- a/crosslink/src/commands/kickoff/types.rs +++ b/crosslink/src/commands/kickoff/types.rs @@ -3,7 +3,49 @@ use serde::{Deserialize, Serialize}; use std::path::Path; use std::time::Duration; -pub const DEFAULT_AGENT_IMAGE: &str = "ghcr.io/corvidae-coding-projects/crosslink-agent:latest"; +/// The published agent image repository. +macro_rules! agent_image_repository { + () => { + "ghcr.io/corvidae-coding-projects/crosslink-agent" + }; +} + +/// The published agent image repository, without a tag. +pub const AGENT_IMAGE_REPOSITORY: &str = agent_image_repository!(); + +/// The tag this build defaults to, chosen at build time (see `build.rs`): the +/// release version for release builds, `nightly` for development builds, so +/// the container runs the same crosslink as the CLI that launches it. +pub const AGENT_IMAGE_TAG: &str = env!("CROSSLINK_AGENT_IMAGE_TAG"); + +/// The image kickoff, swarm, sentinel and `container` commands use unless an +/// image is given explicitly. +pub const DEFAULT_AGENT_IMAGE: &str = concat!( + agent_image_repository!(), + ":", + env!("CROSSLINK_AGENT_IMAGE_TAG") +); + +/// Tags that move to a new build without a version change. A local copy of +/// one of these is re-pulled on every launch, so it cannot go stale. +const FLOATING_IMAGE_TAGS: [&str; 2] = ["nightly", "latest"]; + +/// Container-runtime arguments that keep a floating tag current: `--pull=always` +/// for `:nightly` and `:latest`, nothing for a pinned tag or a digest. +pub fn image_pull_args(image: &str) -> Vec { + if image.contains('@') { + return Vec::new(); + } + let tag = image + .rsplit_once(':') + .filter(|(_, tag)| !tag.contains('/')) + .map_or("latest", |(_, tag)| tag); + if FLOATING_IMAGE_TAGS.contains(&tag) { + vec!["--pull=always".to_string()] + } else { + Vec::new() + } +} pub const EFFORT_LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"]; diff --git a/crosslink/src/main.rs b/crosslink/src/main.rs index 5f9478853..c162d6c1f 100644 --- a/crosslink/src/main.rs +++ b/crosslink/src/main.rs @@ -859,6 +859,11 @@ enum ContainerCommands { #[arg(long)] memory: Option, + + /// Agent image (default: env `CROSSLINK_CONTAINER_IMAGE`, then the + /// published image matching this crosslink build) + #[arg(long)] + image: Option, }, Ps, @@ -1435,10 +1440,9 @@ enum KickoffCommands { #[arg(long, default_value = "standard")] model: String, - #[arg( - long, - default_value = "ghcr.io/corvidae-coding-projects/crosslink-agent:latest" - )] + /// Agent image (default: the published image matching this crosslink + /// build — `:` for releases, `:nightly` for development builds) + #[arg(long, default_value = commands::kickoff::DEFAULT_AGENT_IMAGE)] image: String, #[arg(long, default_value = "1h")] diff --git a/docs_src/guides/container-agents.qmd b/docs_src/guides/container-agents.qmd index d868af6cf..375b3df22 100644 --- a/docs_src/guides/container-agents.qmd +++ b/docs_src/guides/container-agents.qmd @@ -74,12 +74,20 @@ The default image used by `--container docker|podman` is published to GitHub Con [`ghcr.io/corvidae-coding-projects/crosslink-agent`](https://github.com/Corvidae-Coding-Projects/crosslink/pkgs/container/crosslink-agent), built for both `linux/amd64` and `linux/arm64`. Published tags: +- `:` (e.g. `:0.10.0`) — the image built for that release - `:latest` — the most recent tagged release -- `:` (e.g. `:0.5.2`) — pinned release - `:nightly` — floating tag tracking `develop` -- `:nightly-` — immutable nightly snapshots - -The first `--container docker` launch will pull the image automatically; subsequent launches reuse the local copy. Pin `--image ghcr.io/corvidae-coding-projects/crosslink-agent:` for reproducible runs. +- `:nightly-` — the nightly built from that `develop` commit + +By default, crosslink uses the image that matches its own build, so the +crosslink inside the container is the same as the one that launches it: a +released crosslink (installed from crates.io or built at its `v` tag) +uses `:`, and a development build uses `:nightly`. `:nightly` and +`:latest` are re-pulled on every launch so a cached copy never goes stale; +pinned tags are pulled once and then reused. Pass `--image` (or set +`CROSSLINK_CONTAINER_IMAGE` for `container start` and `container auth`) to use +another image. Registry tags can be moved; for a byte-for-byte reproducible +run, pin the image by digest (`--image ghcr.io/corvidae-coding-projects/crosslink-agent@sha256:`). ### Building the image locally From 5f435beddbc5251dc1a52b03f9325aea6930a9d6 Mon Sep 17 00:00:00 2001 From: magnificentlycursed <190320250+magnificentlycursed@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:05:12 -0700 Subject: [PATCH 02/15] ci(release): gate release images, verify anonymous pulls, and move :latest only for verified stable releases A `v*` tag pushed both `:` and `:latest` before the smoke test ran, moved `:latest` for prereleases, and the smoke test logged in before pulling, so neither a private package nor a missing tag could fail it. container-image.yml now: - checks the tag against crosslink/Cargo.toml before anything is pushed; - pushes only `:` on a tag push; - runs the smoke test without logging in, first checking that every pushed tag resolves anonymously; - promotes `:latest` afterwards in a separate job, by the verified digest, and only for non-prerelease versions (re-pointing it to an older digest is the rollback); - refuses manual publishes from anything but develop, so a dispatch can no longer overwrite `:nightly` from a feature branch or rebuild an old tag. Release builds of the binaries (release-builds.yml and the image's own binaries) set CROSSLINK_AGENT_IMAGE_TAG to the tag's version, passed through cross, so their default image is the release image even from a shallow checkout without tags. A new daily image-canary workflow checks, anonymously, that `:nightly`, the newest release's `:` (and `:latest` when it is stable), and the frozen ghcr.io/forecast-bio/crosslink-agent:latest that 0.9.0-beta.1 builds default to can still be pulled. crates.io publishing is unchanged here; its ownership and credential are tracked separately. --- .github/workflows/container-image.yml | 100 +++++++++++++++++++++++--- .github/workflows/image-canary.yml | 68 ++++++++++++++++++ .github/workflows/release-builds.yml | 5 ++ CHANGELOG.md | 9 +++ crosslink/Cross.toml | 4 ++ docs_src/guides/container-agents.qmd | 3 +- 6 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/image-canary.yml diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml index 2e827759d..a973d326c 100644 --- a/.github/workflows/container-image.yml +++ b/.github/workflows/container-image.yml @@ -107,6 +107,10 @@ jobs: crosslink/target/ key: ${{ runner.os }}-cargo-container-${{ matrix.target }}-${{ hashFiles('crosslink/Cargo.lock') }} + - name: Pin the default agent image tag (release tags only) + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + run: echo "CROSSLINK_AGENT_IMAGE_TAG=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + - name: Build release binary (native) if: matrix.use_cross == false run: cargo build --locked --release --target ${{ matrix.target }} @@ -134,6 +138,11 @@ jobs: primary_tag: ${{ steps.tags.outputs.primary_tag }} version: ${{ steps.tags.outputs.version }} pushed: ${{ steps.tags.outputs.pushed }} + tags: ${{ steps.tags.outputs.tags_line }} + base: ${{ steps.tags.outputs.base }} + release: ${{ steps.tags.outputs.release }} + prerelease: ${{ steps.tags.outputs.prerelease }} + digest: ${{ steps.build.outputs.digest }} steps: - uses: actions/checkout@v4 @@ -170,23 +179,39 @@ jobs: PUSH=false PRIMARY="" VERSION="" + RELEASE=false + PRERELEASE=false if [ "$EVENT" = "pull_request" ]; then TAGS=("${BASE}:pr-${{ github.event.pull_request.number }}") PRIMARY="${BASE}:pr-${{ github.event.pull_request.number }}" VERSION="pr-${{ github.event.pull_request.number }}" - elif [[ "$REF" == refs/tags/v* ]]; then + elif [ "$EVENT" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then VERSION="${REF#refs/tags/v}" - TAGS=("${BASE}:${VERSION}" "${BASE}:latest") + CARGO_VERSION="$(sed -n 's/^version = "\(.*\)"$/\1/p' crosslink/Cargo.toml | head -n 1)" + if [ "$VERSION" != "$CARGO_VERSION" ]; then + echo "::error::Tag v${VERSION} does not match crosslink/Cargo.toml version ${CARGO_VERSION}; nothing was published" + exit 1 + fi + # Only the versioned tag is pushed here. :latest moves later, by + # digest, after the anonymous smoke test passes, and never for a + # prerelease (promote-latest). + TAGS=("${BASE}:${VERSION}") PRIMARY="${BASE}:${VERSION}" PUSH=true + RELEASE=true + case "$VERSION" in *-*) PRERELEASE=true ;; esac elif [ "$REF" = "refs/heads/develop" ]; then VERSION="nightly-${SHORT_SHA}" TAGS=("${BASE}:nightly" "${BASE}:nightly-${SHORT_SHA}") PRIMARY="${BASE}:nightly" PUSH=true elif [ "$EVENT" = "workflow_dispatch" ]; then + if [ "$REF" != "refs/heads/develop" ]; then + echo "::error::Manual image publishes run only from develop (got ${REF}); releases publish from their v* tag push" + exit 1 + fi VERSION="manual-${SHORT_SHA}" TAGS=("${BASE}:nightly" "${BASE}:manual-${SHORT_SHA}") PRIMARY="${BASE}:nightly" @@ -205,6 +230,10 @@ jobs: echo "version=${VERSION}" echo "pushed=${PUSH}" echo "cache_ref=${BASE}:buildcache" + echo "tags_line=${TAGS[*]}" + echo "base=${BASE}" + echo "release=${RELEASE}" + echo "prerelease=${PRERELEASE}" } >> "$GITHUB_OUTPUT" - name: Set up QEMU @@ -224,6 +253,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push + id: build uses: docker/build-push-action@v6 with: context: crosslink/resources/container @@ -331,12 +361,33 @@ jobs: - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + # No login: the smoke test pulls exactly as a user does, so a private + # package or a tag that did not land fails here instead of in the field. + - name: Every pushed tag is pullable anonymously + if: matrix.platform == 'linux/amd64' + env: + BASE: ${{ needs.publish-image.outputs.base }} + TAGS: ${{ needs.publish-image.outputs.tags }} + run: | + set -euo pipefail + repo="${BASE#"${REGISTRY}"/}" + token="$(curl -fsS "https://${REGISTRY}/token?scope=repository:${repo}:pull" | jq -r '.token // empty')" + if [ -z "$token" ]; then + echo "::error::${BASE} refuses anonymous pulls; make the package public in its package settings" + exit 1 + fi + for ref in $TAGS; do + tag="${ref##*:}" + code="$(curl -sS -o /dev/null -w '%{http_code}' -I \ + -H "Authorization: Bearer ${token}" \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ + "https://${REGISTRY}/v2/${repo}/manifests/${tag}")" + if [ "$code" != 200 ]; then + echo "::error::${ref} is not pullable anonymously (HTTP ${code})" + exit 1 + fi + echo "anonymous pull OK: ${ref}" + done - name: Pull image run: docker pull --platform ${{ matrix.platform }} ${{ needs.publish-image.outputs.primary_tag }} @@ -377,3 +428,36 @@ jobs: exit 1 fi echo "Version match: $EXPECTED" + + # :latest is the most recent stable release. It moves only after the + # versioned image has passed the anonymous smoke test on both platforms, by + # digest, so it always names an image that was verified, and never for a + # prerelease. Re-pointing it to an older version's digest is the rollback. + promote-latest: + name: Promote :latest (stable releases only) + needs: [publish-image, smoke-verify] + if: >- + needs.publish-image.outputs.release == 'true' && + needs.publish-image.outputs.prerelease == 'false' + runs-on: ubuntu-latest + steps: + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Point :latest at the verified digest + env: + BASE: ${{ needs.publish-image.outputs.base }} + DIGEST: ${{ needs.publish-image.outputs.digest }} + run: | + set -euo pipefail + test -n "$DIGEST" + docker buildx imagetools create --tag "${BASE}:latest" "${BASE}@${DIGEST}" + docker buildx imagetools inspect "${BASE}:latest" --format '{{json .Manifest.Digest}}' | grep -F "$DIGEST" + diff --git a/.github/workflows/image-canary.yml b/.github/workflows/image-canary.yml new file mode 100644 index 000000000..d5f5a246e --- /dev/null +++ b/.github/workflows/image-canary.yml @@ -0,0 +1,68 @@ +name: Agent Image Canary + +# Checks, as an anonymous user would, that the agent images released crosslink +# builds default to can still be pulled. A private package, a missing tag or a +# deleted old-owner image fails here instead of in users' kickoffs. + +on: + schedule: + - cron: '17 6 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + canary: + name: Default images are pullable + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Collect the image references released builds use + id: refs + run: | + set -euo pipefail + owner="$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" + refs="ghcr.io/${owner}/crosslink-agent:nightly" + # Builds released before the build-matched default (v0.9.0-beta.1 + # and earlier) pull the frozen forecast-bio image; keep it alive. + refs="${refs} ghcr.io/forecast-bio/crosslink-agent:latest" + # Newer releases default to their own version tag. + newest="$(git tag --list 'v*' --sort=-version:refname | head -n 1)" + if [ -n "$newest" ] && [ "$(printf '%s\n%s\n' v0.9.0-beta.1 "$newest" | sort -V | tail -n 1)" = "$newest" ] && [ "$newest" != v0.9.0-beta.1 ]; then + refs="${refs} ghcr.io/${owner}/crosslink-agent:${newest#v}" + case "$newest" in *-*) ;; *) refs="${refs} ghcr.io/${owner}/crosslink-agent:latest" ;; esac + fi + echo "refs=${refs}" >> "$GITHUB_OUTPUT" + + - name: Pull each reference anonymously + env: + REFS: ${{ steps.refs.outputs.refs }} + run: | + set -euo pipefail + failed=0 + for ref in $REFS; do + image="${ref#ghcr.io/}" + repo="${image%:*}" + tag="${image##*:}" + token="$(curl -fsS "https://ghcr.io/token?scope=repository:${repo}:pull" | jq -r '.token // empty' || true)" + if [ -z "$token" ]; then + echo "::error::${ref}: anonymous pull refused (package private or missing)" + failed=1 + continue + fi + code="$(curl -sS -o /dev/null -w '%{http_code}' -I \ + -H "Authorization: Bearer ${token}" \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \ + "https://ghcr.io/v2/${repo}/manifests/${tag}")" + if [ "$code" = 200 ]; then + echo "OK ${ref}" + else + echo "::error::${ref}: manifest HTTP ${code}" + failed=1 + fi + done + exit "$failed" diff --git a/.github/workflows/release-builds.yml b/.github/workflows/release-builds.yml index dc2ff311b..d0d7efacb 100644 --- a/.github/workflows/release-builds.yml +++ b/.github/workflows/release-builds.yml @@ -102,6 +102,11 @@ jobs: with: targets: ${{ matrix.target }} + - name: Pin the default agent image tag (release tags only) + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + shell: bash + run: echo "CROSSLINK_AGENT_IMAGE_TAG=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + - name: Build release run: cargo build --locked --release --target ${{ matrix.target }} diff --git a/CHANGELOG.md b/CHANGELOG.md index bea236156..5ea331c13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). and honours `CROSSLINK_CONTAINER_IMAGE`, and swarm, sentinel and the auth commands share the same default. Release builds can set `CROSSLINK_AGENT_IMAGE_TAG` at build time to choose the tag explicitly. +- Release image publishing is gated (gh#126, gh#122). A `v*` tag must match + `crosslink/Cargo.toml` before anything is pushed; the tag push publishes only + `:`; the smoke test pulls every pushed tag anonymously, so a private + package or a missing tag fails CI; and `:latest` moves afterwards, by digest, + for stable releases only. Manual image publishes run only from `develop`. + Release binaries bake their version in as the default agent image tag, and a + daily canary checks that the images released builds default to, including + the frozen `ghcr.io/forecast-bio/crosslink-agent:latest` that + 0.9.0-beta.1 uses, can still be pulled anonymously. - `crosslink container auth login|status|refresh|logout --provider github` keeps a GitHub CLI login in a per-user docker volume so container agents diff --git a/crosslink/Cross.toml b/crosslink/Cross.toml index 90d54ec6d..9d7eb0d1e 100644 --- a/crosslink/Cross.toml +++ b/crosslink/Cross.toml @@ -1,3 +1,7 @@ +[build.env] +# The default agent image tag release builds bake in (see build.rs). +passthrough = ["CROSSLINK_AGENT_IMAGE_TAG"] + diff --git a/docs_src/guides/container-agents.qmd b/docs_src/guides/container-agents.qmd index 375b3df22..0666eacad 100644 --- a/docs_src/guides/container-agents.qmd +++ b/docs_src/guides/container-agents.qmd @@ -75,7 +75,8 @@ The default image used by `--container docker|podman` is published to GitHub Con built for both `linux/amd64` and `linux/arm64`. Published tags: - `:` (e.g. `:0.10.0`) — the image built for that release -- `:latest` — the most recent tagged release +- `:latest` — the most recent stable (non-prerelease) release; it moves only after + that release's image has passed an anonymous pull and smoke test - `:nightly` — floating tag tracking `develop` - `:nightly-` — the nightly built from that `develop` commit From 643c0ca1c49bbfa81f5652bca0012c1b3b128064 Mon Sep 17 00:00:00 2001 From: magnificentlycursed <190320250+magnificentlycursed@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:26:51 -0700 Subject: [PATCH 03/15] fix(build): watch .git/packed-refs only when it exists Cargo treats a rerun-if-changed path that does not exist as changed, so on a checkout without packed-refs (the shallow checkouts CI uses) the build script reran and the binary was rebuilt on every cargo invocation. On Windows the readiness tests then failed: the second test group's rebuild could not replace crosslink.exe while a daemon from the first group still had it open ("Access is denied"). --- crosslink/build.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crosslink/build.rs b/crosslink/build.rs index bcd06dc3a..aefc513e0 100644 --- a/crosslink/build.rs +++ b/crosslink/build.rs @@ -5,7 +5,12 @@ use std::path::Path; fn main() { println!("cargo:rerun-if-changed=../.git/HEAD"); println!("cargo:rerun-if-changed=../.git/refs/"); - println!("cargo:rerun-if-changed=../.git/packed-refs"); + // Cargo treats a watched path that does not exist as changed, which would + // rerun this script and rebuild the binary on every invocation; shallow CI + // checkouts have no packed-refs. Watch it only when it is there. + if Path::new("../.git/packed-refs").exists() { + println!("cargo:rerun-if-changed=../.git/packed-refs"); + } println!("cargo:rerun-if-env-changed={AGENT_IMAGE_TAG_ENV}"); println!( "cargo:rustc-env={AGENT_IMAGE_TAG_ENV}={}", From 9a326eacf4c6ff992200eaccdcba3bea21e5d2b0 Mon Sep 17 00:00:00 2001 From: magnificentlycursed <190320250+magnificentlycursed@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:54:53 -0700 Subject: [PATCH 04/15] fix(container): choose the default image only from positive evidence, fail on bad overrides, and keep offline launches working From the review of this PR: - A build counts as a release, and defaults to `:`, only on positive evidence: a published package (`.cargo_vcs_info.json`), a checkout of crosslink at its `v` tag, or an explicit CROSSLINK_AGENT_IMAGE_TAG. A build with no git information is no longer guessed to be a release; it defaults to `:nightly`. The clean-tree condition is dropped: watching the index to keep it current would rebuild on git's own index refreshes. - An explicit CROSSLINK_AGENT_IMAGE_TAG must match the OCI tag grammar and must not be `latest` or `local`; otherwise the build fails instead of falling back silently. CROSSLINK_AGENT_IMAGE_REPOSITORY lets fork builds default to their own registry, validated the same way. - The build script watches the HEAD, refs and packed-refs that git reports for the checkout (so linked worktrees work), and only paths that exist. - The rules live in src/build_support/agent_image.rs, included by build.rs and table-tested from the kickoff tests. - `--pull=always` made every launch of a floating tag fail without network access, even with the image cached. Floating tags are now refreshed with an explicit pull first; if that fails and a local copy exists, the launch warns and uses it, and otherwise the error carries the runtime's reason. - The container guide states what development builds actually run, that `:nightly` follows develop, and that `cargo install --git` needs `--image`. --- CHANGELOG.md | 14 +- crosslink/build.rs | 175 ++++++++++++--------- crosslink/src/build_support/agent_image.rs | 99 ++++++++++++ crosslink/src/commands/container.rs | 114 ++++++++++++-- crosslink/src/commands/kickoff/launch.rs | 2 +- crosslink/src/commands/kickoff/mod.rs | 2 +- crosslink/src/commands/kickoff/tests.rs | 130 ++++++++++++--- crosslink/src/commands/kickoff/types.rs | 43 +++-- docs_src/guides/container-agents.qmd | 28 +++- 9 files changed, 462 insertions(+), 145 deletions(-) create mode 100644 crosslink/src/build_support/agent_image.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ea331c13..ef03b5818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Container commands default to the agent image that matches the crosslink build: released versions use `ghcr.io/corvidae-coding-projects/crosslink-agent:` and development builds use `:nightly`, replacing the `:latest` default that - could lag behind or run ahead of the CLI (gh#125). `:nightly` and `:latest` - are re-pulled on every launch. `crosslink container start` accepts `--image` - and honours `CROSSLINK_CONTAINER_IMAGE`, and swarm, sentinel and the auth - commands share the same default. Release builds can set - `CROSSLINK_AGENT_IMAGE_TAG` at build time to choose the tag explicitly. + could lag behind or run ahead of the CLI (gh#125). A build counts as a + release only on positive evidence: a published package, a checkout at its + `v` tag, or an explicit `CROSSLINK_AGENT_IMAGE_TAG`, which must be a + valid tag or the build fails. `CROSSLINK_AGENT_IMAGE_REPOSITORY` lets fork + builds default to their own registry. `:nightly` and `:latest` are refreshed + before every launch, falling back to a local copy with a warning when the + registry is unreachable. `crosslink container start` accepts `--image` and + honours `CROSSLINK_CONTAINER_IMAGE`, and swarm, sentinel and the auth commands + share the same default. - Release image publishing is gated (gh#126, gh#122). A `v*` tag must match `crosslink/Cargo.toml` before anything is pushed; the tag push publishes only `:`; the smoke test pulls every pushed tag anonymously, so a private diff --git a/crosslink/build.rs b/crosslink/build.rs index aefc513e0..5ee6136ba 100644 --- a/crosslink/build.rs +++ b/crosslink/build.rs @@ -3,19 +3,8 @@ use std::io::Write; use std::path::Path; fn main() { - println!("cargo:rerun-if-changed=../.git/HEAD"); - println!("cargo:rerun-if-changed=../.git/refs/"); - // Cargo treats a watched path that does not exist as changed, which would - // rerun this script and rebuild the binary on every invocation; shallow CI - // checkouts have no packed-refs. Watch it only when it is there. - if Path::new("../.git/packed-refs").exists() { - println!("cargo:rerun-if-changed=../.git/packed-refs"); - } - println!("cargo:rerun-if-env-changed={AGENT_IMAGE_TAG_ENV}"); - println!( - "cargo:rustc-env={AGENT_IMAGE_TAG_ENV}={}", - agent_image_tag(env!("CARGO_PKG_VERSION")) - ); + emit_agent_image(); + if let Ok(output) = std::process::Command::new("git") .args(["rev-parse", "--short", "HEAD"]) .output() @@ -93,76 +82,116 @@ the design; GH #429 tracks the broader feature.

} } -/// Build-time override for the agent image tag, and the compile-time variable -/// that carries the chosen tag into the binary. +include!("src/build_support/agent_image.rs"); + +/// Build-time overrides, and the compile-time variables that carry the chosen +/// repository and tag into the binary. const AGENT_IMAGE_TAG_ENV: &str = "CROSSLINK_AGENT_IMAGE_TAG"; +const AGENT_IMAGE_REPOSITORY_ENV: &str = "CROSSLINK_AGENT_IMAGE_REPOSITORY"; -/// The agent image tag this build defaults to, so the container runs the same -/// crosslink as the host CLI that launches it: -/// -/// - an explicit `CROSSLINK_AGENT_IMAGE_TAG` at build time wins; -/// - a build from crosslink's own git checkout uses the release tag -/// `` when HEAD is exactly `v` with a clean tree, and -/// `nightly` (the image built from `develop`) otherwise; -/// - any other build (a crates.io source install, a vendored copy) is a -/// released version and uses ``. -fn agent_image_tag(version: &str) -> String { - if let Ok(tag) = std::env::var(AGENT_IMAGE_TAG_ENV) { - let tag = tag.trim(); - let valid = !tag.is_empty() - && tag.len() <= 128 - && tag - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')); - if valid { - return tag.to_string(); - } - println!("cargo:warning=ignoring invalid {AGENT_IMAGE_TAG_ENV}={tag:?}"); - } - if !in_own_git_checkout() { - return version.to_string(); - } - let git = |args: &[&str]| { - std::process::Command::new("git") - .args(args) - .output() +/// Choose the default agent image (see `src/build_support/agent_image.rs`), +/// pass it to the compiler, and tell cargo when to choose again. An invalid +/// explicit override fails the build rather than falling back silently. +fn emit_agent_image() { + println!("cargo:rerun-if-env-changed={AGENT_IMAGE_TAG_ENV}"); + println!("cargo:rerun-if-env-changed={AGENT_IMAGE_REPOSITORY_ENV}"); + let env_override = |name: &str| { + std::env::var(name) .ok() - .filter(|output| output.status.success()) - .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) }; - let at_release_tag = git(&["tag", "--points-at", "HEAD"]) - .is_some_and(|tags| tags.lines().any(|tag| tag == format!("v{version}"))); - let clean = git(&["status", "--porcelain"]).is_some_and(|status| status.is_empty()); - if at_release_tag && clean { - version.to_string() - } else { - "nightly".to_string() + let version = env!("CARGO_PKG_VERSION"); + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + let packaged = Path::new(&manifest_dir) + .join(".cargo_vcs_info.json") + .is_file(); + let git_dirs = own_checkout_git_dirs(&manifest_dir); + if let Some((git_dir, common_dir)) = &git_dirs { + // Watch only paths that exist: cargo treats a missing watched path + // as changed and would rebuild on every invocation (shallow CI + // checkouts have no packed-refs; a worktree's HEAD lives in its own + // git dir). + for path in [ + git_dir.join("HEAD"), + common_dir.join("refs"), + common_dir.join("packed-refs"), + ] { + if path.exists() { + println!("cargo:rerun-if-changed={}", path.display()); + } + } } + let release_tag = format!("v{version}"); + let release_tag_at_head = git_dirs.is_some() + && git_output(&manifest_dir, &["tag", "--points-at", "HEAD"]) + .is_some_and(|tags| tags.lines().any(|tag| tag.trim() == release_tag)); + let tag_override = env_override(AGENT_IMAGE_TAG_ENV); + let tag = choose_agent_image_tag(&AgentImageBuild { + override_tag: tag_override.as_deref(), + packaged, + own_checkout: git_dirs.is_some(), + release_tag_at_head, + version, + }) + .unwrap_or_else(|error| panic!("{error}")); + let repository = + choose_agent_image_repository(env_override(AGENT_IMAGE_REPOSITORY_ENV).as_deref()) + .unwrap_or_else(|error| panic!("{error}")); + println!("cargo:rustc-env={AGENT_IMAGE_TAG_ENV}={tag}"); + println!("cargo:rustc-env={AGENT_IMAGE_REPOSITORY_ENV}={repository}"); } -/// Whether this build runs inside crosslink's own repository, rather than a -/// source tree that merely sits inside some other git repository. -fn in_own_git_checkout() -> bool { - let Ok(output) = std::process::Command::new("git") - .args(["rev-parse", "--show-toplevel"]) +fn git_output(dir: &str, args: &[&str]) -> Option { + std::process::Command::new("git") + .current_dir(dir) + .args(args) .output() - else { - return false; - }; - if !output.status.success() { - return false; + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// The git dir and common git dir of crosslink's own checkout, when the +/// build runs inside one (not merely inside some other repository, as a +/// vendored or packaged copy might be). Works in linked worktrees, where +/// `.git` is a file. +fn own_checkout_git_dirs(manifest_dir: &str) -> Option<(std::path::PathBuf, std::path::PathBuf)> { + let toplevel = git_output(manifest_dir, &["rev-parse", "--show-toplevel"])?; + let own = fs::canonicalize(Path::new(&toplevel).join("crosslink")).ok()? + == fs::canonicalize(manifest_dir).ok()?; + if !own { + return None; } - let toplevel = Path::new(String::from_utf8_lossy(&output.stdout).trim()).join("crosslink"); - let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") else { - return false; + // `--path-format=absolute` needs git 2.31; older gits (as in some cross + // images) print paths relative to the working directory instead. + let dirs = git_output( + manifest_dir, + &[ + "rev-parse", + "--path-format=absolute", + "--git-dir", + "--git-common-dir", + ], + ) + .or_else(|| { + git_output( + manifest_dir, + &["rev-parse", "--git-dir", "--git-common-dir"], + ) + })?; + let mut lines = dirs.lines(); + let resolve = |line: &str| { + let path = Path::new(line.trim()); + if path.is_absolute() { + path.to_path_buf() + } else { + Path::new(manifest_dir).join(path) + } }; - match ( - fs::canonicalize(&toplevel), - fs::canonicalize(Path::new(&manifest_dir)), - ) { - (Ok(a), Ok(b)) => a == b, - _ => false, - } + let git_dir = resolve(lines.next()?); + let common_dir = resolve(lines.next()?); + Some((git_dir, common_dir)) } fn generate_commands_file(commands_dir: &Path) -> Result<(), Box> { diff --git a/crosslink/src/build_support/agent_image.rs b/crosslink/src/build_support/agent_image.rs new file mode 100644 index 000000000..c4ac8ceb5 --- /dev/null +++ b/crosslink/src/build_support/agent_image.rs @@ -0,0 +1,99 @@ +// How a build chooses its default agent image. Pure functions, no I/O: +// `build.rs` includes this file to make the choice, and the kickoff tests +// include it to table-test the rules. It is not a module of the crate. + +/// The published agent image repository, unless a build overrides it. +const DEFAULT_AGENT_IMAGE_REPOSITORY: &str = "ghcr.io/corvidae-coding-projects/crosslink-agent"; + +/// Tags a build default may never be: `latest` floats across releases, and +/// `local` is what `crosslink container build` writes. +const RESERVED_AGENT_IMAGE_TAGS: [&str; 2] = ["latest", "local"]; + +/// An image tag per the OCI grammar `[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}`. +fn validate_agent_image_tag(tag: &str) -> Result<(), String> { + let mut chars = tag.chars(); + let first_ok = chars + .next() + .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_'); + let rest_ok = chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')); + if !first_ok || !rest_ok || tag.len() > 128 { + return Err(format!( + "{tag:?} is not a valid image tag ([A-Za-z0-9_][A-Za-z0-9_.-]{{0,127}})" + )); + } + if RESERVED_AGENT_IMAGE_TAGS.contains(&tag) { + return Err(format!("{tag:?} cannot be a build's default image tag")); + } + Ok(()) +} + +/// An image repository: lowercase registry path components, an optional +/// registry port, no tag or digest. +fn validate_agent_image_repository(repository: &str) -> Result<(), String> { + let (host, path) = repository + .split_once('/') + .ok_or_else(|| format!("{repository:?} has no registry path"))?; + let host_ok = !host.is_empty() + && host + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-' | ':')); + let path_ok = !path.is_empty() + && path.split('/').all(|part| { + !part.is_empty() + && part.chars().all(|c| { + c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-') + }) + }); + if host_ok && path_ok { + Ok(()) + } else { + Err(format!( + "{repository:?} is not a valid image repository (lowercase registry/path, no tag)" + )) + } +} + +/// What the build knows about where it is being built. +struct AgentImageBuild<'a> { + /// `CROSSLINK_AGENT_IMAGE_TAG` at build time, if set and non-empty. + override_tag: Option<&'a str>, + /// The source is a published package (`cargo package` wrote + /// `.cargo_vcs_info.json`), as for `cargo install crosslink`. + packaged: bool, + /// The build runs inside crosslink's own git checkout. + own_checkout: bool, + /// The tag `v` points at that checkout's HEAD. + release_tag_at_head: bool, + version: &'a str, +} + +/// The default agent image tag: an explicit override (which must be valid), +/// then the version for published packages and for checkouts at their +/// release tag, and `nightly` (the image built from `develop`) for every +/// other build. A guess never yields a version: only positive evidence does. +fn choose_agent_image_tag(build: &AgentImageBuild<'_>) -> Result { + if let Some(tag) = build.override_tag { + validate_agent_image_tag(tag) + .map_err(|error| format!("CROSSLINK_AGENT_IMAGE_TAG: {error}"))?; + return Ok(tag.to_string()); + } + if build.packaged || (build.own_checkout && build.release_tag_at_head) { + validate_agent_image_tag(build.version) + .map_err(|error| format!("crate version as image tag: {error}"))?; + return Ok(build.version.to_string()); + } + Ok("nightly".to_string()) +} + +/// The default agent image repository: an explicit, valid override (for +/// forks that publish their own image) or the published one. +fn choose_agent_image_repository(override_repository: Option<&str>) -> Result { + match override_repository { + Some(repository) => { + validate_agent_image_repository(repository) + .map_err(|error| format!("CROSSLINK_AGENT_IMAGE_REPOSITORY: {error}"))?; + Ok(repository.to_string()) + } + None => Ok(DEFAULT_AGENT_IMAGE_REPOSITORY.to_string()), + } +} diff --git a/crosslink/src/commands/container.rs b/crosslink/src/commands/container.rs index fdf4e7ccb..717740dac 100644 --- a/crosslink/src/commands/container.rs +++ b/crosslink/src/commands/container.rs @@ -381,6 +381,46 @@ pub(crate) fn github_login_preflight( } } +/// Bring a floating tag (`:nightly`, `:latest`) up to date before a launch. +/// When the registry cannot be reached, an existing local copy is used with a +/// warning, so an offline launch keeps working; without a local copy the error +/// names the reason. Pinned tags and digests are left to the runtime, which +/// pulls them once. +pub(crate) fn refresh_floating_image(runtime: &str, image: &str) -> Result<()> { + if !crate::commands::kickoff::is_floating_image(image) { + return Ok(()); + } + let pull = Command::new(runtime) + .args(["pull", "--quiet", image]) + .output() + .with_context(|| format!("Failed to run {runtime} pull"))?; + if pull.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&pull.stderr); + let reason = stderr + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("no error output") + .to_string(); + let cached = Command::new(runtime) + .args(["image", "inspect", image]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + if cached { + eprintln!( + "warning: could not refresh {image} ({reason}); using the local copy, which may be out of date" + ); + return Ok(()); + } + bail!( + "could not pull {image}: {reason}\nCheck network and registry access, or pass --image with an image you have locally (`just build-image` tags :local)." + ) +} + /// Environment override for the image that `container start` and container /// account login run. Forks publish the agent image under their own registry /// path; the login volume is image-independent, so any image carrying the @@ -444,7 +484,7 @@ fn run_auth_container(provider: &str, status: bool, image: Option<&str>) -> Resu if let Some(agent_provider) = parsed_provider.agent_provider_name() { command.args(["-e", &format!("CROSSLINK_AGENT_PROVIDER={agent_provider}")]); } - command.args(crate::commands::kickoff::image_pull_args(&image)); + refresh_floating_image("docker", &image)?; command.arg(&image); command.args(parsed_provider.command(status)); if status { @@ -856,6 +896,8 @@ pub fn start( let agent_id = format!("container--{worktree_slug}"); + refresh_floating_image("docker", &image)?; + println!("Starting task container: {container_name}"); println!(" Worktree: {}", worktree_abs.display()); println!(" Memory: {memory_limit}"); @@ -962,7 +1004,6 @@ pub fn start( } } - cmd.args(crate::commands::kickoff::image_pull_args(&image)); cmd.arg(&image); let workspace_arg = crate::utils::shell_escape_arg(&container_workspace.to_string_lossy()); let runtime_dir = crate::utils::shell_escape_arg( @@ -1193,19 +1234,13 @@ mod tests { #[test] fn image_name_is_ghcr_namespaced() { - assert_eq!( - IMAGE_NAME, - "ghcr.io/corvidae-coding-projects/crosslink-agent" - ); - assert_eq!( - IMAGE_NAME, - crate::commands::kickoff::DEFAULT_AGENT_IMAGE - .rsplit_once(':') - .map_or(IMAGE_NAME, |(name, _)| name), - "container.rs IMAGE_NAME diverged from kickoff DEFAULT_AGENT_IMAGE — \ - re-opens the GH#576 compose-failure between `crosslink container build` \ - and `crosslink kickoff run --container …`" - ); + // Fork builds may override the repository at build time. + if std::env::var("CROSSLINK_AGENT_IMAGE_REPOSITORY").is_err() { + assert_eq!( + IMAGE_NAME, + "ghcr.io/corvidae-coding-projects/crosslink-agent" + ); + } } #[test] @@ -1357,6 +1392,55 @@ mod tests { assert!(podman.contains("create `vol` with podman"), "{podman}"); } + /// A stand-in runtime for the refresh: `pull` and `image inspect` exit + /// with the given codes, and every call is appended to `calls.log`. + #[cfg(unix)] + fn fake_pull_runtime(dir: &Path, pull_rc: i32, inspect_rc: i32) -> String { + use std::os::unix::fs::PermissionsExt; + let path = dir.join(format!("pull-{pull_rc}{inspect_rc}")); + let log = dir.join("calls.log"); + std::fs::write( + &path, + format!( + "#!/bin/sh\necho \"$*\" >> '{}'\ncase \"$1\" in\n pull) echo 'dial tcp: lookup ghcr.io: no such host' >&2; exit {pull_rc} ;;\n image) exit {inspect_rc} ;;\nesac\nexit 1\n", + log.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path.to_string_lossy().into_owned() + } + + #[cfg(unix)] + #[test] + fn floating_images_are_refreshed_and_fall_back_to_a_local_copy_offline() { + let dir = tempfile::tempdir().unwrap(); + let nightly = format!("{IMAGE_NAME}:nightly"); + // Registry reachable: refreshed. + refresh_floating_image(&fake_pull_runtime(dir.path(), 0, 1), &nightly).unwrap(); + // Registry unreachable, local copy present: launch continues. + refresh_floating_image(&fake_pull_runtime(dir.path(), 1, 0), &nightly).unwrap(); + // Unreachable and nothing local: the error carries the runtime's reason. + let error = refresh_floating_image(&fake_pull_runtime(dir.path(), 1, 1), &nightly) + .unwrap_err() + .to_string(); + assert!(error.contains("no such host"), "{error}"); + assert!(error.contains("--image"), "{error}"); + // A pinned tag is never pulled here. + let calls_before = std::fs::read_to_string(dir.path().join("calls.log")).unwrap(); + refresh_floating_image( + &fake_pull_runtime(dir.path(), 1, 1), + &format!("{IMAGE_NAME}:0.10.0"), + ) + .unwrap(); + let calls_after = std::fs::read_to_string(dir.path().join("calls.log")).unwrap(); + assert_eq!(calls_before, calls_after); + assert!( + calls_after.contains(&format!("pull --quiet {nightly}")), + "{calls_after}" + ); + } + /// A stand-in container runtime: `info`, `volume inspect` and `run` exit /// with the given codes, so each preflight branch can be reached without /// a real daemon. diff --git a/crosslink/src/commands/kickoff/launch.rs b/crosslink/src/commands/kickoff/launch.rs index ee301ef67..1df605b8f 100644 --- a/crosslink/src/commands/kickoff/launch.rs +++ b/crosslink/src/commands/kickoff/launch.rs @@ -968,7 +968,7 @@ pub(super) fn launch_container( Path::new("/workspaces/repo"), policy, )?; - args.extend(super::types::image_pull_args(image)); + crate::commands::container::refresh_floating_image(runtime_cmd, image)?; args.push(image.to_string()); args.push("bash".to_string()); args.push("-c".to_string()); diff --git a/crosslink/src/commands/kickoff/mod.rs b/crosslink/src/commands/kickoff/mod.rs index 43720e4bf..2e7ed8b09 100644 --- a/crosslink/src/commands/kickoff/mod.rs +++ b/crosslink/src/commands/kickoff/mod.rs @@ -14,7 +14,7 @@ mod wizard; mod tests; pub use types::{ - image_pull_args, ContainerMode, KickoffOpts, KickoffReport, PlanOpts, ReportFormat, + is_floating_image, ContainerMode, KickoffOpts, KickoffReport, PlanOpts, ReportFormat, VerifyLevel, AGENT_IMAGE_REPOSITORY, AGENT_IMAGE_TAG, DEFAULT_AGENT_IMAGE, EFFORT_LEVELS, }; diff --git a/crosslink/src/commands/kickoff/tests.rs b/crosslink/src/commands/kickoff/tests.rs index 967bfb00a..3becdef07 100644 --- a/crosslink/src/commands/kickoff/tests.rs +++ b/crosslink/src/commands/kickoff/tests.rs @@ -3393,26 +3393,118 @@ fn test_resolve_kickoff_template_none_without_flag_or_config() { ); } -#[test] -fn default_agent_image_matches_this_build() { - assert_eq!( - DEFAULT_AGENT_IMAGE, - format!("{AGENT_IMAGE_REPOSITORY}:{AGENT_IMAGE_TAG}") - ); - assert!( - AGENT_IMAGE_TAG == "nightly" - || AGENT_IMAGE_TAG == env!("CARGO_PKG_VERSION") - || std::env::var("CROSSLINK_AGENT_IMAGE_TAG").is_ok(), - "default tag must be nightly or this build's version, got {AGENT_IMAGE_TAG}" - ); - assert_ne!( - AGENT_IMAGE_TAG, "latest", - "the default must never float to :latest" - ); +/// The build script's image rules (`src/build_support/agent_image.rs`), +/// compiled here so they can be tested directly. +#[allow(dead_code)] +mod agent_image_rules { + include!("../../build_support/agent_image.rs"); + + #[test] + fn this_builds_default_image_follows_the_rules() { + use super::{AGENT_IMAGE_REPOSITORY, AGENT_IMAGE_TAG, DEFAULT_AGENT_IMAGE}; + assert_eq!( + DEFAULT_AGENT_IMAGE, + format!("{AGENT_IMAGE_REPOSITORY}:{AGENT_IMAGE_TAG}") + ); + validate_agent_image_tag(AGENT_IMAGE_TAG).unwrap(); + validate_agent_image_repository(AGENT_IMAGE_REPOSITORY).unwrap(); + } + + fn build( + override_tag: Option<&'static str>, + packaged: bool, + own_checkout: bool, + release_tag_at_head: bool, + ) -> Result { + choose_agent_image_tag(&AgentImageBuild { + override_tag, + packaged, + own_checkout, + release_tag_at_head, + version: "0.10.0", + }) + } + + #[test] + fn only_positive_evidence_yields_the_release_tag() { + // (override, packaged, own checkout, v at HEAD) -> tag + let cases: [(Option<&'static str>, bool, bool, bool, &str); 8] = [ + (None, true, false, false, "0.10.0"), // cargo install crosslink + (None, false, true, true, "0.10.0"), // checkout at the release tag + (None, false, true, false, "nightly"), // develop or a feature branch + (None, false, false, false, "nightly"), // no git, not a package: a guess, not evidence + (None, false, false, true, "nightly"), // a tag in some other repository + (None, true, true, false, "0.10.0"), // a package is a release wherever it sits + (Some("0.10.1"), false, true, false, "0.10.1"), // release CI override + (Some("nightly"), true, false, false, "nightly"), // override beats evidence + ]; + for (override_tag, packaged, own, at_tag, expected) in cases { + assert_eq!( + build(override_tag, packaged, own, at_tag).unwrap(), + expected, + "override={override_tag:?} packaged={packaged} own={own} at_tag={at_tag}" + ); + } + } + + #[test] + fn an_invalid_override_is_an_error_not_a_fallback() { + for bad in [ + "latest", + "local", + ".hidden", + "-x", + "1.0.0+build", + "has space", + "", + ] { + let error = build(Some(bad), false, true, false).unwrap_err(); + assert!( + error.contains("CROSSLINK_AGENT_IMAGE_TAG"), + "{bad:?}: {error}" + ); + } + let too_long = "a".repeat(129); + assert!(validate_agent_image_tag(&too_long).is_err()); + for good in [ + "0.10.0", + "0.10.0-beta.1", + "nightly", + "_x", + "a".repeat(128).as_str(), + ] { + validate_agent_image_tag(good).unwrap(); + } + } + + #[test] + fn repository_overrides_must_be_plain_lowercase_repositories() { + assert_eq!( + choose_agent_image_repository(None).unwrap(), + DEFAULT_AGENT_IMAGE_REPOSITORY + ); + for good in [ + "ghcr.io/example/crosslink-agent", + "localhost:5000/crosslink-agent", + "registry.example.com/team/sub/agent_image", + ] { + assert_eq!(choose_agent_image_repository(Some(good)).unwrap(), good); + } + for bad in [ + "ghcr.io/Example/agent", + "ghcr.io/example/agent:tag", + "ghcr.io/example/agent@sha256:abc", + "crosslink-agent", + "ghcr.io//agent", + "ghcr.io/example/agent/", + ] { + assert!(choose_agent_image_repository(Some(bad)).is_err(), "{bad}"); + } + } } #[test] -fn floating_image_tags_are_always_pulled_and_pinned_ones_are_not() { +fn floating_tags_are_refreshed_and_pinned_ones_are_not() { let repo = AGENT_IMAGE_REPOSITORY; for floating in [ format!("{repo}:nightly"), @@ -3420,7 +3512,7 @@ fn floating_image_tags_are_always_pulled_and_pinned_ones_are_not() { repo.to_string(), "localhost:5000/crosslink-agent".to_string(), ] { - assert_eq!(image_pull_args(&floating), ["--pull=always"], "{floating}"); + assert!(is_floating_image(&floating), "{floating}"); } for pinned in [ format!("{repo}:0.10.0"), @@ -3428,6 +3520,6 @@ fn floating_image_tags_are_always_pulled_and_pinned_ones_are_not() { format!("{repo}@sha256:{}", "a".repeat(64)), "localhost:5000/crosslink-agent:local".to_string(), ] { - assert!(image_pull_args(&pinned).is_empty(), "{pinned}"); + assert!(!is_floating_image(&pinned), "{pinned}"); } } diff --git a/crosslink/src/commands/kickoff/types.rs b/crosslink/src/commands/kickoff/types.rs index 1448147f2..418315ac8 100644 --- a/crosslink/src/commands/kickoff/types.rs +++ b/crosslink/src/commands/kickoff/types.rs @@ -3,25 +3,25 @@ use serde::{Deserialize, Serialize}; use std::path::Path; use std::time::Duration; -/// The published agent image repository. -macro_rules! agent_image_repository { - () => { - "ghcr.io/corvidae-coding-projects/crosslink-agent" - }; -} - -/// The published agent image repository, without a tag. -pub const AGENT_IMAGE_REPOSITORY: &str = agent_image_repository!(); - -/// The tag this build defaults to, chosen at build time (see `build.rs`): the -/// release version for release builds, `nightly` for development builds, so -/// the container runs the same crosslink as the CLI that launches it. +/// The agent image repository this build defaults to: the published one, +/// unless the build set `CROSSLINK_AGENT_IMAGE_REPOSITORY` (forks that publish +/// their own image). Chosen in `build.rs`. +pub const AGENT_IMAGE_REPOSITORY: &str = env!("CROSSLINK_AGENT_IMAGE_REPOSITORY"); + +/// The tag this build defaults to, chosen in `build.rs` from positive +/// evidence only: the release version for published packages and for +/// checkouts at their `v` tag, `nightly` (the image built from +/// `develop`) for every other build, or an explicit build-time +/// `CROSSLINK_AGENT_IMAGE_TAG`. A release build therefore runs the crosslink +/// it was released with inside the container; a development build runs +/// develop's crosslink, which can differ from a feature branch's code (build +/// and pass your own image when changing hub or readiness code). pub const AGENT_IMAGE_TAG: &str = env!("CROSSLINK_AGENT_IMAGE_TAG"); /// The image kickoff, swarm, sentinel and `container` commands use unless an /// image is given explicitly. pub const DEFAULT_AGENT_IMAGE: &str = concat!( - agent_image_repository!(), + env!("CROSSLINK_AGENT_IMAGE_REPOSITORY"), ":", env!("CROSSLINK_AGENT_IMAGE_TAG") ); @@ -30,21 +30,18 @@ pub const DEFAULT_AGENT_IMAGE: &str = concat!( /// one of these is re-pulled on every launch, so it cannot go stale. const FLOATING_IMAGE_TAGS: [&str; 2] = ["nightly", "latest"]; -/// Container-runtime arguments that keep a floating tag current: `--pull=always` -/// for `:nightly` and `:latest`, nothing for a pinned tag or a digest. -pub fn image_pull_args(image: &str) -> Vec { +/// Whether an image reference names a floating tag (`:nightly`, `:latest`, +/// or no tag at all, which means `:latest`) that should be refreshed before a +/// launch. Pinned tags and digests are not. +pub fn is_floating_image(image: &str) -> bool { if image.contains('@') { - return Vec::new(); + return false; } let tag = image .rsplit_once(':') .filter(|(_, tag)| !tag.contains('/')) .map_or("latest", |(_, tag)| tag); - if FLOATING_IMAGE_TAGS.contains(&tag) { - vec!["--pull=always".to_string()] - } else { - Vec::new() - } + FLOATING_IMAGE_TAGS.contains(&tag) } pub const EFFORT_LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"]; diff --git a/docs_src/guides/container-agents.qmd b/docs_src/guides/container-agents.qmd index 0666eacad..0e01e1fd2 100644 --- a/docs_src/guides/container-agents.qmd +++ b/docs_src/guides/container-agents.qmd @@ -80,15 +80,27 @@ built for both `linux/amd64` and `linux/arm64`. Published tags: - `:nightly` — floating tag tracking `develop` - `:nightly-` — the nightly built from that `develop` commit -By default, crosslink uses the image that matches its own build, so the -crosslink inside the container is the same as the one that launches it: a -released crosslink (installed from crates.io or built at its `v` tag) -uses `:`, and a development build uses `:nightly`. `:nightly` and -`:latest` are re-pulled on every launch so a cached copy never goes stale; -pinned tags are pulled once and then reused. Pass `--image` (or set +By default, crosslink uses the image that matches its own build. A released +crosslink (installed from crates.io, or built from a checkout at its +`v` tag) uses `:`, so the crosslink inside the container is +the one it was released with. Every other build, such as `develop` or a feature +branch, uses `:nightly`, the image built from the tip of `develop`: its +crosslink can differ from your branch's code, so when you change hub or +readiness code, build your own image (`just build-image`) and pass +`--image ghcr.io/corvidae-coding-projects/crosslink-agent:local`. Builds from +`cargo install --git` cannot see their release tag and also use `:nightly`; +pass `--image` with the version you installed. Release CI sets the tag +explicitly with `CROSSLINK_AGENT_IMAGE_TAG`, and fork builds can point at their +own registry with `CROSSLINK_AGENT_IMAGE_REPOSITORY`. + +`:nightly` and `:latest` are refreshed before every launch. If the registry +cannot be reached and a local copy exists, crosslink warns and uses it; pinned +tags are pulled once and then reused. Because `:nightly` follows `develop`, +anything merged there runs in every development build's next container, which +mounts your provider and GitHub logins. Pass `--image` (or set `CROSSLINK_CONTAINER_IMAGE` for `container start` and `container auth`) to use -another image. Registry tags can be moved; for a byte-for-byte reproducible -run, pin the image by digest (`--image ghcr.io/corvidae-coding-projects/crosslink-agent@sha256:`). +another image. Registry tags can be moved; for a byte-for-byte reproducible run, +pin the image by digest (`--image ghcr.io/corvidae-coding-projects/crosslink-agent@sha256:`). ### Building the image locally From dc02df1108110e61ef6b708a2b1dcf39f2fa38e2 Mon Sep 17 00:00:00 2001 From: magnificentlycursed <190320250+magnificentlycursed@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:05:24 -0700 Subject: [PATCH 05/15] feat(container): resolve the agent image the same way for every container command CROSSLINK_CONTAINER_IMAGE was honoured only by `container start` and `container auth`; kickoff took its image from a clap default, and swarm and sentinel used the build default with no override at all, so the same environment chose different images depending on the command. One resolver now serves all of them: `--image`, then CROSSLINK_CONTAINER_IMAGE, then this build's default. Blank values count as unset and image names starting with `-` are refused. Kickoff (when it launches a container) and `container start` print the image and where it came from. Together with the build-time CROSSLINK_AGENT_IMAGE_REPOSITORY override, a fork's builds can default to the fork's own registry. --- CHANGELOG.md | 7 +-- crosslink/src/commands/container.rs | 36 ++++----------- crosslink/src/commands/kickoff/mod.rs | 17 +++++-- crosslink/src/commands/kickoff/types.rs | 55 +++++++++++++++++++++++ crosslink/src/commands/sentinel/engine.rs | 3 +- crosslink/src/commands/swarm/lifecycle.rs | 3 +- crosslink/src/main.rs | 9 ++-- docs_src/guides/container-agents.qmd | 8 ++-- 8 files changed, 95 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef03b5818..8a611e42e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). valid tag or the build fails. `CROSSLINK_AGENT_IMAGE_REPOSITORY` lets fork builds default to their own registry. `:nightly` and `:latest` are refreshed before every launch, falling back to a local copy with a warning when the - registry is unreachable. `crosslink container start` accepts `--image` and - honours `CROSSLINK_CONTAINER_IMAGE`, and swarm, sentinel and the auth commands - share the same default. + registry is unreachable. Every container command (kickoff, `container + start`, `container auth`, swarm and sentinel) resolves its image the same + way: `--image`, then `CROSSLINK_CONTAINER_IMAGE`, then the build default, and + reports where the image came from. - Release image publishing is gated (gh#126, gh#122). A `v*` tag must match `crosslink/Cargo.toml` before anything is pushed; the tag push publishes only `:`; the smoke test pulls every pushed tag anonymously, so a private diff --git a/crosslink/src/commands/container.rs b/crosslink/src/commands/container.rs index 717740dac..146344703 100644 --- a/crosslink/src/commands/container.rs +++ b/crosslink/src/commands/container.rs @@ -421,36 +421,18 @@ pub(crate) fn refresh_floating_image(runtime: &str, image: &str) -> Result<()> { ) } -/// Environment override for the image that `container start` and container -/// account login run. Forks publish the agent image under their own registry -/// path; the login volume is image-independent, so any image carrying the -/// provider CLI will do. -const AUTH_IMAGE_ENV: &str = "CROSSLINK_CONTAINER_IMAGE"; - -/// Pick the image: an explicit `--image` wins, then the environment value, +/// Pick the image: an explicit `--image` wins, then `CROSSLINK_CONTAINER_IMAGE`, /// then this build's default (the image matching the CLI's own version). -/// Blank values count as unset. +/// Blank values count as unset. See `kickoff::resolve_agent_image_from`. +#[cfg(test)] fn resolve_auth_image(explicit: Option<&str>, env_value: Option<&str>) -> String { - let non_empty = |value: Option<&str>| { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(String::from) - }; - non_empty(explicit) - .or_else(|| non_empty(env_value)) - .unwrap_or_else(|| DEFAULT_IMAGE.to_string()) + crate::commands::kickoff::resolve_agent_image_from(explicit, env_value) + .map_or_else(|_| DEFAULT_IMAGE.to_string(), |(image, _)| image) } -/// `resolve_auth_image` over the live `CROSSLINK_CONTAINER_IMAGE` value. +/// The image a container command uses, and where it came from. fn auth_image(explicit: Option<&str>) -> Result { - let from_env = std::env::var(AUTH_IMAGE_ENV).ok(); - let image = resolve_auth_image(explicit, from_env.as_deref()); - anyhow::ensure!( - !image.starts_with('-'), - "container image name cannot start with '-': {image}" - ); - Ok(image) + crate::commands::kickoff::resolve_agent_image(explicit).map(|(image, _)| image) } fn run_auth_container(provider: &str, status: bool, image: Option<&str>) -> Result<()> { @@ -818,7 +800,7 @@ pub fn start( if !docker_available() { bail!("Docker is not available. Install Docker and ensure the daemon is running."); } - let image = auth_image(image)?; + let (image, image_source) = crate::commands::kickoff::resolve_agent_image(image)?; check_staleness(); @@ -903,7 +885,7 @@ pub fn start( println!(" Memory: {memory_limit}"); println!(" Agent: {agent_id}"); println!(" Provider: {}", resolved.provider); - println!(" Image: {image}"); + println!(" Image: {image} (from {image_source})"); let mut cmd = Command::new("docker"); cmd.args(["run", "-d"]); diff --git a/crosslink/src/commands/kickoff/mod.rs b/crosslink/src/commands/kickoff/mod.rs index 2e7ed8b09..53244cff6 100644 --- a/crosslink/src/commands/kickoff/mod.rs +++ b/crosslink/src/commands/kickoff/mod.rs @@ -14,10 +14,13 @@ mod wizard; mod tests; pub use types::{ - is_floating_image, ContainerMode, KickoffOpts, KickoffReport, PlanOpts, ReportFormat, - VerifyLevel, AGENT_IMAGE_REPOSITORY, AGENT_IMAGE_TAG, DEFAULT_AGENT_IMAGE, EFFORT_LEVELS, + is_floating_image, resolve_agent_image, ContainerMode, KickoffOpts, KickoffReport, PlanOpts, + ReportFormat, VerifyLevel, AGENT_IMAGE_REPOSITORY, AGENT_IMAGE_TAG, DEFAULT_AGENT_IMAGE, + EFFORT_LEVELS, }; +#[cfg(test)] +pub use types::resolve_agent_image_from; pub use types::{parse_container_mode, parse_duration, parse_verify_level}; pub use cleanup::cleanup; @@ -76,6 +79,10 @@ pub fn dispatch( None }; let parsed_container = parse_container_mode(&container)?; + let (image, image_source) = resolve_agent_image(image.as_deref())?; + if parsed_container != ContainerMode::None && !quiet { + println!("Agent image: {image} (from {image_source})"); + } let parsed_timeout = parse_duration(&timeout)?; let agent = crate::agents::resolve_agent(crosslink_dir)?; let policy = resolve_execution_policy( @@ -309,13 +316,14 @@ fn dispatch_launch( (parsed_container != ContainerMode::None) .then_some(crate::agents::SandboxPosture::ExternalIsolation), )?; + let (agent_image, _) = resolve_agent_image(None)?; let opts = KickoffOpts { description: &description, issue, container: parsed_container, verify: parse_verify_level(verify)?, model, - image: types::DEFAULT_AGENT_IMAGE, + image: &agent_image, timeout: parsed_timeout, dry_run, branch: None, @@ -414,13 +422,14 @@ fn dispatch_launch( (parsed_container != ContainerMode::None) .then_some(crate::agents::SandboxPosture::ExternalIsolation), )?; + let (agent_image, _) = resolve_agent_image(None)?; let opts = KickoffOpts { description: &description, issue: config.issue, container: parsed_container, verify: parse_verify_level(&config.verify)?, model: &config.model, - image: types::DEFAULT_AGENT_IMAGE, + image: &agent_image, timeout: parsed_timeout, dry_run: false, branch: None, diff --git a/crosslink/src/commands/kickoff/types.rs b/crosslink/src/commands/kickoff/types.rs index 418315ac8..19b9c09c3 100644 --- a/crosslink/src/commands/kickoff/types.rs +++ b/crosslink/src/commands/kickoff/types.rs @@ -26,6 +26,61 @@ pub const DEFAULT_AGENT_IMAGE: &str = concat!( env!("CROSSLINK_AGENT_IMAGE_TAG") ); +/// Environment override for the agent image, honoured by every container +/// command. Forks publish the agent image under their own registry path. +pub const AGENT_IMAGE_ENV: &str = "CROSSLINK_CONTAINER_IMAGE"; + +/// Where the agent image a command uses came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentImageSource { + Flag, + Environment, + BuildDefault, +} + +impl std::fmt::Display for AgentImageSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Flag => "--image", + Self::Environment => AGENT_IMAGE_ENV, + Self::BuildDefault => "this build's default", + }) + } +} + +/// The agent image every container command uses: an explicit `--image` wins, +/// then `CROSSLINK_CONTAINER_IMAGE`, then this build's default. Blank values +/// count as unset. +pub fn resolve_agent_image_from( + explicit: Option<&str>, + env_value: Option<&str>, +) -> Result<(String, AgentImageSource)> { + let non_empty = |value: Option<&str>| { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(String::from) + }; + let (image, source) = non_empty(explicit) + .map(|image| (image, AgentImageSource::Flag)) + .or_else(|| non_empty(env_value).map(|image| (image, AgentImageSource::Environment))) + .unwrap_or_else(|| { + ( + DEFAULT_AGENT_IMAGE.to_string(), + AgentImageSource::BuildDefault, + ) + }); + if image.starts_with('-') { + bail!("container image name cannot start with '-': {image}"); + } + Ok((image, source)) +} + +/// `resolve_agent_image_from` over the live `CROSSLINK_CONTAINER_IMAGE`. +pub fn resolve_agent_image(explicit: Option<&str>) -> Result<(String, AgentImageSource)> { + resolve_agent_image_from(explicit, std::env::var(AGENT_IMAGE_ENV).ok().as_deref()) +} + /// Tags that move to a new build without a version change. A local copy of /// one of these is re-pulled on every launch, so it cannot go stale. const FLOATING_IMAGE_TAGS: [&str; 2] = ["nightly", "latest"]; diff --git a/crosslink/src/commands/sentinel/engine.rs b/crosslink/src/commands/sentinel/engine.rs index e20d24da1..5ec2f204c 100644 --- a/crosslink/src/commands/sentinel/engine.rs +++ b/crosslink/src/commands/sentinel/engine.rs @@ -446,13 +446,14 @@ fn spawn_agent( None, )?; + let (agent_image, _) = crate::commands::kickoff::resolve_agent_image(None)?; let opts = KickoffOpts { description: &scoped_description, issue: Some(issue_id), container: ContainerMode::None, verify: scope.verify.clone(), model: &scope.model, - image: crate::commands::kickoff::DEFAULT_AGENT_IMAGE, + image: &agent_image, timeout: scope.timeout, dry_run: false, branch: None, diff --git a/crosslink/src/commands/swarm/lifecycle.rs b/crosslink/src/commands/swarm/lifecycle.rs index 5fe21abd7..987bb8672 100644 --- a/crosslink/src/commands/swarm/lifecycle.rs +++ b/crosslink/src/commands/swarm/lifecycle.rs @@ -700,13 +700,14 @@ pub fn launch(crosslink_dir: &Path, db: &Database, phase_slug: &str, quiet: bool timeout, None, )?; + let (agent_image, _) = kickoff::resolve_agent_image(None)?; let opts = KickoffOpts { description: &description, issue: issue_id, container: ContainerMode::None, verify: VerifyLevel::Local, model: &dials.model, - image: kickoff::DEFAULT_AGENT_IMAGE, + image: &agent_image, timeout, dry_run: false, branch: branch.as_deref(), diff --git a/crosslink/src/main.rs b/crosslink/src/main.rs index c162d6c1f..a65c5437f 100644 --- a/crosslink/src/main.rs +++ b/crosslink/src/main.rs @@ -1440,10 +1440,11 @@ enum KickoffCommands { #[arg(long, default_value = "standard")] model: String, - /// Agent image (default: the published image matching this crosslink - /// build — `:` for releases, `:nightly` for development builds) - #[arg(long, default_value = commands::kickoff::DEFAULT_AGENT_IMAGE)] - image: String, + /// Agent image (default: env `CROSSLINK_CONTAINER_IMAGE`, then the + /// published image matching this crosslink build — `:` for + /// releases, `:nightly` for development builds) + #[arg(long)] + image: Option, #[arg(long, default_value = "1h")] timeout: String, diff --git a/docs_src/guides/container-agents.qmd b/docs_src/guides/container-agents.qmd index 0e01e1fd2..2ac0c7550 100644 --- a/docs_src/guides/container-agents.qmd +++ b/docs_src/guides/container-agents.qmd @@ -97,9 +97,11 @@ own registry with `CROSSLINK_AGENT_IMAGE_REPOSITORY`. cannot be reached and a local copy exists, crosslink warns and uses it; pinned tags are pulled once and then reused. Because `:nightly` follows `develop`, anything merged there runs in every development build's next container, which -mounts your provider and GitHub logins. Pass `--image` (or set -`CROSSLINK_CONTAINER_IMAGE` for `container start` and `container auth`) to use -another image. Registry tags can be moved; for a byte-for-byte reproducible run, +mounts your provider and GitHub logins. To use another image, pass `--image` +or set `CROSSLINK_CONTAINER_IMAGE`; every container command (kickoff, +`container start`, `container auth`, swarm and sentinel launches) resolves the +image the same way, flag first, then the variable, then the build default, and +reports which one it used. Registry tags can be moved; for a byte-for-byte reproducible run, pin the image by digest (`--image ghcr.io/corvidae-coding-projects/crosslink-agent@sha256:`). ### Building the image locally From 5c8eecca14b9f263504e931514c6be1e06d8e392 Mon Sep 17 00:00:00 2001 From: magnificentlycursed <190320250+magnificentlycursed@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:14:40 -0700 Subject: [PATCH 06/15] feat(container): compare the image's crosslink version with the CLI's at launch The only skew check, check_staleness, read a binary-hash label that only a local `container build` set, hashed a host binary against the image's Linux binary (never equal across platforms), and inspected the default image even when another was chosen; it never fired for published images. Images now carry org.opencontainers.image.version with the exact `crosslink --version` of their binary (`container build` sets it from the packaged binary; CI sets it in the release workflow). After refreshing the image, kickoff and `container start` compare that label with this CLI's version: a different version warns, because the container and the CLI write the same hub; the same version from a different commit (a development build against :nightly) is reported at info level; images without the label are skipped. --- crosslink/src/commands/container.rs | 208 +++++++++++++++++------ crosslink/src/commands/kickoff/launch.rs | 1 + crosslink/src/commands/kickoff/mod.rs | 7 +- 3 files changed, 160 insertions(+), 56 deletions(-) diff --git a/crosslink/src/commands/container.rs b/crosslink/src/commands/container.rs index 146344703..00a687c3e 100644 --- a/crosslink/src/commands/container.rs +++ b/crosslink/src/commands/container.rs @@ -558,8 +558,6 @@ pub(crate) fn github_login_args( const IMAGE_NAME: &str = crate::commands::kickoff::AGENT_IMAGE_REPOSITORY; -const IMAGE_TAG: &str = crate::commands::kickoff::AGENT_IMAGE_TAG; - /// This build's default image: the published image matching the CLI's own /// version (`:` for releases, `:nightly` for development builds). const DEFAULT_IMAGE: &str = crate::commands::kickoff::DEFAULT_AGENT_IMAGE; @@ -584,22 +582,6 @@ fn find_crosslink_binary() -> Result { std::env::current_exe().context("Could not determine crosslink binary path") } -fn file_hash(path: &Path) -> Result { - use std::io::Read; - let mut file = std::fs::File::open(path)?; - - let mut buf = vec![0u8; 65536]; - let n = file.read(&mut buf)?; - buf.truncate(n); - - let mut hash: u64 = 0xcbf2_9ce4_8422_2325; - for &byte in &buf { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x0100_0000_01b3); - } - Ok(format!("{hash:016x}")) -} - fn resolve_repo_root() -> Result { let output = Command::new("git") .args(["rev-parse", "--show-toplevel"]) @@ -676,37 +658,83 @@ fn compute_memory_limit(config_override: Option<&str>) -> String { ) } -fn get_image_hash() -> Option { - let output = Command::new("docker") +/// The image label that records which crosslink an agent image contains: the +/// exact `crosslink --version` of its binary. CI and `container build` set it. +pub(crate) const IMAGE_VERSION_LABEL: &str = "org.opencontainers.image.version"; + +/// This CLI's version as `crosslink --version` reports it (`+` for git +/// builds). +pub(crate) fn cli_version() -> &'static str { + option_env!("CROSSLINK_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")) +} + +/// How the crosslink inside an image relates to this CLI's. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ImageVersionMatch { + /// Same version and build. + Same, + /// Same release version, built from a different commit: the normal case + /// for a development build against `:nightly`. + SameReleaseDifferentBuild, + /// A different version: the container and the CLI write the same hub, and + /// their formats may disagree. + Different, +} + +/// Compare two `crosslink --version` strings (`[+]`). +pub(crate) fn compare_crosslink_versions(cli: &str, image: &str) -> ImageVersionMatch { + let core = |version: &str| { + version + .split_once('+') + .map_or(version, |(core, _)| core) + .to_string() + }; + if cli == image { + ImageVersionMatch::Same + } else if core(cli) == core(image) { + ImageVersionMatch::SameReleaseDifferentBuild + } else { + ImageVersionMatch::Different + } +} + +/// Compare the crosslink an image contains with this CLI's, using the image's +/// version label, and say so when they differ. Nothing is printed when the +/// image is not available locally yet or carries no label (images built before +/// the label existed). +pub(crate) fn check_image_version(runtime: &str, image: &str) -> Option { + let output = Command::new(runtime) .args([ + "image", "inspect", "--format", - "{{index .Config.Labels \"crosslink-binary-hash\"}}", - &format!("{IMAGE_NAME}:{IMAGE_TAG}"), + &format!("{{{{ index .Config.Labels \"{IMAGE_VERSION_LABEL}\" }}}}"), + image, ]) .output() - .ok()?; - if output.status.success() { - let hash = String::from_utf8(output.stdout).ok()?.trim().to_string(); - if !hash.is_empty() && hash != "" { - return Some(hash); - } - } - None -} - -fn check_staleness() { - let Ok(binary_hash) = find_crosslink_binary().and_then(|p| file_hash(&p)) else { - return; - }; - if let Some(image_hash) = get_image_hash() { - if image_hash != binary_hash { - tracing::warn!( - "container image {IMAGE_NAME}:{IMAGE_TAG} is stale relative to your installed crosslink binary. \ - Pull the latest published image (`docker pull {IMAGE_NAME}:{IMAGE_TAG}`) or rebuild locally (`just build-image` or `crosslink container build`)." - ); - } + .ok() + .filter(|output| output.status.success())?; + let image_version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if image_version.is_empty() || image_version == "" { + tracing::debug!( + "{image} carries no {IMAGE_VERSION_LABEL} label; skipping the version check" + ); + return None; + } + let verdict = compare_crosslink_versions(cli_version(), &image_version); + match verdict { + ImageVersionMatch::Same => {} + ImageVersionMatch::SameReleaseDifferentBuild => tracing::info!( + "{image} contains crosslink {image_version}; this CLI is {}", + cli_version() + ), + ImageVersionMatch::Different => eprintln!( + "warning: {image} contains crosslink {image_version}, but this CLI is {}. Both write the same hub; \ + use the image matching this build, or pass --image with one built from your code (`just build-image`).", + cli_version() + ), } + Some(verdict) } struct BuildDirCleanup(PathBuf); @@ -763,8 +791,6 @@ pub fn build(force: bool, tag: Option<&str>, dockerfile: Option<&str>) -> Result std::fs::copy(&binary, build_path.join(&staged_binary)) .context("Failed to copy crosslink binary to build context")?; - let binary_hash = file_hash(&binary).unwrap_or_else(|_| "unknown".to_string()); - println!("Building container image: {image}"); let mut cmd = Command::new("docker"); @@ -772,7 +798,10 @@ pub fn build(force: bool, tag: Option<&str>, dockerfile: Option<&str>) -> Result cmd.args(["--build-arg", &format!("TARGETARCH={docker_arch}")]); cmd.args(["--label", LABEL_AGENT]); - cmd.args(["--label", &format!("crosslink-binary-hash={binary_hash}")]); + cmd.args([ + "--label", + &format!("{IMAGE_VERSION_LABEL}={}", cli_version()), + ]); if force { cmd.arg("--no-cache"); } @@ -785,7 +814,7 @@ pub fn build(force: bool, tag: Option<&str>, dockerfile: Option<&str>) -> Result } println!("Image built successfully: {image}"); - println!("Binary hash: {binary_hash}"); + println!("crosslink version: {}", cli_version()); Ok(()) } @@ -802,8 +831,6 @@ pub fn start( } let (image, image_source) = crate::commands::kickoff::resolve_agent_image(image)?; - check_staleness(); - let worktree_abs = std::fs::canonicalize(worktree_path) .with_context(|| format!("Worktree not found: {}", worktree_path.display()))?; @@ -879,6 +906,7 @@ pub fn start( let agent_id = format!("container--{worktree_slug}"); refresh_floating_image("docker", &image)?; + check_image_version("docker", &image); println!("Starting task container: {container_name}"); println!(" Worktree: {}", worktree_abs.display()); @@ -1198,7 +1226,7 @@ mod tests { #[test] fn auth_image_resolution_prefers_flag_then_env_then_default() { - let default = format!("{IMAGE_NAME}:{IMAGE_TAG}"); + let default = DEFAULT_IMAGE.to_string(); assert_eq!(resolve_auth_image(None, None), default); assert_eq!(resolve_auth_image(Some(" "), Some("")), default); assert_eq!( @@ -1229,8 +1257,8 @@ mod tests { fn build_default_tag_is_distinct_from_lookup_tag() { assert_eq!(BUILD_DEFAULT_TAG, "local"); assert_ne!( - BUILD_DEFAULT_TAG, IMAGE_TAG, - "BUILD_DEFAULT_TAG and IMAGE_TAG must differ — otherwise `crosslink container build` \ + BUILD_DEFAULT_TAG, crate::commands::kickoff::AGENT_IMAGE_TAG, + "BUILD_DEFAULT_TAG and crate::commands::kickoff::AGENT_IMAGE_TAG must differ — otherwise `crosslink container build` \ clobbers the published `:latest` users pulled from GHCR" ); } @@ -1374,6 +1402,82 @@ mod tests { assert!(podman.contains("create `vol` with podman"), "{podman}"); } + #[test] + fn image_versions_compare_on_release_and_build() { + use ImageVersionMatch::{Different, Same, SameReleaseDifferentBuild}; + let cases = [ + ("0.10.0", "0.10.0", Same), + ("0.10.0+abc1234", "0.10.0+abc1234", Same), + ( + "0.10.0+abc1234", + "0.10.0+def5678", + SameReleaseDifferentBuild, + ), + ( + "0.10.0+abc1234-dirty", + "0.10.0+abc1234", + SameReleaseDifferentBuild, + ), + ("0.10.0", "0.10.0+def5678", SameReleaseDifferentBuild), + ("0.10.0", "0.9.0", Different), + ("0.10.0-beta.1+abc", "0.10.0+abc", Different), + ]; + for (cli, image, expected) in cases { + assert_eq!( + compare_crosslink_versions(cli, image), + expected, + "{cli} vs {image}" + ); + } + } + + /// A stand-in runtime whose `image inspect` prints the given label value. + #[cfg(unix)] + fn fake_inspect_runtime(dir: &Path, label: Option<&str>) -> String { + use std::os::unix::fs::PermissionsExt; + let path = dir.join(format!( + "inspect-{}", + label.unwrap_or("missing").replace(['+', '.'], "_") + )); + let body = label.map_or_else( + || "exit 1".to_string(), + |value| format!("printf '%s\\n' '{value}'"), + ); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path.to_string_lossy().into_owned() + } + + #[cfg(unix)] + #[test] + fn launch_reads_the_images_version_label() { + let dir = tempfile::tempdir().unwrap(); + let image = format!("{IMAGE_NAME}:nightly"); + assert_eq!( + check_image_version( + &fake_inspect_runtime(dir.path(), Some(cli_version())), + &image + ), + Some(ImageVersionMatch::Same) + ); + assert_eq!( + check_image_version(&fake_inspect_runtime(dir.path(), Some("0.0.1")), &image), + Some(ImageVersionMatch::Different) + ); + // No label (older images) or no local image: nothing to compare. + assert_eq!( + check_image_version( + &fake_inspect_runtime(dir.path(), Some("")), + &image + ), + None + ); + assert_eq!( + check_image_version(&fake_inspect_runtime(dir.path(), None), &image), + None + ); + } + /// A stand-in runtime for the refresh: `pull` and `image inspect` exit /// with the given codes, and every call is appended to `calls.log`. #[cfg(unix)] diff --git a/crosslink/src/commands/kickoff/launch.rs b/crosslink/src/commands/kickoff/launch.rs index 1df605b8f..41dac16eb 100644 --- a/crosslink/src/commands/kickoff/launch.rs +++ b/crosslink/src/commands/kickoff/launch.rs @@ -969,6 +969,7 @@ pub(super) fn launch_container( policy, )?; crate::commands::container::refresh_floating_image(runtime_cmd, image)?; + crate::commands::container::check_image_version(runtime_cmd, image); args.push(image.to_string()); args.push("bash".to_string()); args.push("-c".to_string()); diff --git a/crosslink/src/commands/kickoff/mod.rs b/crosslink/src/commands/kickoff/mod.rs index 53244cff6..1d8daf121 100644 --- a/crosslink/src/commands/kickoff/mod.rs +++ b/crosslink/src/commands/kickoff/mod.rs @@ -15,13 +15,12 @@ mod tests; pub use types::{ is_floating_image, resolve_agent_image, ContainerMode, KickoffOpts, KickoffReport, PlanOpts, - ReportFormat, VerifyLevel, AGENT_IMAGE_REPOSITORY, AGENT_IMAGE_TAG, DEFAULT_AGENT_IMAGE, - EFFORT_LEVELS, + ReportFormat, VerifyLevel, AGENT_IMAGE_REPOSITORY, DEFAULT_AGENT_IMAGE, EFFORT_LEVELS, }; -#[cfg(test)] -pub use types::resolve_agent_image_from; pub use types::{parse_container_mode, parse_duration, parse_verify_level}; +#[cfg(test)] +pub use types::{resolve_agent_image_from, AGENT_IMAGE_TAG}; pub use cleanup::cleanup; pub use graph::graph; From 4b9f988cb2ee29832b7b5a8c137e8a33db7f66f6 Mon Sep 17 00:00:00 2001 From: magnificentlycursed <190320250+magnificentlycursed@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:23:42 -0700 Subject: [PATCH 07/15] ci(release): one gated release flow, anonymous digest smoke, safe :latest promotion, pinned image From the review of this PR, plus the release-binary and reproducibility items folded into it: - A gate job decides what a run may publish before anything is built: a v* tag must match crosslink/Cargo.toml (read with tomllib, like cargo) and must not already be published, since pinned tags are cached and a second image under one tag would split users; manual runs publish only from develop and can rehearse promotion against :promotion-rehearsal; the unreachable manual- branch is gone. - Binaries bake the gate's agent image tag explicitly (the version on a tag, nightly otherwise) instead of inferring it. - Only the publishing jobs hold packages/contents write, and their actions are pinned by commit. Images carry org.opencontainers.image.version (the binary's exact version), revision and source labels, and a GitHub build attestation. - The smoke test pulls the built digest without logging in, checks that every pushed tag resolves anonymously to that digest, and checks the version label against the binary. - promote-latest moves :latest only to the verified digest, only when the version is the highest stable v* tag (backports and reruns never move it backwards), one run at a time, verifying the source before and the tag after. Release runs are never cancelled part-way. - release-builds.yml becomes a reusable workflow called on release tags (it keeps its pull-request check), adds Linux aarch64 and macOS aarch64 builds, and the release job creates the GitHub release with the binaries, plugin archives, SHA256SUMS and build attestations only after the image passed its smoke test. - The canary checks every release since the build-matched default with prereleases ordered below their release, and pins the frozen forecast-bio image to its digest so a silent repoint fails. - The Dockerfile pins the base image by digest and gh, Codex, Claude Code, uv and gosu by version, and verifies gosu by checksum. crates.io publishing (publish.yml) is unchanged and still independent of this flow until its ownership and credential are settled. --- .github/workflows/container-image.yml | 524 ++++++---- .github/workflows/image-canary.yml | 52 +- .github/workflows/release-builds.yml | 58 +- CHANGELOG.md | 1108 +--------------------- crosslink/resources/container/Dockerfile | 27 +- 5 files changed, 441 insertions(+), 1328 deletions(-) diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml index a973d326c..d2ab05485 100644 --- a/.github/workflows/container-image.yml +++ b/.github/workflows/container-image.yml @@ -1,21 +1,17 @@ name: Container Image - - - - - - - - - - - - - - - - +# Builds the agent image and, for releases, the whole release: +# +# gate -> build-binary -> publish-image -> smoke-verify -> promote-latest +# \-> publish-release +# \-> release-binaries (release tags) -------------/ +# +# A v* tag must match crosslink/Cargo.toml and must not already be published. +# It pushes only :; the anonymous smoke test runs that exact digest; +# :latest then moves to the verified digest, serialized across runs and only +# for the highest stable release; the GitHub release with binaries is created +# only after the image passed. Pushes to develop publish :nightly. crates.io +# publishing (publish.yml) is not part of this flow yet. on: push: @@ -25,51 +21,140 @@ on: paths: - 'crosslink/resources/container/**' - '.github/workflows/container-image.yml' + - '.github/workflows/release-builds.yml' - 'crosslink/Cargo.toml' - 'crosslink/Cargo.lock' - - - + - 'crosslink/Cross.toml' + - 'crosslink/build.rs' workflow_dispatch: + inputs: + rehearse_promotion: + description: >- + After the smoke test, promote this develop build to :promotion-rehearsal + (exercises the promote-latest path without touching :latest) + type: boolean + default: false permissions: contents: read - packages: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # Never cancel a release run part-way through publishing. + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} env: REGISTRY: ghcr.io - - IMAGE_NAME: crosslink-agent jobs: - - + gate: + name: Release gate + runs-on: ubuntu-latest + outputs: + push: ${{ steps.plan.outputs.push }} + tags: ${{ steps.plan.outputs.tags }} + primary_tag: ${{ steps.plan.outputs.primary_tag }} + base: ${{ steps.plan.outputs.base }} + version: ${{ steps.plan.outputs.version }} + release: ${{ steps.plan.outputs.release }} + prerelease: ${{ steps.plan.outputs.prerelease }} + rehearsal: ${{ steps.plan.outputs.rehearsal }} + agent_image_tag: ${{ steps.plan.outputs.agent_image_tag }} + cache_ref: ${{ steps.plan.outputs.cache_ref }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Decide what this run may publish + id: plan + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + REHEARSE: ${{ github.event.inputs.rehearse_promotion }} + run: | + set -euo pipefail + OWNER="$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" + BASE="${REGISTRY}/${OWNER}/${IMAGE_NAME}" + REPO_PATH="${OWNER}/${IMAGE_NAME}" + SHORT_SHA="${GITHUB_SHA::7}" + CARGO_VERSION="$(python3 -c 'import tomllib; print(tomllib.load(open("crosslink/Cargo.toml", "rb"))["package"]["version"])')" + PUSH=false RELEASE=false PRERELEASE=false REHEARSAL=false + AGENT_IMAGE_TAG=nightly + case "${GITHUB_EVENT_NAME}:${GITHUB_REF}" in + pull_request:*) + VERSION="pr-${PR_NUMBER}" + TAGS=("${BASE}:pr-${PR_NUMBER}") + ;; + push:refs/tags/v*) + VERSION="${GITHUB_REF#refs/tags/v}" + if [ "$VERSION" != "$CARGO_VERSION" ]; then + echo "::error::Tag v${VERSION} does not match crosslink/Cargo.toml version ${CARGO_VERSION}; nothing was published" + exit 1 + fi + # A published version is never replaced: pinned tags are pulled + # once and cached, so a second image under the same tag would + # split users across two builds. + token="$(curl -sS "https://${REGISTRY}/token?scope=repository:${REPO_PATH}:pull" | jq -r '.token // empty' || true)" + if [ -n "$token" ]; then + code="$(curl -sS -o /dev/null -w '%{http_code}' -I \ + -H "Authorization: Bearer ${token}" \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \ + "https://${REGISTRY}/v2/${REPO_PATH}/manifests/${VERSION}")" + if [ "$code" = 200 ]; then + echo "::error::${BASE}:${VERSION} is already published; versions are never republished. Release a new version instead." + exit 1 + fi + fi + TAGS=("${BASE}:${VERSION}") + PUSH=true RELEASE=true AGENT_IMAGE_TAG="${VERSION}" + case "$VERSION" in *-*) PRERELEASE=true ;; esac + ;; + push:refs/heads/develop|workflow_dispatch:refs/heads/develop) + VERSION="nightly-${SHORT_SHA}" + TAGS=("${BASE}:nightly" "${BASE}:nightly-${SHORT_SHA}") + PUSH=true + if [ "${GITHUB_EVENT_NAME}" = workflow_dispatch ] && [ "${REHEARSE:-false}" = true ]; then + REHEARSAL=true + fi + ;; + workflow_dispatch:*) + echo "::error::Manual image publishes run only from develop (got ${GITHUB_REF}); releases publish from their v* tag push" + exit 1 + ;; + *) + echo "::error::Unexpected event/ref: ${GITHUB_EVENT_NAME} ${GITHUB_REF}" + exit 1 + ;; + esac + { + echo "push=${PUSH}" + echo "tags<> "$GITHUB_OUTPUT" + echo "Plan: push=${PUSH} release=${RELEASE} prerelease=${PRERELEASE} rehearsal=${REHEARSAL} tags=${TAGS[*]}" build-binary: name: Build binary (${{ matrix.arch }}) + needs: gate runs-on: ubuntu-latest + env: + # The default agent image the binaries in this image will use: the + # release version on a tag, nightly otherwise. Set explicitly, never inferred. + CROSSLINK_AGENT_IMAGE_TAG: ${{ needs.gate.outputs.agent_image_tag }} strategy: fail-fast: true matrix: include: - - - arch: amd64 target: x86_64-unknown-linux-musl use_cross: false - - - - - - - - - arch: arm64 target: aarch64-unknown-linux-musl use_cross: true @@ -107,10 +192,6 @@ jobs: crosslink/target/ key: ${{ runner.os }}-cargo-container-${{ matrix.target }}-${{ hashFiles('crosslink/Cargo.lock') }} - - name: Pin the default agent image tag (release tags only) - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - run: echo "CROSSLINK_AGENT_IMAGE_TAG=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" - - name: Build release binary (native) if: matrix.use_cross == false run: cargo build --locked --release --target ${{ matrix.target }} @@ -127,33 +208,29 @@ jobs: if-no-files-found: error retention-days: 1 - - - publish-image: name: Build & publish image - needs: build-binary + needs: [gate, build-binary] runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + attestations: write outputs: - primary_tag: ${{ steps.tags.outputs.primary_tag }} - version: ${{ steps.tags.outputs.version }} - pushed: ${{ steps.tags.outputs.pushed }} - tags: ${{ steps.tags.outputs.tags_line }} - base: ${{ steps.tags.outputs.base }} - release: ${{ steps.tags.outputs.release }} - prerelease: ${{ steps.tags.outputs.prerelease }} digest: ${{ steps.build.outputs.digest }} + crosslink_version: ${{ steps.label.outputs.crosslink_version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download amd64 binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: crosslink-amd64 path: artifacts/amd64 - name: Download arm64 binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: crosslink-arm64 path: artifacts/arm64 @@ -164,89 +241,26 @@ jobs: cp artifacts/arm64/crosslink crosslink/resources/container/crosslink-arm64 chmod +x crosslink/resources/container/crosslink-amd64 chmod +x crosslink/resources/container/crosslink-arm64 - ls -lah crosslink/resources/container/ - - name: Compute tags - id: tags + - name: Read the crosslink version the image will contain + id: label run: | set -euo pipefail - REF="${GITHUB_REF}" - EVENT="${GITHUB_EVENT_NAME}" - SHORT_SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)" - OWNER="$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" - BASE="${REGISTRY}/${OWNER}/${IMAGE_NAME}" - TAGS=() - PUSH=false - PRIMARY="" - VERSION="" - RELEASE=false - PRERELEASE=false - - if [ "$EVENT" = "pull_request" ]; then - - TAGS=("${BASE}:pr-${{ github.event.pull_request.number }}") - PRIMARY="${BASE}:pr-${{ github.event.pull_request.number }}" - VERSION="pr-${{ github.event.pull_request.number }}" - elif [ "$EVENT" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then - VERSION="${REF#refs/tags/v}" - CARGO_VERSION="$(sed -n 's/^version = "\(.*\)"$/\1/p' crosslink/Cargo.toml | head -n 1)" - if [ "$VERSION" != "$CARGO_VERSION" ]; then - echo "::error::Tag v${VERSION} does not match crosslink/Cargo.toml version ${CARGO_VERSION}; nothing was published" - exit 1 - fi - # Only the versioned tag is pushed here. :latest moves later, by - # digest, after the anonymous smoke test passes, and never for a - # prerelease (promote-latest). - TAGS=("${BASE}:${VERSION}") - PRIMARY="${BASE}:${VERSION}" - PUSH=true - RELEASE=true - case "$VERSION" in *-*) PRERELEASE=true ;; esac - elif [ "$REF" = "refs/heads/develop" ]; then - VERSION="nightly-${SHORT_SHA}" - TAGS=("${BASE}:nightly" "${BASE}:nightly-${SHORT_SHA}") - PRIMARY="${BASE}:nightly" - PUSH=true - elif [ "$EVENT" = "workflow_dispatch" ]; then - if [ "$REF" != "refs/heads/develop" ]; then - echo "::error::Manual image publishes run only from develop (got ${REF}); releases publish from their v* tag push" - exit 1 - fi - VERSION="manual-${SHORT_SHA}" - TAGS=("${BASE}:nightly" "${BASE}:manual-${SHORT_SHA}") - PRIMARY="${BASE}:nightly" - PUSH=true - else - echo "::error::Unexpected ref/event combination: ref=${REF} event=${EVENT}" - exit 1 - fi - - { - echo "tags<> "$GITHUB_OUTPUT" + out="$(crosslink/resources/container/crosslink-amd64 --version)" + echo "crosslink_version=${out#crosslink }" >> "$GITHUB_OUTPUT" + echo "$out" - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 with: platforms: linux/amd64,linux/arm64 - name: Set up Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to GHCR - if: steps.tags.outputs.push == 'true' - uses: docker/login-action@v3 + if: needs.gate.outputs.push == 'true' + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -254,23 +268,32 @@ jobs: - name: Build and push id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: crosslink/resources/container file: crosslink/resources/container/Dockerfile platforms: linux/amd64,linux/arm64 - push: ${{ steps.tags.outputs.push == 'true' }} - tags: ${{ steps.tags.outputs.tags }} + push: ${{ needs.gate.outputs.push == 'true' }} + tags: ${{ needs.gate.outputs.tags }} + labels: | + org.opencontainers.image.version=${{ steps.label.outputs.crosslink_version }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} provenance: false + cache-from: type=registry,ref=${{ needs.gate.outputs.cache_ref }} + cache-to: ${{ needs.gate.outputs.push == 'true' && format('type=registry,ref={0},mode=max', needs.gate.outputs.cache_ref) || '' }} - cache-from: type=registry,ref=${{ steps.tags.outputs.cache_ref }} - cache-to: ${{ steps.tags.outputs.push == 'true' && format('type=registry,ref={0},mode=max', steps.tags.outputs.cache_ref) || '' }} - - + - name: Attest the image build + if: needs.gate.outputs.push == 'true' + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + with: + subject-name: ${{ needs.gate.outputs.base }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true container-pr-smoke: name: Provider smoke (PR) - needs: build-binary + needs: [gate, build-binary] if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: @@ -336,128 +359,229 @@ jobs: fi done - - - - smoke-verify: name: Smoke verify (${{ matrix.platform }}) - needs: publish-image - if: needs.publish-image.outputs.pushed == 'true' + needs: [gate, publish-image] + if: needs.gate.outputs.push == 'true' runs-on: ubuntu-latest strategy: fail-fast: false matrix: platform: [linux/amd64, linux/arm64] + env: + BASE: ${{ needs.gate.outputs.base }} + DIGEST: ${{ needs.publish-image.outputs.digest }} steps: - name: Set up QEMU (for non-native arch) uses: docker/setup-qemu-action@v3 with: platforms: linux/amd64,linux/arm64 - - - - - - # No login: the smoke test pulls exactly as a user does, so a private # package or a tag that did not land fails here instead of in the field. - - name: Every pushed tag is pullable anonymously + - name: Every pushed tag resolves anonymously to the built digest if: matrix.platform == 'linux/amd64' env: - BASE: ${{ needs.publish-image.outputs.base }} - TAGS: ${{ needs.publish-image.outputs.tags }} + TAGS: ${{ needs.gate.outputs.tags }} run: | set -euo pipefail + test -n "$DIGEST" repo="${BASE#"${REGISTRY}"/}" - token="$(curl -fsS "https://${REGISTRY}/token?scope=repository:${repo}:pull" | jq -r '.token // empty')" + token="$(curl -sS "https://${REGISTRY}/token?scope=repository:${repo}:pull" | jq -r '.token // empty' || true)" if [ -z "$token" ]; then echo "::error::${BASE} refuses anonymous pulls; make the package public in its package settings" exit 1 fi for ref in $TAGS; do tag="${ref##*:}" - code="$(curl -sS -o /dev/null -w '%{http_code}' -I \ + headers="$(curl -sS -o /dev/null -D - -I \ -H "Authorization: Bearer ${token}" \ - -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \ "https://${REGISTRY}/v2/${repo}/manifests/${tag}")" - if [ "$code" != 200 ]; then - echo "::error::${ref} is not pullable anonymously (HTTP ${code})" + status="$(printf '%s' "$headers" | head -n 1 | awk '{print $2}')" + got="$(printf '%s' "$headers" | tr -d '\r' | awk -F': ' 'tolower($1) == "docker-content-digest" {print $2}')" + if [ "$status" != 200 ]; then + echo "::error::${ref} is not pullable anonymously (HTTP ${status})" + exit 1 + fi + if [ "$got" != "$DIGEST" ]; then + echo "::error::${ref} resolves to ${got}, not the built digest ${DIGEST}" exit 1 fi - echo "anonymous pull OK: ${ref}" + echo "anonymous pull OK: ${ref} -> ${got}" done - - name: Pull image - run: docker pull --platform ${{ matrix.platform }} ${{ needs.publish-image.outputs.primary_tag }} + - name: Pull the built digest + run: docker pull --platform ${{ matrix.platform }} "${BASE}@${DIGEST}" - - name: Inspect manifest covers both arches (once per matrix is fine) + - name: Manifest covers both architectures (once) if: matrix.platform == 'linux/amd64' run: | - docker manifest inspect ${{ needs.publish-image.outputs.primary_tag }} \ - | jq -e '[.manifests[].platform | "\(.os)/\(.architecture)"] | sort | . == ["linux/amd64","linux/arm64"]' + docker manifest inspect "${BASE}@${DIGEST}" \ + | jq -e '[.manifests[].platform | select(.os != "unknown") | "\(.os)/\(.architecture)"] | sort | . == ["linux/amd64","linux/arm64"]' - - name: Run crosslink --version - id: version + - name: Run crosslink --version and check the image's version label + env: + LABEL_VERSION: ${{ needs.publish-image.outputs.crosslink_version }} + RELEASE: ${{ needs.gate.outputs.release }} + VERSION: ${{ needs.gate.outputs.version }} run: | set -euo pipefail - - - OUT=$(docker run --rm --platform ${{ matrix.platform }} \ - --entrypoint /usr/local/bin/crosslink \ - ${{ needs.publish-image.outputs.primary_tag }} --version) - echo "version output: $OUT" - echo "out=$OUT" >> "$GITHUB_OUTPUT" - - echo "$OUT" | grep -E '^crosslink [0-9]+\.[0-9]+\.[0-9]+' - - - name: Verify version matches tag (release tags only) - if: startsWith(github.ref, 'refs/tags/v') - run: | - EXPECTED="${{ needs.publish-image.outputs.version }}" - - - - - - - ACTUAL=$(echo "${{ steps.version.outputs.out }}" | awk '{print $2}' | sed 's/+.*//') - if [ "$EXPECTED" != "$ACTUAL" ]; then - echo "::error::Tag version ($EXPECTED) does not match binary version ($ACTUAL)" + out="$(docker run --rm --platform ${{ matrix.platform }} --entrypoint /usr/local/bin/crosslink "${BASE}@${DIGEST}" --version)" + echo "version output: $out" + echo "$out" | grep -E '^crosslink [0-9]+\.[0-9]+\.[0-9]+' + label="$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "${BASE}@${DIGEST}")" + if [ "crosslink ${label}" != "$out" ] || [ "$label" != "$LABEL_VERSION" ]; then + echo "::error::image label '${label}' does not match its binary '${out}'" exit 1 fi - echo "Version match: $EXPECTED" + if [ "$RELEASE" = true ]; then + actual="$(echo "$out" | awk '{print $2}' | sed 's/+.*//')" + if [ "$VERSION" != "$actual" ]; then + echo "::error::Tag version ($VERSION) does not match binary version ($actual)" + exit 1 + fi + fi - # :latest is the most recent stable release. It moves only after the - # versioned image has passed the anonymous smoke test on both platforms, by - # digest, so it always names an image that was verified, and never for a - # prerelease. Re-pointing it to an older version's digest is the rollback. + # :latest is the most recent stable release. It moves only to the digest the + # smoke test ran, only when that version is the highest stable v* tag (a + # backport or a re-run of an older release never moves it backwards), and one + # promotion at a time across runs. Re-pointing it to an older version's + # digest by hand is the rollback. A develop dispatch with + # rehearse_promotion exercises this job against :promotion-rehearsal. promote-latest: - name: Promote :latest (stable releases only) - needs: [publish-image, smoke-verify] + name: Promote ${{ needs.gate.outputs.rehearsal == 'true' && ':promotion-rehearsal' || ':latest' }} + needs: [gate, publish-image, smoke-verify] if: >- - needs.publish-image.outputs.release == 'true' && - needs.publish-image.outputs.prerelease == 'false' + (needs.gate.outputs.release == 'true' && needs.gate.outputs.prerelease == 'false') || + needs.gate.outputs.rehearsal == 'true' runs-on: ubuntu-latest + permissions: + contents: read + packages: write + concurrency: + group: promote-latest-${{ github.repository }} + cancel-in-progress: false + env: + BASE: ${{ needs.gate.outputs.base }} + DIGEST: ${{ needs.publish-image.outputs.digest }} + VERSION: ${{ needs.gate.outputs.version }} + REHEARSAL: ${{ needs.gate.outputs.rehearsal }} steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Decide the target tag + id: target + run: | + set -euo pipefail + if [ "$REHEARSAL" = true ]; then + echo "tag=promotion-rehearsal" >> "$GITHUB_OUTPUT" + exit 0 + fi + highest="$(git -c versionsort.suffix=- tag --list 'v*' --sort=-version:refname | grep -v -- '-' | head -n 1)" + if [ "$highest" != "v${VERSION}" ]; then + echo "::notice::v${VERSION} is not the highest stable release (${highest}); :latest stays where it is" + echo "tag=" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "tag=latest" >> "$GITHUB_OUTPUT" + - name: Set up Buildx - uses: docker/setup-buildx-action@v3 + if: steps.target.outputs.tag != '' + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to GHCR - uses: docker/login-action@v3 + if: steps.target.outputs.tag != '' + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Point :latest at the verified digest + - name: Point the tag at the verified digest + if: steps.target.outputs.tag != '' env: - BASE: ${{ needs.publish-image.outputs.base }} - DIGEST: ${{ needs.publish-image.outputs.digest }} + TARGET: ${{ steps.target.outputs.tag }} run: | set -euo pipefail test -n "$DIGEST" - docker buildx imagetools create --tag "${BASE}:latest" "${BASE}@${DIGEST}" - docker buildx imagetools inspect "${BASE}:latest" --format '{{json .Manifest.Digest}}' | grep -F "$DIGEST" + # The source must exist before anything moves. + docker buildx imagetools inspect "${BASE}@${DIGEST}" > /dev/null + docker buildx imagetools create --tag "${BASE}:${TARGET}" "${BASE}@${DIGEST}" + repo="${BASE#"${REGISTRY}"/}" + token="$(curl -sS "https://${REGISTRY}/token?scope=repository:${repo}:pull" | jq -r '.token // empty' || true)" + got="$(curl -sS -o /dev/null -D - -I -H "Authorization: Bearer ${token}" \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ + "https://${REGISTRY}/v2/${repo}/manifests/${TARGET}" | tr -d '\r' | awk -F': ' 'tolower($1) == "docker-content-digest" {print $2}')" + if [ "$got" != "$DIGEST" ]; then + echo "::error::${BASE}:${TARGET} resolves to '${got}', not ${DIGEST}; re-point it by hand" + exit 1 + fi + echo "${BASE}:${TARGET} -> ${DIGEST}" + + release-binaries: + name: Release binaries + needs: gate + if: needs.gate.outputs.release == 'true' + uses: ./.github/workflows/release-builds.yml + with: + agent_image_tag: ${{ needs.gate.outputs.version }} + + # The GitHub release is created only after the image for this version passed + # its anonymous smoke test, because every attached binary defaults to it. + publish-release: + name: Publish GitHub release + needs: [gate, smoke-verify, release-binaries] + if: needs.gate.outputs.release == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + env: + VERSION: ${{ needs.gate.outputs.version }} + PRERELEASE: ${{ needs.gate.outputs.prerelease }} + steps: + - name: Download release artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + path: dist + pattern: release-* + + - name: Collect assets and checksums + run: | + set -euo pipefail + mkdir -p release + for dir in dist/release-*/; do + name="$(basename "$dir")" + name="${name#release-}" + for file in "$dir"*; do + case "$file" in + *.tar.gz) cp "$file" release/ ;; + *) cp "$file" "release/${name}" ;; + esac + done + done + (cd release && sha256sum -- * > SHA256SUMS) + cat release/SHA256SUMS + + - name: Attest the release binaries + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + with: + subject-path: release/* + - name: Create the release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + flags=() + if [ "$PRERELEASE" = true ]; then flags+=(--prerelease); fi + gh release create "v${VERSION}" release/* \ + --repo "${GITHUB_REPOSITORY}" \ + --verify-tag \ + --title "v${VERSION}" \ + --generate-notes \ + "${flags[@]}" diff --git a/.github/workflows/image-canary.yml b/.github/workflows/image-canary.yml index d5f5a246e..8c446c155 100644 --- a/.github/workflows/image-canary.yml +++ b/.github/workflows/image-canary.yml @@ -26,17 +26,29 @@ jobs: run: | set -euo pipefail owner="$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" - refs="ghcr.io/${owner}/crosslink-agent:nightly" - # Builds released before the build-matched default (v0.9.0-beta.1 - # and earlier) pull the frozen forecast-bio image; keep it alive. - refs="${refs} ghcr.io/forecast-bio/crosslink-agent:latest" - # Newer releases default to their own version tag. - newest="$(git tag --list 'v*' --sort=-version:refname | head -n 1)" - if [ -n "$newest" ] && [ "$(printf '%s\n%s\n' v0.9.0-beta.1 "$newest" | sort -V | tail -n 1)" = "$newest" ] && [ "$newest" != v0.9.0-beta.1 ]; then - refs="${refs} ghcr.io/${owner}/crosslink-agent:${newest#v}" - case "$newest" in *-*) ;; *) refs="${refs} ghcr.io/${owner}/crosslink-agent:latest" ;; esac + base="ghcr.io/${owner}/crosslink-agent" + # ref[@expected digest]: a digest means the tag must still name exactly + # that image (it is outside this repository's control). + refs="${base}:nightly" + # Releases up to v0.9.0-beta.1 default to the frozen forecast-bio + # image; it must stay pullable and unchanged. + refs="${refs} ghcr.io/forecast-bio/crosslink-agent:latest@sha256:5f93d5509aecbc5d05c88e9836b62014de7acc754e93605bfe67319e388ccb60" + # Every later release defaults to its own version tag. versionsort.suffix + # ranks a prerelease below its release (v0.10.0-rc.1 < v0.10.0). + stable="" + after_scheme=false + for tag in $(git -c versionsort.suffix=- tag --list 'v*' --sort=version:refname); do + if [ "$after_scheme" = true ]; then + refs="${refs} ${base}:${tag#v}" + case "$tag" in *-*) ;; *) stable="$tag" ;; esac + fi + if [ "$tag" = v0.9.0-beta.1 ]; then after_scheme=true; fi + done + if [ -n "$stable" ]; then + refs="${refs} ${base}:latest" fi echo "refs=${refs}" >> "$GITHUB_OUTPUT" + echo "checking: ${refs}" - name: Pull each reference anonymously env: @@ -44,25 +56,33 @@ jobs: run: | set -euo pipefail failed=0 - for ref in $REFS; do + for entry in $REFS; do + ref="${entry%@*}" + expected="" + if [ "$entry" != "$ref" ]; then expected="${entry#*@}"; fi image="${ref#ghcr.io/}" repo="${image%:*}" tag="${image##*:}" - token="$(curl -fsS "https://ghcr.io/token?scope=repository:${repo}:pull" | jq -r '.token // empty' || true)" + token="$(curl -sS "https://ghcr.io/token?scope=repository:${repo}:pull" | jq -r '.token // empty' || true)" if [ -z "$token" ]; then echo "::error::${ref}: anonymous pull refused (package private or missing)" failed=1 continue fi - code="$(curl -sS -o /dev/null -w '%{http_code}' -I \ + headers="$(curl -sS -o /dev/null -D - -I \ -H "Authorization: Bearer ${token}" \ -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \ "https://ghcr.io/v2/${repo}/manifests/${tag}")" - if [ "$code" = 200 ]; then - echo "OK ${ref}" - else - echo "::error::${ref}: manifest HTTP ${code}" + status="$(printf '%s' "$headers" | head -n 1 | awk '{print $2}')" + got="$(printf '%s' "$headers" | tr -d '\r' | awk -F': ' 'tolower($1) == "docker-content-digest" {print $2}')" + if [ "$status" != 200 ]; then + echo "::error::${ref}: manifest HTTP ${status}" failed=1 + elif [ -n "$expected" ] && [ "$got" != "$expected" ]; then + echo "::error::${ref} now resolves to ${got}, not ${expected}: the image installed releases pull has changed" + failed=1 + else + echo "OK ${ref} ${got}" fi done exit "$failed" diff --git a/.github/workflows/release-builds.yml b/.github/workflows/release-builds.yml index d0d7efacb..7f1eb4c77 100644 --- a/.github/workflows/release-builds.yml +++ b/.github/workflows/release-builds.yml @@ -3,22 +3,31 @@ name: Release Builds +# Builds the release binaries and Codex plugin archives. Pull requests to main +# build them as a check; releases run this as a reusable workflow from +# container-image.yml, which attaches the results to the GitHub release only +# after the release's agent image has passed its smoke test. on: - push: - tags: - - 'v*' pull_request: branches: [main] + workflow_call: + inputs: + agent_image_tag: + description: The default agent image tag the binaries bake in (the release version) + type: string + required: true permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: release-builds-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: CARGO_TERM_COLOR: always + # Release runs pass the version; pull-request check builds are development builds. + CROSSLINK_AGENT_IMAGE_TAG: ${{ inputs.agent_image_tag || 'nightly' }} jobs: package-codex-plugin: @@ -66,7 +75,7 @@ jobs: - name: Upload plugin release artifact uses: actions/upload-artifact@v4 with: - name: crosslink-codex-plugin + name: release-crosslink-codex-plugin path: | crosslink/crosslink-codex-plugin-*.tar.gz crosslink/crosslink-codex-marketplace-*.tar.gz @@ -83,18 +92,29 @@ jobs: include: - os: ubuntu-latest target: x86_64-unknown-linux-musl - artifact: crosslink-linux + artifact: release-crosslink-linux-x86_64 + use_cross: false + - os: ubuntu-latest + target: aarch64-unknown-linux-musl + artifact: release-crosslink-linux-aarch64 + use_cross: true + - os: macos-latest + target: aarch64-apple-darwin + artifact: release-crosslink-macos-aarch64 + use_cross: false - os: macos-latest target: x86_64-apple-darwin - artifact: crosslink-macos + artifact: release-crosslink-macos-x86_64 + use_cross: false - os: windows-latest target: x86_64-pc-windows-msvc - artifact: crosslink-win.exe + artifact: release-crosslink-windows-x86_64.exe + use_cross: false steps: - uses: actions/checkout@v4 - - name: Install musl-tools (Linux only) - if: matrix.os == 'ubuntu-latest' + - name: Install musl-tools (Linux native only) + if: matrix.os == 'ubuntu-latest' && matrix.use_cross == false run: sudo apt-get update && sudo apt-get install -y musl-tools - name: Install Rust toolchain @@ -102,14 +122,20 @@ jobs: with: targets: ${{ matrix.target }} - - name: Pin the default agent image tag (release tags only) - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - shell: bash - run: echo "CROSSLINK_AGENT_IMAGE_TAG=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + - name: Install cross (Linux aarch64 only) + if: matrix.use_cross == true + uses: taiki-e/install-action@v2 + with: + tool: cross - - name: Build release + - name: Build release (native) + if: matrix.use_cross == false run: cargo build --locked --release --target ${{ matrix.target }} + - name: Build release (cross) + if: matrix.use_cross == true + run: cross build --locked --release --target ${{ matrix.target }} + - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a611e42e..9becbc855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,1092 +21,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). start`, `container auth`, swarm and sentinel) resolves its image the same way: `--image`, then `CROSSLINK_CONTAINER_IMAGE`, then the build default, and reports where the image came from. -- Release image publishing is gated (gh#126, gh#122). A `v*` tag must match - `crosslink/Cargo.toml` before anything is pushed; the tag push publishes only - `:`; the smoke test pulls every pushed tag anonymously, so a private - package or a missing tag fails CI; and `:latest` moves afterwards, by digest, - for stable releases only. Manual image publishes run only from `develop`. - Release binaries bake their version in as the default agent image tag, and a - daily canary checks that the images released builds default to, including - the frozen `ghcr.io/forecast-bio/crosslink-agent:latest` that - 0.9.0-beta.1 uses, can still be pulled anonymously. - -- `crosslink container auth login|status|refresh|logout --provider github` - keeps a GitHub CLI login in a per-user docker volume so container agents - can publish hub refs over HTTPS (gh#109). Kickoff and `container start` - mount it read-only when the hub remote is an HTTPS URL, refuse non-HTTPS - remotes up front, and the entrypoint requires a working login before the - agent starts. -- First-class Claude and Codex provider support. `agent.provider` now selects - protocol semantics independently from the optional `agent.binary` override; - kickoff, design, orchestrator, swarm, Sentinel, status, usage, and containers - share structured provider adapters and normalized runtime events. -- `crosslink init` installs Claude and Codex repository integrations by default, - with `--agent-integration claude|codex|both` for explicit selection. Codex gets - merged hooks/MCP configuration, canonical skills, a managed `AGENTS.md` block, - and a versioned local plugin generated from provider-neutral assets. -- `crosslink doctor` reports selected-provider executable, account-login, - integration, MCP, plugin, hook-trust, and container credential readiness. -- Recorded Claude/Codex event fixtures, cross-provider hook protocol fixtures, - plugin drift validation, and native Windows/container provider coverage. -- Per-dispatch reasoning/spend dials on the kickoff path (gh#61). - `crosslink kickoff run` and `crosslink kickoff plan` accept - `--effort ` and `--budget-usd `, emitted - as `claude --effort` / `claude --max-budget-usd` immediately after `--model` - on both the local (tmux) and container launch paths. `--effort` is validated - fail-closed by clap; omitting both flags reproduces the previous invocation - byte-for-byte. The resolved `model`, `effort`, and `budget_usd` are recorded - in `.kickoff-metadata.json` so a run record captures what the dispatch was - actually given. `crosslink swarm config` gains the same two flags: swarm now - sources `model`/`effort`/`budget_usd` from its budget config instead of - hardcoding `opus` at launch, and the spend ceiling applies per dispatched - agent (a wave of N agents can spend up to N x the amount). -- Repository reconciliation now pins local and remote historical evidence, - prepares immutable v3 objects, verifies canonical semantics, and publishes a - single generation transaction. The reconciliation transaction can resume - interruptions, adopt an independently verified racing winner, preserve - concurrent/offline work, and retire legacy remote authority without a - finalize/remigrate sequence. - -### Changed - -- Web retrieval now stays on each provider's native web/search implementation. - The flaky safe-fetch proxy and obsolete key-based sanitization path were - removed; repository instructions, skills, and hooks instead preserve external - provenance and make the boundary explicit: fetched words are evidence to - examine, never instructions to obey. -- Repository ownership, package metadata, documentation, release assets, and - remotes now use `Corvidae-Coding-Projects/crosslink` as the canonical source. -- `crosslink migrate hub-v3` is now a compatibility adapter over automatic - repository reconciliation. `--finalize --yes-delete-v2` is accepted as an - idempotent compatibility request; `--adopt-stale` and - `--remigrate-from-v2` fail with guidance because unverifiable or forced - publication is no longer part of the migration policy. - -### Fixed - -- `crosslink container build` no longer fails with a cryptic - `COPY crosslink-: not found` (gh#75). It staged the binary as - `crosslink` while the Dockerfile expects `crosslink-${TARGETARCH}`; it now - stages `crosslink-` and passes `--build-arg TARGETARCH=` so a - plain `docker build` resolves the COPY. Since it packages the *installed* - binary (no source tree to cross-compile from), it now fails fast with a - clear message on non-Linux hosts — where that binary can't run in the - Linux image — pointing at the CI workflow / `just build-image` instead. -- The container-image workflow's `IMAGE_NAME` is derived from - `${{ github.repository_owner }}` (gh#75), so a fork publishes to its own - GHCR namespace instead of failing to push to the upstream org (on - Corvidae-Coding-Projects it still resolves to `Corvidae-Coding-Projects/crosslink-agent`), and a - `workflow_dispatch` trigger lets a fork publish a working agent image - on-demand without a `develop` push. Addresses the unpublished - `DEFAULT_AGENT_IMAGE` (Corvidae-Coding-Projects/crosslink#576). -- `kickoff run --container` now passes claude's permission flag into the - container agent (gh#59). The local (tmux) path emitted - `--dangerously-skip-permissions` / `--permission-mode`, and `container start` - hardcodes it, but the kickoff container path built its `claude` invocation - without any permission flag — so the containerized agent prompted for every - tool and, with no TTY, blocked forever (a cause of the gh#55 "agent launches - then stalls" signature). Both launch paths now share a single - `permission_flag` helper so they cannot drift, and `launch_container` - threads `skip_permissions`/`permission_mode` through and emits the flag. -- `GET /api/v1/sync/status` reports an accurate `last_fetch_at` on v3 hubs - (gh#53). It was derived from the hub-cache directory's mtime, which tracks a - fetch on v2 (the worktree is reset) but freezes on v3, where a fetch adopts - refs into `.git` and never touches the worktree. On v3 it now reads - `FETCH_HEAD`'s mtime (git rewrites it on every fetch), located via - `rev-parse --git-path` so it is correct across worktree layouts. -- `kickoff plan` accepts `--skip-permissions` / `--permission-mode` (gh#66). - Plan mode is read-only, so it launched its claude session with no permission - flag — which meant a fresh worktree triggered claude's interactive - workspace-trust dialog, and a headless dispatcher (no TTY to answer it) hung - forever (the plan/tmux half of the gh#55 stall). The flags now match - `kickoff run`/`launch`; the default stays off (interactive users still get - the dialog — fail-closed), and a headless caller passes `--skip-permissions`, - which is what actually clears the trust dialog (`--permission-mode` alone does - not). The `kickoff --plan` form now threads the flags too (they were - previously accepted and silently dropped). -- The dashboard agent-request pane is populated on v3 hubs (gh#49, agent - requests half). `read_snapshot_v3` hardcoded `agent_requests` empty because - the request/ack streams live on the agent refs, not a worktree the snapshot - reader scans. A new `hub_v3::read_all_agent_requests` walks the agent refs, - reads each driver's `requests-out/--.json` and each target's - `requests-ack/.json`, and pairs them by ulid grouped by target — the - whole-fleet view the dashboard needs, distinct from the per-inbox - `poll_requests_for_agent`. (The `ci_status` half of gh#49 remains: v3 has no - CI-status ref home or writer yet — separate work.) -- The dashboard `unreachable_project` alert can now fire (gh#48). The alert and - the frontend tile severity key on `projects.status == "error"`, but the poll - loop never wrote that value — it discarded the fetch outcome and propagated a - snapshot-read failure without recording it — so a genuinely unreachable - project showed stale tiles and stayed silent. `poll_project` now marks - `status = "error"` when the clone is gone/unreadable, or when a fetch failed - and no local hub data exists, and clears it back to `"active"` on a - successful poll (a transient fetch failure over existing local data does not - flap). -- `kickoff plan --dry-run` and `kickoff run --dry-run` are side-effect-free - (gh#19). Both created a real git worktree, branch, and sentinel files — and - `plan` a permanent `PlanRecord` — before their dry-run guard, so repeated - dry-runs accumulated orphan worktrees and phantom "planning" rows that no - cleanup flag reclaimed. The guards now run before any creation and print the - would-be worktree/branch/agent names. (`run --dry-run` still creates the - tracker issue when none is passed, since the printed prompt embeds its id.) -- The launch command writes a `TIMEOUT` sentinel to `.kickoff-status` when the - timeout wrapper kills the agent (gh#60) — on both the local (tmux) and - container paths, via an exit-code-124 trailer. Killed agents previously kept - their `RUNNING` sentinel forever and only the wall-clock check ever noticed; - `TIMEOUT` now classifies as `timed-out` in status normalization. -- Design docs using the documented H2 `## Phase:` / `## Layer:` headers - produce requirement groups for swarm phasing (gh#57). The parser previously - recognized only H3 `### Phase N:` headers inside `## Requirements`; the - documented H2 form silently fell into `unknown_sections` and phasing intent - degraded to auto-decomposition. -- The `/design` skill no longer resets `pipeline.json` on `--continue` - (gh#56). Its pipeline-state step wrote the file via an unconditional - heredoc, wiping `plans`/`runs` history and regressing `stage` on designs - that had already advanced; it now creates the file only when absent and - otherwise updates `doc_hash`/`design_doc` in place, preserving history. -- Agent hook enforcement matches the documented contract (gh#58). The shipped - `agent_overrides` (and the hook's built-in agent defaults) blocked only - destructive operations while the guides and the generated KICKOFF.md prompt - claimed "no push, no merge, gated commits": `git merge`/`rebase`/ - `cherry-pick`/`reset`, stash/tag/patch, and branch surgery are now - mechanically blocked for agents and `git commit` is gated on an active - issue. Plain `git push` remains permitted because the CI-verify flow - instructs the agent to push a draft PR (force-push stays always blocked) — - the prompt and guides now state that contract accurately instead of - overclaiming. -- Hub-cache init no longer fails with "Author identity unknown" on a host whose - git identity is present but empty (gh#34). `ensure_cache_git_identity` checked - only that `git config user.email` *succeeded* — but `git config` exits 0 for a - key set to an empty string (e.g. a global `user.email = ` used to force - per-repo identities), so the empty value slipped past as "configured" and the - `Initialize crosslink v3 hub worktree` commit then died with no author. It now - requires a non-empty value on both `user.email` and `user.name` (the check - previously ignored `user.name` entirely), falling back to the - `crosslink`/`crosslink@localhost` identity otherwise — hardening real - `crosslink agent bootstrap` on CI runners and identity-less/empty-identity - hosts. -- Dashboard git calls can no longer hang on a credential prompt (gh#34). The - poll loop's per-tick `git fetch` and the track-time `git remote show origin` - default-branch probe now run with `GIT_TERMINAL_PROMPT=0`, a neutralized - `GIT_ASKPASS`, and `-c credential.helper=`, so a private, moved, or deleted - remote (or an interactive askpass like VS Code's) makes them fail fast - instead of blocking a headless `dashboard serve` worker indefinitely. The - `detect_default_branch` test fixtures now record a local remote-tracking - HEAD so they resolve without any network I/O (previously they reached the - live network against hardcoded URLs, hanging on interactive-helper hosts). -- The dashboard is v3-aware (gh#4, final symptom): migrated+finalized repos no - longer surface as `unreachable_project`. The poll fetch uses the - `+refs/heads/crosslink/*` glob (the exact v2-only refspec failed every tick - against migrated remotes, so v3 refs never became local and the - already-mode-routed reader saw nothing), the v2 hub-cache worktree - materialization and the post-action `reset --hard crosslink/hub` are gated - off on v3, tile freshness (`hub_sha`/`last_commit_at`) keys off the - checkpoint ref, GitHub org discovery falls back to probing the - `crosslink/checkpoint` branch, the track-time hub check recognizes v3 - markers (local refs only), `GET /api/v1/sync/status` names the mode's - actual hub ref, and hub signature verification checks the checkpoint tip - on v3 instead of the nonexistent `locks.json` history. -- `migrate hub-v3 --finalize` no longer refuses after legitimate post-migration - hub use (gh#45). The finalize gate reused the migration-time invariant - (`reduce(current refs) == genesis`), which only holds in the instant after - seeding - a single lock release, or even the dispatcher's own v3 fetch - advancing the checkpoint ref, permanently blocked the cutover with a - misleading "v2 and v3 no longer agree". Migration now persists the seeded - genesis checkpoint commit and per-agent seed tips in `HubMeta` (serde-default - fields, compatible both directions), and finalize verifies what actually - gates safe v2 deletion: the v2 files still reproduce the SEEDED genesis - (content-addressed at the recorded commit), every seed tip remains an - ancestor of its ref (ancestry, not byte-prefix, so compaction and REQ-11 - pruning never false-fail), and reducing AT the seeded tips reproduces the - genesis. Hubs migrated before the metadata existed fall back to the strict - check with an actionable `--remigrate-from-v2` message. -- `crosslink integrity` no longer false-FAILs `counters` and `hydration` on a - v3 hub. Both checks read legacy v2 worktree artifacts (meta/counters.json, - issues/*.json) that v3 never materializes, fabricating "next_display_id is - 1" and "0 issues in JSON, N in SQLite" against healthy hubs - and steering - users toward a `--repair --accept-data-loss` that wiped local shared tables - and restored nothing. On v3 the checks now compare against the reduced - checkpoint state (the same source the already-v3-aware `locks` check uses), - and hydration repair re-hydrates from that state instead of the empty - worktree (gh#7). -- Hydration no longer multiplies hub comments. Comments have no unique uuid - index, so a hub comment adopted into the sqlite-only preservation snapshot - (via a numeric issue-id collision, e.g. after a legacy import) was restored - next to the fresh copy each pass re-inserted - one extra byte-identical - copy per mutating invocation. Comment hydration and the preservation - restore now dedup by uuid, which also collapses previously accumulated - duplicates back to a single copy on the next hydration. Refused v2 - mutations are additionally side-effect-free: the legacy-layout refusal now - fires before the hub write lock is acquired (gh#12). -- `crosslink locks release` is no longer a silent no-op from a fresh process - on a v3 hub. `read_lock_v2`'s v3 branch consulted only the in-process - `last_v3_state` cache - empty in any process that had not yet performed a - v3 mutation - so release reported "was not locked" while `locks list` - (which reads the checkpoint) showed the claim. The v3 read now lazily - reduces the persisted ref namespace when the cache is cold, the same idiom - `load_milestone_by_id` already uses; this also repairs the `AlreadyHeld` - pre-check in `claim_lock_v2` (gh#8). -- Claude hooks now resolve the crosslink binary via `find_crosslink_binary()` - instead of a bare PATH lookup (`work-check.py` control-flags check, - `heartbeat.py`, `session-start.py`), and `check_control_flags` fails open - unless stdout parses as flag-state JSON with a blocking flag actually set. - Previously, a PATH binary too old to know `agent flags` exited 2 with a clap - usage error - the same exit code as the flags protocol - and every - Write/Edit/Bash call was blocked with a false "AGENT PAUSED - an operator - has paused this agent" message (gh#31). -- `crosslink migrate to-shared` now works on v3 hubs (gh#4, second half). The - v2 body wrote worktree JSON and counters the reduction never reads, then - pushed the nonexistent legacy `crosslink/hub` ref ("src refspec does not - match any"). On v3 it now promotes SQLite-only issues (uuids the reduced - state does not know) through the event log via the same batch path - `import` uses - one commit, one hydration - preserving existing uuids (so - hydration replaces local rows in place instead of duplicating them) and - carrying free positive local ids into the reduction so local numbering - survives. Idempotent: already-promoted issues are skipped, so it doubles - as the recovery sweep for rows created before the hub was established. -- Post-migrate `crosslink sync` no longer aborts with SQLite `Error code - 1555: constraint failed` (gh#11). Preserved sqlite-only comments carry - positive v2/import-era ids while hydration inserts hub comments at frozen - positive display ids through a plain INSERT - a collision killed the whole - hydration transaction. The preservation restore now re-keys colliding - preserved comment ids to fresh negative local ids (comment ids are not FK - targets), mirroring the issue-id remap above it. -- `crosslink create` / `quick` after a legacy `import` no longer silently lose - the new issue while reporting success (#5, related #4). Three fixes: - `crosslink import` on a v3 hub now promotes the whole batch through the - event log in a single commit, so imported issues get reduction-assigned - display ids and are hub-visible like created issues; hydration no longer - lets a preserved local-only issue silently REPLACE (INSERT OR REPLACE) a hub - issue occupying the same id — the local row is remapped to a negative local - id with a warning and both survive; and `create` verifies the new issue is - actually visible in `SQLite` after hydration, failing loudly instead of - reporting a phantom id. Also starts v3 negative comment-id numbering below - preserved comment ids (the v3 analogue of #681). -- `crosslink sync` / `crosslink init` no longer fail on Git < 2.42.0 (e.g. - Ubuntu 22.04 LTS, which ships Git 2.34.1) with `unknown option 'orphan'`. - Hub-v3 and knowledge cache setup used `git worktree add --orphan` (added in - Git 2.42.0); on older Git they now fall back to an equivalent sequence - (detached worktree + `git checkout --orphan` + clearing the tree) that yields - the same empty unborn orphan branch (Corvidae-Coding-Projects/crosslink#655). -- The container entrypoint no longer `source`s `/host-auth/*.env` (gh#10). - Sourcing executed the file as shell: a token containing spaces or shell - metacharacters ran as commands, leaked credential fragments into the - container log, and silently truncated the exported value. The entrypoint - now parses the files line-by-line, honors only `CLAUDE_CODE_OAUTH_TOKEN` - and `ANTHROPIC_API_KEY` (optionally `export`-prefixed, quoted, or CRLF), - takes the value verbatim after the first `=` without executing or echoing - it, and reports `env-file:` auth resolution only when a recognized key was - actually found. -- The agent container image installs `build-essential` (gh#9). The - entrypoint's rustup install provided `rustc`/`cargo` but the image had no - C toolchain or linker, so `cargo test`/`clippy`/`check` failed at link - inside every container agent — one of the tracked contributors to the - gh#55 kickoff stall. - -## [0.9.0-beta.1] - 2026-06-13 - -> First beta of the 0.9 line. The 0.6.0-0.8.0 sections below were authored on -> their release branches and are reconciled into this shared history by this cut. - -### Breaking - -- **Hub v3: per-agent-ref coordination storage.** The shared `crosslink/hub` - branch with mutable JSON state is replaced by single-writer git refs - - visible branches `crosslink/agents/`, `crosslink/checkpoint`, - `crosslink/meta` - carrying append-only signed event logs reduced - deterministically to materialized state. Pushes to your own ref are always - fast-forward; the conflict can no longer occur, and ~10,000 lines of - rebase-retry/recovery machinery were deleted outright (guarded by a - permanent regression test). Mutations on un-migrated v2 hubs refuse with a - migrate prompt; v2 hubs remain readable forever for inspection and - migration. The checkpoint branch carries a browsable state tree (per-issue - JSON with inline comments, README, milestones) and each agent's branch is - its own visible activity/heartbeat feed. -- `crosslink serve` is deprecated in favor of `crosslink dashboard serve`. - -### Added - -- `crosslink migrate hub-v3` - verified one-shot migration (full-state gate - with automatic rollback; idempotent re-run doubles as push retry), adoption - path for machines whose remote was already migrated, `--finalize - --yes-delete-v2` hard cutover (survives corrupt legacy worktree - registrations), and `migrate hub-branches` for the hidden-refs-to-branches - rename. -- Full event sourcing: every issue/milestone mutation emits a signed event; - new event variants for comments, time entries, deletion tombstones, - milestones, and schedule changes, with deterministic display-id assignment. -- Dashboard - multi-project control panel (`crosslink dashboard serve`): - SCADA-style aggregation of every tracked project with live tiles, alerts - (stale locks, silent agents, overdue issues), full write surface with audit - trail, webhook alerting (Slack/Discord/JSON), CSV export, GitHub PAT - management, filesystem autodiscovery, and a git-native agent control - protocol (`crosslink agent request/poll-requests/requests`). -- Sentinel cpitd source: scheduled code-clone scans filing triage issues - (`sentinel.sources.cpitd.*`, default off) - plus pipx-first cpitd install - with actionable PEP 668 guidance (GH#621). -- `--contributor` attribution override on `knowledge add/edit/import` - (GH#628). -- `argument-hint` frontmatter on all arg-taking command templates (GH#626). - -### Fixed - -- Hook blocking messages now reach the model: exit-2 messages write to - stderr per the Claude Code hook contract (GH#624). -- Stray `.crosslink/` directories from cwd drift: hooks can no longer seed - them and discovery refuses to bind them (GH#625). -- Kickoff pipeline `runs` records are reconciled on completion instead of - accumulating stale `running` rows forever (GH#614). -- Hub-sync concurrency hardening: unique fsynced temp files, no live-holder - lock stealing, unified worktree locking (#750); plumbing ref writes pinned - immune to git signing config (GH#627). -- Hub-state recovery could rewrite a feature branch with hub contents when - the cache worktree link broke (GH#574); corrupt-JSON-committed-over-good- - history class eliminated by architecture (GH#616); unrelated-histories - divergence error eliminated (GH#629). -- `/design` first-run zsh glob failure - fixed templates; refresh deployed - copies with `crosslink init --force` (GH#615). - -## [0.8.0] - 2026-04-17 - -### Added - -#### Sentinel — Autonomous Maintenance Agent -- Full `crosslink sentinel` module — autonomous dispatch engine that monitors the repo and launches agents to fix issues ([GH-650]–[GH-672]) -- Database schema v16 migration for sentinel state ([GH-651]) -- `GitHubLabelSource` — poll GitHub labels to trigger dispatches ([GH-652]) -- 4-layer deduplication system to prevent redundant agent dispatches ([GH-653]) -- Dispatch engine with triage logic and kickoff integration ([GH-654]) -- Structured result collection with GitHub comment reporting ([GH-655]) -- `sentinel history` command for reviewing past dispatches ([GH-656]) -- Sonnet→Opus escalation on exhausted outcomes ([GH-657]) -- `sentinel watch` daemon mode for continuous monitoring ([GH-659]) -- `agent-todo:fix` dispatch rule with `VerifyLevel::Ci` and draft PR creation ([GH-660]) -- Per-agent in-flight details in `sentinel status` ([GH-661]) -- `InternalHygieneSource` — stale issues, orphan detection, unlabeled items ([GH-663]) -- `GitHubCISource` — poll failed CI workflow runs ([GH-664]) -- `sentinel metrics` command — success rates per model and dispatch rule ([GH-665]) -- `MaintenanceSweepSource` — lint drift and test regression detection ([GH-666]) -- Webhook receiver for real-time GitHub events ([GH-667]) -- Slack/webhook notifications for dispatch results ([GH-668]) -- Historical pattern detection for sentinel dispatches ([GH-669]) -- Self-tuning model selection from historical success rates ([GH-670]) -- Async watch loop with webhook server ([GH-672]) - -#### Integrity & Security -- `integrity sign-backfill` command for human attestation of unsigned hub entries ([GH-546]) -- Hub signing bootstrap deadlock fix for new repos ([GH-644]) - -### Fixed -- Display-ID and milestone counter collision from stale counters — reconcile against on-disk state ([GH-564]) -- V2 comment ID collision with preserved SQLite-only comments during hydration ([GH-563]) -- Sentinel: don't harvest dispatches while agent is still running ([GH-561]) -- Sentinel: keep watch daemon alive when stdin is `/dev/null` ([GH-561]) -- Kickoff: propagate `CLAUDE_CONFIG_DIR` through tmux to agent ([GH-555]) -- Dashboard: wire bearer-token auth for `crosslink serve` ([GH-556]) -- Init: respect `--dry-run` flag when combined with `--force` ([GH-542]) -- Init: scaffold `agent-prompt-server.py` during `crosslink init` ([GH-549]) -- Knowledge: read stdin body content in `knowledge add` ([GH-548]) -- Suppress unsigned warning for bootstrap locks commit -- QA audit — removed all `allow(dead_code)`, wired up unused code ([GH-671]) -- Zero clippy errors crate-wide under pedantic+nursery lints -- Clippy 1.95 strict lint compliance (`sort_by_key`, `map_or_else`, `doc_markdown`) - -### Changed -- Design doc gaps closed for V0/V1 — templates, `GH_TOKEN` handling, test coverage ([GH-662]) -- Crosslink-guide skill updated to match current CLI ([GH-547]) -- Init test coverage expanded to cover all three MCP servers ([GH-554]) - -## [0.7.0] - 2026-03-30 - -### Added -- `crosslink init --update` with manifest-tracked safe upgrades — tracks installed resource versions and applies incremental updates without overwriting user customizations -- First-class Shell/Bash support in language rules, detection, and hooks -- QA architectural review skill (`/qa`) shipped with `crosslink init` -- Team and solo configuration preset documentation - -### Fixed -- Full-codebase QA audit — 180+ fixes across security, correctness, and architecture: shell injection, fail-open hooks, CORS, transaction safety, hydration data loss, non-atomic writes, TOCTOU races, N+1 queries, and structural refactors (init.rs split, config registry extraction, `status.rs` → `lifecycle.rs`) -- `swarm merge --base` flag for repos without a `develop` branch -- `gh` added to allowed bash prefixes; session status caching in work-check hook -- `.hub-write-lock` excluded from git tracking to prevent recovery commit loop -- Consistent signing bypass for all hub-cache commits -- Resolved clippy pedantic and nursery warnings across codebase - -### Changed -- `init.rs` split into `init/mod.rs`, `init/merge.rs`, `init/python.rs`, `init/signing.rs`, `init/walkthrough.rs` for maintainability -- Config command logic extracted to `config_registry.rs` -- `status.rs` renamed to `lifecycle.rs` -- Shared error helpers module added to server (`server/errors.rs`) -- TUI tabs refactored with shared helpers to reduce duplication - -## [0.6.0] - 2026-03-24 - -### Added - -#### Kickoff & Multi-Agent -- Unified design-plan-run pipeline UX for `crosslink kickoff` — bare `kickoff` launches interactive wizard ([GH-445]) -- `crosslink kickoff graph` command for branch topology visualization with merge detection ([GH-502]) -- Compact base62 naming for agents, branches, and tmux sessions — `--` format ([GH-494]) -- Generate dedicated signing keys and auto-approve kickoff subagents ([GH-511]) -- Add CLI command and MCP tool for reliable tmux prompt delivery to agents ([GH-513]) -- Auto-detect and configure lint/test commands for kickoff agents based on project tooling ([GH-515]) - -#### Config System -- Config system overhaul with registry-based validation, layered loading (defaults → team → local), provenance tracking, and TUI inline editing ([GH-490]) -- `--team` / `--solo` presets and ConfigGroup categorization (Workflow, Security, Infrastructure, Agents) - -#### Workflow Enforcement -- Enforce comment discipline via hooks — `git commit` and `issue close` require typed comments ([GH-512]) -- Lazy auto-hydration on read when hub branch ref moves — no more explicit `crosslink sync` for reads ([GH-514]) - -#### Onboarding -- Improve first-use experience and onboarding clarity ([GH-516]) -- Add jj (Jujutsu) read-only commands to allowed_bash_prefixes ([GH-517]) - -### Fixed - -#### Hub Stability -- Hub stability bundle — self-healing health checks, FK protection, lock verification, orphan cleanup ([GH-464]) -- Hub structural fixes — lock serialization, promotion tracking, lock ownership refactor ([GH-482]) -- Hub health check — remove index.lock first, escalate detached HEAD recovery ([GH-483]) -- Stage untracked files before fetch to prevent heartbeat race ([GH-488]) -- Replace `--amend` with new revert commit in promotion rollback ([GH-463]) - -#### Agent Coordination -- Persist tmux session name so `kickoff list` detects swarm-launched agents ([GH-510]) -- Fall back to driver signing key when agent key is missing after cleanup ([GH-509]) -- Stabilize local ID assignment for offline issues across re-hydration ([GH-508]) -- Handle branch name collision from prior kickoff/swarm phases ([GH-487]) -- Integrity locks `--repair` releases stale locks instead of stealing them ([GH-497]) - -#### Init & Config -- `crosslink init` verifies git repo and initial commit exist before proceeding ([GH-489]) - -### Changed -- Config loading is now layered: embedded defaults → `.crosslink/hook-config.json` (team) → `.crosslink/hook-config.local.json` (local). Pre-overhaul configs are fully backward-compatible. -- New agent IDs, branch names, and tmux sessions use compact `--` format. Existing agents are preserved. - -### Known Issues -- `crosslink serve` dashboard frontend is not included in `cargo install crosslink` — build from source or use release binaries ([GH-429]) - -## [0.5.2] - 2026-03-19 - -### Added - -#### External Source Querying -- `crosslink issue --from` and `crosslink knowledge --from` flags for querying external repositories ([CL-206], [GH-428]) -- External repository resolution with GitHub API integration - -#### Init & Onboarding -- Greenfield scaffold with interactive design questions and CLAUDE.md template generation ([CL-369]) -- `crosslink-guide` Claude skill for in-session feature discoverability ([GH-404]) -- Implementation rigor guardrail with auto-discovered rule files ([GH-366]) - -#### Swarm Enhancements -- Multi-swarm UUID data model and swarm lifecycle commands (`swarm create`, `swarm list`, `swarm switch`) ([CL-371]) -- Swarm plan editing commands: `move`, `merge`, `split`, `remove`, `reorder`, `rename` ([CL-373]) -- Explicit layer/phase header support in `swarm init` design doc parsing ([CL-373]) - -#### CLI Enhancements -- `--json` support for `swarm status`, `session status`, `issue tree`, `blocked`, `ready`, and `next` commands ([CL-375], [CL-377]) -- Local time displayed alongside UTC timestamps in TUI issue detail ([GH-402]) -- Token-budget-aware behavioral guard reinjection for long sessions ([CL-384]) - -#### Testing -- Concurrency, coordination, and lifecycle smoke test suites ([GH-364]) -- Dashboard unit tests (App, color utilities, general utils) with Vitest ([GH-364]) -- VS Code extension platform detection tests ([GH-364]) - -#### Build & Infrastructure -- Integrity layout check and centralized hub file path constants ([GH-428]) -- Auto-discover rule files and command files from resources directories in `build.rs` ([CL-387]) - -### Fixed -- investigate two mcp server failures reported by user (#722) -- Signing: human key always signs hub commits; agent keys only for identity (#718) -- Fix piped shell commands in skill templates that fail permission checks (#254) -- Fix hub cache recovery loop caused by tracked .hub-write-lock file (#634) -- Fix auth token refresh (#146) -- Alert lock actions: surface holder + disambiguate Release vs Steal (#716) -- Dashboard InitBanner doesn't refresh on init success + track-all init fails on subset of repos (#715) -- Clone target should be $HOME/, not $HOME// (#714) -- Resolve crosslink binary via PATH first, not current_exe (#713) -- InitBanner error 'spawn crosslink init' hides the real underlying cause (#712) -- Change default clone_root from ~/crosslink-tracked to $HOME (#711) -- Alerts page mutations don't invalidate alerts query; orphan_subissue close appears to no-op (#709) -- Dashboard polish: token refresh UX + issue ordering + remote-repo hub data missing (#707) -- Dashboard: project detail page blank on click + gh CLI auth fallback (#706) -- Fix crosslink init: deploy agent-prompt-server.py (GH#554) (#677) -- Fix kickoff env propagation: CLAUDE_CONFIG_DIR not reaching tmux agent (GH#555) (#676) -- Fix dashboard auth: wire API client to bearer token (GH#556) (#675) - -#### Hub & Sync -- V1/V2 hub layout coexistence — resolve inconsistent write paths and cache corruption ([GH-428]) -- Prevent hydration data loss and resolve `--parent` cache lookup failure ([GH-427]) -- Preserve local-only close events during sync fetch ([GH-430]) -- Preserve session work state across hydration cycles ([GH-443]) -- Prevent hub cache corruption and ensure agent issue mutations sync correctly ([CL-372]) - -#### CLI & Display -- Render local issues as `Ln` instead of `#-n` across all commands ([GH-424]) -- Resolve worktree paths relative to main repo root, not pwd ([GH-425]) -- Recognize local issue `L-` prefix in `work-check` hook ([CL-370]) -- Detect Claude Code sub-agent worktrees in `is_agent_context` ([CL-420]) -- Resolve main repo root in `kickoff show-plan` for worktree support ([CL-374]) - -#### Reliability -- Make `delete_issue` atomic — restore files on commit failure ([GH-427]) -- Migrate `eprintln!` to `tracing` in sync/hydration paths to prevent TUI screen corruption ([GH-447]) -- Replace `unwrap`/`expect` with proper error handling in `build.rs` and sync paths ([CL-206]) -- Make `detect_hostname` test non-flaky in parallel test suites - -#### Adversarial Review Fixes -- Apply all Group A correctness fixes from adversarial review ([CL-364]) -- Resolve clippy errors across Group A fixes ([CL-364]) -- Replace lazy `INTENTIONAL` suppressions with `eprintln!` warnings ([CL-L6]) -- Add `INTENTIONAL` comments to deliberate error suppression patterns ([CL-419]) - -### Changed -- Release v0.6.0 — version bump, changelog, docs, tests (#258) -- Add VHS tape and screenshot scripts for docs visuals (#131) -- Manual test container-based agent execution (#73) -- Configure GitHub rulesets for release/* branches and create RELEASING.md (#170) -- Repo cleanup: migrate design docs to knowledge, move docs, remove root scripts (#171) -- Configure GitHub rulesets for release/* branches and create RELEASING.md (#170) -- Repo cleanup: migrate design docs to knowledge, move docs, remove root scripts (#171) -- Configure GitHub rulesets for release/* branches and create RELEASING.md (#170) -- Repo cleanup: migrate design docs to knowledge, move docs, remove root scripts (#171) -- Configure GitHub rulesets for release/* branches and create RELEASING.md (#170) -- Repo cleanup: migrate design docs to knowledge, move docs, remove root scripts (#171) -- P1.11: agent request protocol — hub-branch agents//requests/*.json + agent-side polling lib + kill/pause/resume/reprioritise verbs + audit log (#700) -- P1.10: lock controls — claim, release, steal (#699) -- P1.9: write surface — milestones + relations + blockers (#698) -- P1.8: write surface — issues + labels (close, reopen, comment, update, label, unlabel) (#697) -- P1.7: alert UI — banner, sidebar rail, /alerts page, desktop notifications (#696) -- P1.6: alert engine — derived alerts (stale_lock, silent_agent, overdue_issue, ci_failure, unreachable, signature_invalid, orphan_subissue, hub_diverged, hub_parse_error, untrusted_signer, pending_request, compaction_lag) (#695) -- P1.5: WebSocket live updates — push change notifications, frontend refetch-on-signal (#694) -- P1.4: project grid (read-only frontend) — tile component, fetch projects, show counts (#693) -- P1.3: REST API — projects + activity endpoints, bearer auth (#692) -- P1.2: poll loop + single-project index — git-fetch every 5s, populate projects/project_state/activity (#691) -- P1.1: scaffolding — crosslink dashboard subcommand + serve deprecation + empty SPA + SQLite bootstrap (#690) -- Manual QA for PR #553 sentinel run + webhook tests (#678) -- Manual QA for PR #553 sentinel run + webhook tests (#678) - -#### Codebase Decomposition -- Decompose 6 god files into focused submodules — `shared_writer.rs`, `kickoff.rs`, `db.rs`, `sync.rs`, `knowledge.rs`, `commands/knowledge.rs` ([CL-413]) -- Split `kickoff.rs` into 10 submodules (conventions, container, plan, prompt, report, status, watchdog, worktree, wizard, cleanup) ([CL-413]) -- Split `commands/knowledge.rs` into `mod.rs` + `operations.rs` ([CL-413]) -- Properly wire all swarm paths through `SwarmContext`, remove `allow(dead_code)` annotations - -#### Observability -- Migrate logging from `eprintln!` to `tracing` crate across sync, hydration, and daemon modules ([GH-364], [GH-447]) -- Route tracing output to stderr to avoid polluting structured CLI output - -## [0.5.1] - 2026-03-15 - -### Added - -#### Swarm Review System -- `swarm review` command for parallel adversarial codebase exploration ([GH-342]) -- `swarm fix` command for parallel issue-to-agent fix execution ([GH-345]) -- `swarm merge` subcommand for combining parallel agent worktree changes ([GH-346]) -- End-to-end `swarm review --fix` pipeline orchestrator ([GH-348]) -- Finding consolidation and deduplication for swarm reviews ([GH-343]) -- Seam detection and codebase auto-partitioning module ([GH-341]) -- Trust model configuration for swarm review triage ([GH-347]) -- Automatic GitHub issue creation from swarm review findings ([GH-344]) - -#### Testing -- Adversarial smoke test harness with 134 tests ([GH-354]) -- Test coverage boosted to 92.73% ([GH-355]) - -#### Language Support -- First-class Elixir support in kickoff conventions, context detection, hooks, and command docs ([GH-352]) - -#### CLI Enhancements -- `--skip-permissions` flag for `kickoff run` ([GH-357]) -- Git commit hash included in version string ([GH-339]) - -#### Build Tooling -- `justfile` with `render-docs` pipeline: SVG generation, quarto render via staging dir, collision detection for manually-maintained docs, asset lint for broken images/styles/scripts/links ([CL-192], [CL-193]) - -### Fixed -- Missing banner and wordmark SVGs in docs site deployment — banner ref escaped the `docs/` boundary, wordmark not copied by Quarto ([CL-192]) -- Swarm launch failure — write `.kickoff-status` sentinel on launch and treat missing status as failed ([GH-359]) -- V2 comment hydration bug found during smoke testing ([GH-354]) -- Swarm gate worktree bug ([GH-355]) -- Update resource templates and hooks to use canonical `crosslink issue` syntax ([GH-338]) -- 9 Windows compatibility issues across codebase ([GH-337]) -- Windows clipboard support via `clip.exe` ([GH-325]) - -### Changed -- Remove all `#[allow(dead_code)]` annotations and wire in unused code -- Update CLAUDE.md to document canonical CLI syntax, `--quiet`, and `--json` flags ([GH-338]) -- Docs CI workflow now uses `just render-docs` with asset verification gate - -## [0.5.0] - 2026-03-11 - -### Added - -#### Web Dashboard (`crosslink serve`) -- `crosslink serve` subcommand with axum HTTP server scaffold ([GH-290]) -- React Vite dashboard scaffold with TypeScript, TailwindCSS 4, and shadcn/ui components -- Agent monitoring REST endpoints and real-time WebSocket updates with filesystem watcher -- Agent list page with AgentCard component and agent detail drilldown with HeartbeatTimeline and LockList -- Issues CRUD REST endpoints, issue list and detail views, session management UI -- Label manager, dependency editor, and bulk issue operations -- Sessions, milestones, knowledge, search, sync, and config REST API endpoints -- Knowledge browser, milestones, and command palette pages -- Sync dashboard, config editor, and lock visualization -- Usage graphs and cost breakdown components with token usage collection and storage -- DAG and Gantt visualization for orchestrator execution -- Execution controls and live monitoring components -- Document import, stage editor, and LLM-assisted document decomposition orchestrator -- DAG execution engine with topological sort and executor lifecycle management -- Appearance settings page and orchestrator endpoint wiring - -#### CLI Enhancements -- `crosslink prune` command for hub/knowledge history pruning ([GH-297]) -- `crosslink kickoff cleanup` command for pruning stale worktrees and tmux sessions ([GH-298]) -- `crosslink kickoff list` command with worktree, tmux, and Docker discovery -- Refactor CLI into `issue`/`timer`/`migrate` subcommand groups ([CL-157]) -- Watchdog sidecar to nudge idle kickoff agents - -#### Knowledge Management -- `--replace-section` and `--append-to-section` flags for `knowledge edit` command ([GH-264]) - -#### TUI Improvements -- Startup sync, periodic background sync, and manual `r` keybinding for refresh ([CL-169]) - -#### CI -- Fix CI concurrency groups and repo cleanup ([GH-287], [GH-291]) - -### Fixed -- Address 14 findings from adversarial codebase review -- Restore view state on issue detail back navigation and clamp scroll bounds ([GH-293]) -- Add agent init verification and sync steps to kickoff instructions ([GH-289]) -- `ssh-keygen` verify checks both stdout and stderr, allow unsigned hub writes ([GH-299], [GH-301]) -- Replace `unwrap()` calls with `ok_or_else()` for strict clippy CI compliance -- Resolve duplicate Agent type and stale field names in dashboard types -- Fix clippy warnings in adversarial review fixes - -### Changed -- Move `.crosslink/` ignores to inner `.gitignore` ([CL-175]) - -## [0.4.0] - 2026-03-10 - -### Added - -#### Swarm Orchestration (`crosslink swarm`) -- `crosslink swarm init/status/resume` commands for multi-agent swarm lifecycle (Phase 1, [GH-233]) -- `crosslink swarm launch/gate/checkpoint` commands for coordinated agent execution (Phase 2, [GH-233]) -- Swarm budget estimation and throttling (Phase 3, [GH-233]) -- Swarm multi-window planning (Phase 4, [GH-233]) - -#### Mission Control -- `crosslink mission-control` command for monitoring active agents in a unified dashboard - -#### Agent Hooks & Liveness -- Agent-aware hooks with git flag bypass fix ([GH-164], [GH-226]) -- Hook-based heartbeats for kickoff agent liveness detection -- Custom sandbox wrapper support for agent isolation (alternative to Docker) - -#### Kickoff & Preflight -- Unified preflight check with macOS `gtimeout` support for kickoff command -- Platform-specific remediation hints for preflight dependency checks ([GH-260]) - -#### TUI Improvements -- Async data loading for TUI agents and config tabs ([GH-254]) -- Table scroll-to-follow across all tabs ([GH-240]) - -#### Knowledge & Search -- Word-level fuzzy matching for knowledge search ([GH-263]) - -#### CI -- Tiered smoke tests for CI pipeline ([GH-242]) -- Restrict fuzz tests to release branches and PRs targeting main - -### Fixed -- Mission-control pane liveness and auto-attach robustness -- Load `knowledge.md` rules into Claude prompt via `prompt-guard.py` -- Publish parent SSH key under kickoff agent ID after creation ([GH-261]) -- Degrade gracefully when `gpg.ssh.allowedSignersFile` is not configured ([GH-262]) -- Replace `unwrap()` calls with proper error handling for strict clippy compliance -- Restore deleted tests and update preflight test signatures -- Swarm coherence fixes across all 4 phases ([GH-233]) -- Skip headings inside code fences in design doc parser ([GH-248]) -- Simplify drift reminder to fixed 3-turn interval -- Pre-flight check for required external commands in kickoff - -### Changed -- Clean up feature worktrees and tmux sessions (#180) -- README updated with multi-agent orchestration, swarm, kickoff, knowledge, TUI, and hooks features - -## [0.3.0] - 2026-03-05 - -### Added - -#### Kickoff & Agent Orchestration -- `crosslink kickoff` CLI command with local and container agent execution ([GH-175]) -- Design document parser and `--doc` flag for kickoff, importing design docs to knowledge ([GH-215], [GH-216]) -- `crosslink kickoff plan` subcommand for read-only gap analysis -- Spec validation loop for structured validation of kickoff agents ([GH-217]) -- Structured machine-readable build reports for kickoff agents ([GH-219]) - -#### Knowledge Integration -- Structured queries, bulk import, MCP server, and auto-inject for knowledge branch ([GH-221]) -- `--from-doc` flag on `knowledge add` for design doc import - -#### CLI & Workflow -- `/design` skill for interactive design document authoring ([GH-225]) -- `/maintain` skill for codebase maintenance ([GH-205]) -- Configurable auto-steal for stale locks ([GH-223]) -- Atomic lock claims — bail on contended lock claims in `session work` to close timing race ([GH-224]) -- `rules.local/` directory for gitignored local rule overrides ([GH-234]) -- Configurable git remote for hub/knowledge branches via `tracker_remote` setting ([GH-235]) -- VHS tape files and screenshot scripts for docs visuals ([GH-227]) - -#### Code Quality -- Module dispatch refactor — extract monolithic dispatch from `main.rs` into module-level `run()` functions ([GH-222]) -- Extract pure functions from kickoff module for testability ([GH-209]) -- Dry-run integration tests and `build_prompt` unit tests for kickoff ([GH-214]) -- Unit tests for shared writer lock claim, release, and contention ([GH-208]) -- Clock skew detection using git commit timestamps as witness ([GH-173]) - -### Fixed -- Agent signing chicken-and-egg during init — defer key publish and use unsigned bootstrap commits ([GH-237]) -- TUI agents tab not forming agent list — read V2 heartbeats and refresh data on tab focus ([GH-232]) -- Milestone add/remove now persists to coordination branch ([GH-174]) -- Hub cache hooks — propagate canonical integration hooks into the hub cache worktree on init ([GH-213]) -- Hub sync dirty state — deduplicate hub cache issues during hydration, prevent vicious sync loop ([GH-210]) -- Hub sync push warnings — surface visible warnings when push falls back to local-only ([GH-206]) -- Use `--worktree` scope for agent signing config in linked worktrees ([GH-167]) -- Skip worktree agent init and tmux/claude prerequisite checks in dry-run mode - -### Security -- Security check: verify allowed_signers on meta branch is public-key-only (#768) -- VS Code extension security hardening (E1-E3) ([GH-169], [GH-175]) -- CI/CD fuzz testing improvements (T1-T5) ([GH-168]) - -### Changed -- Restrict proptest CI job to release branches and PRs to main to reduce CI minutes ([GH-228]) - -## [0.2.0] - 2026-03-03 - -First stable release. Promotes all changes from v0.1.3-beta.1 plus major new features. - -### Added - -#### Terminal UI (`crosslink tui`) -- `crosslink tui` command — read-only terminal dashboard for browsing crosslink data ([GH-152]) -- Issues tab with tree view, detail view, filtering, and sorting -- Agents tab with active session monitoring -- Knowledge tab with page browser and vivid syntax highlighting -- Milestones tab and Config tab -- Mouse support, command palette (`Ctrl-P`), and clipboard export -- Keyboard navigation with help overlay (`?`) - -#### Container-Based Agent Execution -- `crosslink container` command for isolated agent environments ([GH-110]) -- Container bootstrap command for setting up agent containers -- Updated `/kickoff` and `/check` skills to support container backend - -#### Context Management -- `crosslink context` command — context injection optimization with skills, adaptive reminders, and measurement - -#### Hub Layout V2 -- Compact command, `--defer-id`, lock timeout, V2 stale detection (Phase 5) ([GH-113]) -- Event-based lock confirmation protocol (Phase 3) -- Container bootstrap command (Phase 4) -- SharedWriter updated for v2 hub layout paths ([GH-132]) - -#### Knowledge Management -- Accept-both conflict resolution for knowledge branch sync/push -- Knowledge management prompting norms for agents -- KnowledgeManager for shared research via `crosslink/knowledge` branch with page CRUD and full-text search ([GH-62], [GH-63]) - -#### Init Experience -- Ratatui-based setup wizard with styled progress output for `crosslink init` -- Confirmation screen, taller viewport, and smoother progress output -- Inline TUI rendering with clean Esc cancel -- Interactive TUI walkthrough with `--defaults` and `--reconfigure` flags ([GH-60]) -- Managed `.gitignore` section injected by `crosslink init` -- Document blocked actions and lint checks in default init templates - -#### SSH Signing -- SSH signing foundation — agent key generation, driver key setup, per-commit signing, allowed_signers management ([GH-71]-[GH-76]) -- Track driver signing key fingerprint in interventions and trust approvals - -#### Other -- House style syncing — `crosslink style set/sync/diff/show/unset` for portable project conventions ([GH-91]) -- Consolidated `crosslink config` command — show, get, set, list, reset, diff with typed validation -- Typed comments and auto-documentation trail — comments carry `kind`, `trigger_type`, and `intervention_context` -- Driver intervention tracking with `crosslink issue intervene` command -- cpitd (code clone detection) integration with `crosslink cpitd` command -- Scope session queries to `agent_id` for multi-agent isolation -- Crates.io publish CI workflow on release tags -- GitHub Pages CD workflow for docs site - -### Fixed -- Address TUI adversarial review findings (C1, C2, H2-H4, M3-M7) -- Resolve `.crosslink` directory through git worktrees so hooks work in worktree checkouts ([GH-131]) -- Make hooks resilient to invocation from non-project directories -- Resolve container startup failures found during manual testing -- Resolve `sessions_new` migration batch error from wrong pragma column name ([GH-138]) -- Allow `agent init` to reinit when existing `agent.json` is malformed ([GH-137]) -- Auto-claim lock in `quick`/`create --work` to match `session work` behavior -- Configure fallback git identity in hub cache for CI environments -- Re-check lock ownership after push conflicts in `claim_lock` -- Guard against clock skew in stale lock detection -- Use atomic write (temp + rename) for issue and lock files -- Register SIGTERM/SIGINT handlers for graceful daemon shutdown -- Generate UUID at local issue creation time (not deferred) - -### Security -- Enforce restrictive Unix permissions (0600) on generated SSH keys ([GH-105]) -- Validate key type and principal format in `allowed_signers` parser -- Parse `ssh-keygen` stderr and add timeout to signature verification -- Prevent path traversal in knowledge page names -- Add 10MB file size limit on JSON import -- Add maximum length validation for string inputs (512-char titles) -- Require minimum 3-character agent IDs -- Stop `crosslink init` from overriding project worktree signing key - -### Changed -- Restructure CI/CD workflows for git flow branching model (develop/feature/release/hotfix) -- Add hotfix/release branch triggers and PRs-to-main to CI workflow -- Decouple publish.yml from ci.yml, rely on branch protection -- Rename `crosslink review` subcommand to `crosslink workflow` -- Untrack auto-generated `.claude` and `.crosslink` files from git -- Comprehensive documentation review and quarto site updates -- Full-system adversarial review - -## [0.1.2-alpha.1] - 2026-02-27 - -### Fixed -- SyncManager now detects when running inside a git worktree and reuses the main repo's hub cache instead of trying to create a duplicate `crosslink/hub` worktree (#41) -- Set git user config in test helper for CI compatibility - -### Changed -- Kickoff skill (`/kickoff`) now supports `--verify` flag with three levels: `local` (default), `ci`, and `thorough` for post-implementation verification (#39) -- Updated `/kickoff` and `/featree` skill permissions to cover all tools used during execution (added `Write`, `Read`, `Bash(echo *)`, `Bash(crosslink *)`) (#42) -- Featree skill now uses `crosslink init --force` and `crosslink sync` instead of manual database symlinking for worktree initialization (#42) - -### Removed -- Stale `.chainlink/` directory (legacy issue tracker artifacts) -- Tracked `crosslink/.crosslink/issues.db` (should be local-only) - -## [0.1.1-alpha.1] - 2026-02-26 - -### Multi-Agent Collaboration - -Distributed issue locking and agent coordination, ported from crosslink-enterprise. - -#### Agent Identity -- `crosslink agent init ` — register a machine-local agent identity (stored in `.crosslink/agent.json`, gitignored) -- `crosslink agent status` — show agent identity and currently held locks - -#### Distributed Locking -- `crosslink locks list` — show all active issue locks with stale detection -- `crosslink locks check ` — check if a specific issue is available or claimed -- `crosslink sync` — fetch lock state from the `crosslink/locks` coordination branch, verify GPG signatures, display cache path and commit info - -#### Lock-Aware Workflows -- `crosslink next` now skips issues locked by other agents -- `crosslink session work ` enforces lock ownership before allowing work -- `crosslink create --work` and `crosslink subissue --work` check locks before claiming -- Session start records agent identity in the database (schema v8 to v9 migration) - -#### Daemon Heartbeat -- Daemon pushes agent heartbeat every 2.5 minutes to the coordination branch -- Stale lock detection based on heartbeat freshness - -#### Hook Enhancements -- `session-start.py` runs `crosslink sync` and displays active locks on startup -- `work-check.py` warns (in strict mode) when working on an issue locked by another agent - -#### Init Improvements -- `crosslink init` now writes `.crosslink/.gitignore` to exclude machine-local files (`agent.json`, `.locks-cache/`) - -### Claude 4.6 Opus Optimization Epic (#99) - -Comprehensive overhaul to make crosslink work seamlessly with Claude 4.6 Opus, -reducing tool-call overhead, improving machine-parseable output, and adding -context-compression resilience. - -#### CLI Enhancements -- `crosslink quick` compound command — create + label + work in one call (#100) -- `--json` output flag on show command for structured machine-readable output (#101) -- `--quiet` / `-q` mode for minimal, pipe-friendly output (#108) -- `--work` and `--label` flags on `create` and `subissue` commands (#104) -- `close-all` batch command with label and priority filtering (#107) - -#### Session & Context Management -- Stale session auto-detection and cleanup (auto-ends sessions idle >4 hours) (#102) -- Context compression breadcrumbs via `session action` — records last action, auto-comments on active issue, and restores context on resume (#111) -- PreToolUse hook nudges agent when no active working issue is set (#105) - -#### Templates & Rules -- Three new AI-specific issue templates: `audit`, `continuation`, `investigation` (#110) -- Condensed behavioral guard mode — lighter rule injection after first prompt (#103) -- Reorganized rules into tiered priority system (critical/standard/optional) (#109) - -#### Hooks -- Debounced linting mode in post-edit hook to reduce noise (#106) - -#### Code Quality -- Fix all clippy warnings (introduced `CreateOpts` struct, removed dead imports, idiomatic Rust patterns) (#112) -- Database schema v7→v8 migration (adds `last_action` column to sessions, auto-applied on first use) - -### Added -- Add git clone fallback for cpitd install (#6) -- Add `crosslink integrity` subcommand with `--check` and `--repair` modes (#31) -- Add `--check` flag to `review diff` for CI policy drift detection (#28) -- Add kickoff workflow skills (`/feature`, `/featree`, `/kickoff`, `/check`) to `crosslink init` (#26) -- Add `+key` array-extend semantics in `hook-config.local.json` (#25) -- Add offline issue ID promotion flow with `crosslink promote` (#24) -- Add promotion notifications with reference tracking (#27) -- Add auto-detection of Python toolchain in `crosslink init` and template hook commands (#36) -- Update `crosslink export` to emit per-issue `IssueFile` JSON format (#32) -- Redesign milestones to per-file storage for conflict-free multi-agent writes (#35) -- Deduplicate config-loading logic into shared `crosslink_config.py` module (#29) -- Add `crosslink review diff` slash command for guided policy review (#7) -- Add `hook-config.local.json` support for machine-local overrides (#5) -- Add multi-agent shared issue coordination via `crosslink/hub` branch (#6) -- Add auto-detection of Python toolchain in crosslink init (#21) -- Update READMEs with hook configuration documentation (#119) -- Split tracking instructions into per-mode markdown files (#118) -- Make issue tracking strictness configurable (#117) -- Make blocked git commands user-configurable in work-check hook (#116) -- Update all dependencies to latest versions (#114) -- Add comprehensive edge case testing (proptest, CLI fuzzing, Unicode E2E) (#50) -- Improve session management with auto-start and stronger rules (#48) -- Add sanitizing MCP server for safe web fetching (#47) -- Add macOS binary support to VSCode extension with cross-compilation (#32) -- Auto-create CHANGELOG.md if it doesn't exist when closing issues -- Automatic CHANGELOG.md updates when closing issues (based on labels) -- `--no-changelog` flag to skip changelog entry for internal work -- `crosslink export` now outputs to stdout by default, use `-o` for file output - -### Fixed -- Fix hooks to always find parent .crosslink directory regardless of cwd (#123) -- Fix CI test failure on latest commit (#122) -- Fix vscode engine version to match @types/vscode (#115) -- Fix SQL injection vulnerability in milestone listing (#97) -- Fix cargo-mutants artifact left in production code (#97) -- Fix byte/char length mismatch for Unicode text truncation (#97) -- Fix tree view not filtering subissues by status (#97) -- Fix markdown export silently dropping archived issues (#97) -- Fix daemon log file corruption from duplicate file handles (#97) - -### Changed -- Rename coordination branch from `crosslink/locks` to `crosslink/hub` (#37) -- Optimize CI with tiered job dependencies to save minutes on early failures (#33) -- Rebrand chainlink to crosslink (#4) -- Fix display ID collision in rebase-retry logic (#21) -- Block git mutation commands via hook (#113) -- Fix wrong assertion directions and tautological property tests (#96) -- Fix overly loose CLI integration test assertions (#95) -- Fix display function tests to verify actual output or DB state (#94) -- Add unit tests for session.rs command (#64) -- Add security-focused tests (#82) -- Add unit tests for show.rs command (#58) -- Add unit tests for delete.rs command (#57) -- Add unit tests for update.rs command (#56) -- Add unit tests for label.rs command (#61) -- Add unit tests for status.rs command (#60) -- Add unit tests for search.rs command (#59) -- Add unit tests for models.rs (#75) -- Add unit tests for comment.rs command (#62) -- Add unit tests for create.rs command (#55) -- Add Unicode E2E integration tests (#53) -- Add CLI-layer fuzz target for list/show output (#52) -- Add proptest for string handling functions (#51) -- Issue titles are now expected to be changelog-ready (verb + description) - -## [1.4] - 2026-01-08 - -### Added -- Project infographic for README - -### Fixed -- Audit and fix tautological tests and logical flaws in test suite (#92) -- Fix UTF-8 panic in list truncation (#49) -- Fix macOS cross-compilation linker configuration (#34) -- Import/export roundtrip issues with parent relationships - -## [1.3] - 2026-01-07 - -### Added -- Elixir and Phoenix language rules (community contribution from @Viscosity4373) -- Build system automatically rebuilds Rust binaries when packaging extension -- Improved global.md defaults for AI agents - -### Fixed -- Extension binary update detection (now always overwrites) -- Packager issues - -## [1.2] - 2026-01-05 - -### Added -- VSCode extension for seamless integration -- Agent-agnostic context provider (works with any AI assistant) -- Fuzzing targets for security testing (fuzz_create_issue, fuzz_import, fuzz_search, fuzz_dependency_graph, fuzz_state_machine) -- Property-based testing with proptest -- Cross-platform CI (Windows, macOS, Linux) -- Database corruption recovery -- Daemon auto-start on session start -- ~88% code coverage - -### Security -- Add web.md prompt injection defense rule for external content (#33) -- Bump qs dependency to fix vulnerability - -### Fixed -- Path handling issues on Windows -- Various edge cases found through fuzzing -- Test reliability improvements - -## [1.1] - 2025-12-28 - -### Added -- Issue templates (bug, feature, refactor, research) -- Hook-based test reminder system -- Export/Import functionality (JSON format) -- Milestones for grouping issues -- Issue archiving for completed work -- Best practices rules for 15 programming languages: - - Rust, Python, JavaScript, TypeScript, Go, Java, C#, C++ - - Ruby, PHP, Swift, Kotlin, Scala, Haskell -- Composable rules system for better maintainability - -### Fixed -- Language detection now checks subdirectories - -## [1.0] - 2025-12-27 - -### Added -- Initial release -- Core issue management (create, show, update, close, reopen, delete) -- Issue hierarchy with subissues -- Labels and comments -- Dependencies (block/unblock) -- Issue relations (relate/unrelate) -- Session management with handoff notes -- Timer for time tracking -- Tree view for issue hierarchy -- Search functionality -- Priority levels (low, medium, high, critical) -- SQLite storage (`.crosslink/issues.db`) -- Claude Code hooks integration -- Smart navigation suggestions -- `crosslink next` command for work suggestions - -## Project Goals - -Crosslink is designed to be: -- **Simple**: No complex setup, just `crosslink init` -- **Lean**: Single binary, SQLite storage, no external dependencies -- **AI-First**: Built for AI-assisted development workflows -- **Context-Preserving**: Session handoff notes survive context resets +- Releases are one gated flow (gh#126, gh#122, gh#123). A `v*` tag must match + `crosslink/Cargo.toml` and must not already be published (versions are never + republished). It pushes only `:`; the smoke test pulls that exact + digest anonymously and checks that every pushed tag resolves to it, so a + private package or a missing tag fails CI; `:latest` then moves to the + verified digest, one promotion at a time, and only when the version is the + highest stable release. The GitHub release is created only after the image + passed, with Linux (x86_64, aarch64), macOS (aarch64, x86_64) and Windows + binaries, the Codex plugin archives, `SHA256SUMS`, and build attestations. + Images carry their crosslink version as a label and a build attestation. + Write permissions are granted only to the publishing jobs, whose actions are + pinned by commit. Manual publishes run only from `develop`, and a manual run + can rehearse the promotion against `:promotion-rehearsal`. A daily canary + checks, anonymously, `:nightly`, every release's `:` and + `:latest`, and that the frozen `ghcr.io/forecast-bio/crosslink-agent:latest` + 0.9.0-beta.1 uses still names the same image. +- The agent image pins its base image by digest and its tools by version + (GitHub CLI, Codex, Claude Code, uv, gosu), and verifies gosu by checksum, so + two builds of the same commit contain the same tools (gh#128). diff --git a/crosslink/resources/container/Dockerfile b/crosslink/resources/container/Dockerfile index 4d3ff40f8..bb5cf7849 100644 --- a/crosslink/resources/container/Dockerfile +++ b/crosslink/resources/container/Dockerfile @@ -6,12 +6,18 @@ -FROM ubuntu:24.04 - - +# Everything the image installs is pinned, so two builds of the same commit +# contain the same tools. Bump a version (and its checksum) deliberately. +FROM ubuntu:24.04@sha256:534baea6a22c03a63003dbc8dbe78fe34bc0d7e595d9a9dc9834884ff530eb55 ARG TARGETARCH=amd64 +ARG GH_VERSION=2.102.0 +ARG CODEX_VERSION=0.160.1 +ARG CLAUDE_CODE_VERSION=2.1.292 +ARG UV_VERSION=0.12.23 ARG GOSU_VERSION=1.17 +ARG GOSU_SHA256_AMD64=bbc4136d03ab138b1ad66fa4fc051bafc6cc7ffae632b069a53657279a450de3 +ARG GOSU_SHA256_ARM64=c3805a85d17f4454c23d7059bcb97e1ec1af272b90126e79ed002342de08389b @@ -26,12 +32,12 @@ RUN mkdir -p -m 755 /etc/apt/keyrings \ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \ && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \ - && apt-get update && apt-get install -y --no-install-recommends gh \ + && apt-get update && apt-get install -y --no-install-recommends "gh=${GH_VERSION}" \ && rm -rf /var/lib/apt/lists/* -RUN npm install -g @openai/codex +RUN npm install -g "@openai/codex@${CODEX_VERSION}" RUN userdel -r ubuntu 2>/dev/null || true @@ -45,16 +51,23 @@ WORKDIR /home/agent SHELL ["/bin/bash", "-c"] -RUN curl -fsSL https://claude.ai/install.sh | bash +# The installer verifies the checksum of the Claude Code binary it downloads. +RUN curl -fsSL https://claude.ai/install.sh | bash -s "${CLAUDE_CODE_VERSION}" -RUN curl -LsSf https://astral.sh/uv/install.sh | sh +RUN curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh USER root RUN curl -fsSL "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${TARGETARCH}" -o /usr/local/bin/gosu \ + && case "${TARGETARCH}" in \ + amd64) expected="${GOSU_SHA256_AMD64}" ;; \ + arm64) expected="${GOSU_SHA256_ARM64}" ;; \ + *) echo "no gosu checksum for ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && echo "${expected} /usr/local/bin/gosu" | sha256sum -c - \ && chmod +x /usr/local/bin/gosu From 0679fb7c92369e92dcaf7ed606d74025314b30d3 Mon Sep 17 00:00:00 2001 From: magnificentlycursed <190320250+magnificentlycursed@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:27:43 -0700 Subject: [PATCH 08/15] docs(release): add RELEASING.md with the gated release flow and a repository-move checklist There was no release runbook (tracker #170 planned one and the 0.5.2 changelog says it exists; it did not), and each repository move dropped part of publishing. RELEASING.md describes what a v* tag publishes and in which order, what to check, how to roll back, the guarantees of each image tag, updating pinned image tools, the repository settings releases depend on, and what to carry over when the repository or owner moves. --- CHANGELOG.md | 1 + RELEASING.md | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 RELEASING.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9becbc855..0c6d35ecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). checks, anonymously, `:nightly`, every release's `:` and `:latest`, and that the frozen `ghcr.io/forecast-bio/crosslink-agent:latest` 0.9.0-beta.1 uses still names the same image. +- `RELEASING.md`: the release runbook (what a tag publishes and in which order, rollback, image guarantees, required repository settings) and a checklist for moving the repository or owner (gh#124). - The agent image pins its base image by digest and its tools by version (GitHub CLI, Codex, Claude Code, uv, gosu), and verifies gosu by checksum, so two builds of the same commit contain the same tools (gh#128). diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 000000000..21b866fde --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,143 @@ +# Releasing crosslink + +This is the runbook for cutting a crosslink release and for moving the project +to a new repository or owner. It describes what the workflows do today; when a +workflow changes, change this file in the same pull request. + +## What a release publishes + +A `v` tag pushed to `main` starts the release flow in +`.github/workflows/container-image.yml`: + +| Stage | Job | What happens | Fails when | +|---|---|---|---| +| Gate | `gate` | Decides what the run may publish. | The tag does not match `crosslink/Cargo.toml`, or `:` is already published. | +| Image | `build-binary`, `publish-image` | Builds Linux amd64/arm64 binaries that default to `:`, pushes `ghcr.io//crosslink-agent:` with version, revision and source labels, and attests the build. | The build fails. | +| Smoke test | `smoke-verify` | Without logging in, checks that every pushed tag resolves to the built digest, pulls that digest on both architectures, runs `crosslink --version`, and checks the version label. | The package is private, a tag did not land, or the binary is not the tagged version. | +| `:latest` | `promote-latest` | Points `:latest` at the verified digest. One promotion runs at a time. | Skipped for prereleases and for any version that is not the highest stable `v*` tag. | +| Binaries | `release-binaries` (`release-builds.yml`) | Builds Linux x86_64/aarch64, macOS aarch64/x86_64 and Windows x86_64 binaries plus the Codex plugin archives, all defaulting to `:`. | A build fails. | +| GitHub release | `publish-release` | Creates the release with the binaries, plugin archives, `SHA256SUMS` and build attestations. Prereleases are marked as such. | The smoke test or a build failed. | + +The tag push also runs `publish.yml` (crates.io). That workflow is not part of +the gated flow yet and needs the crate's ownership and credential settled first; +until then it fails on tag pushes from this repository. + +Every released build defaults to the agent image of its own version, so a +release is only usable once `:` is published and public. The order +above guarantees that the GitHub release never points at an unverified image. + +## Before you tag + +1. Release from a `release/v` branch cut from `develop`, opened as a + pull request to `main`. +2. Bump `version` in `crosslink/Cargo.toml` (and `Cargo.lock`). A version that + was ever published anywhere, including crates.io, cannot be reused. +3. Move the `[Unreleased]` entries in `CHANGELOG.md` under the new version. +4. Make sure CI on the release pull request is green, then merge it. +5. Optional, before the first release from a new repository or after changing + the workflow: run **Container Image** manually on `develop` with + *rehearse_promotion*. It exercises the gate, smoke test and promotion + against `:promotion-rehearsal` without touching `:latest`. + +## Tag and watch + +Create an annotated tag `v` on the merged `main` commit and push it to +the repository. + +Watch the **Container Image** run for the tag. When it is green: + +- `ghcr.io//crosslink-agent:` exists and pulls anonymously; +- for a stable release, `:latest` names the same digest; +- the GitHub release has the binaries and `SHA256SUMS`. + +Then merge `main` back into `develop` so the next release does not conflict +(skipping this back-merge is what made the v0.9.0-beta.1 cut painful). + +## If something fails + +- **Gate fails:** nothing was published. Fix the tag or the version and tag + again. Delete a wrong tag only if nothing was published from it. +- **Smoke test fails:** `:` exists but `:latest` did not move and no + GitHub release was created. Find the cause, fix it, and release the next + patch version. Do not republish the same version: the gate refuses, on + purpose, because pinned tags are cached by users. +- **Bad release after promotion (rollback):** point `:latest` at the previous + release's digest by hand: + + ``` + docker buildx imagetools create --tag ghcr.io//crosslink-agent:latest \ + ghcr.io//crosslink-agent@ + ``` + + Then mark the GitHub release as a prerelease or delete its assets, and ship a + fixed patch release. + +## Images and their guarantees + +| Tag | Moves? | Who uses it | +|---|---|---| +| `:` | Never (the gate refuses to republish) | Released builds of that version | +| `:latest` | To each stable release, after its smoke test | Anyone who asks for it with `--image` | +| `:nightly` | On every push to `develop` | Development builds; refreshed before each launch | +| `:nightly-` | Not by design, but registry tags can be moved | Pinning a development build | + +Only a digest pin is byte-for-byte reproducible. Images and binaries carry +build attestations (`gh attestation verify --owner `). + +Because development builds follow `:nightly`, anything merged to `develop` runs +in every development build's next container, which mounts the user's provider +and GitHub logins. Protect `develop` accordingly. + +Known limit: `cargo install --git … --tag v` cannot see its release tag +and defaults to `:nightly`; such users should pass `--image` with their version. + +The daily **Agent Image Canary** checks, anonymously, that `:nightly`, every +release's `:` and `:latest`, and the frozen +`ghcr.io/forecast-bio/crosslink-agent:latest` (which 0.9.0-beta.1 and older +builds default to, pinned to its digest) can still be pulled. Never delete or +make private an image that a released version defaults to. + +## Updating pinned tools in the image + +`crosslink/resources/container/Dockerfile` pins the base image by digest and gh, +Codex, Claude Code, uv and gosu by version (gosu by checksum too). To update one, +change its `ARG` (and checksum) in a pull request; the new tools reach users in +the next `:nightly` and the next release. Released `:` images are not +rebuilt with newer tools: ship a patch release instead. + +## Required repository settings + +- **Packages:** `crosslink-agent` is public and its Actions access lets this + repository write. A package created by a workflow starts private; make it + public before the first release from a new owner. +- **Permissions:** workflows default to read-only tokens; the publishing jobs + request `packages: write`, `contents: write`, `id-token: write` and + `attestations: write` themselves. +- **Environments:** `github-pages` for the docs; `cargo` (tag-only deployment + policy) once crates.io publishing is wired up. +- **Rulesets:** `v*` tags may be created only by maintainers and never moved or + deleted; `main` and `develop` require pull requests and passing CI and refuse + force pushes and deletion. + +## Moving the repository or owner + +Each past move (dollspace-gay/chainlink → forecast-bio/crosslink → +dollspace-gay/crosslink → Corvidae-Coding-Projects/crosslink) dropped part of +publishing. Repository redirects do not cover GHCR, crates.io, Pages or +settings. When moving: + +1. Update `repository`/`homepage` in `crosslink/Cargo.toml`, README links, the + VS Code extension metadata, `docs_src/_quarto.yml`, and the default image + repository in `crosslink/src/build_support/agent_image.rs`. Leave historical + references (CHANGELOG entries, old issue links) pointing where they pointed. +2. Make the new owner's `crosslink-agent` package public and link it to the + repository after its first publish; check with an anonymous pull. +3. Add the new owner to the crates.io crate (or set up trusted publishing for + the new repository) and recreate the `cargo` environment and credential. +4. Recreate Actions secrets, environments (including `github-pages`) and the + rulesets above, and enable Pages with the workflow source. +5. Keep the old owner's images public and frozen (released builds still pull + them); mark them deprecated in the package description and add them to the + canary with their digests. +6. Cut a patch release from the new repository so crates.io metadata, the + release assets and `:` all exist under the new owner. From 511423ee9f84c5fcafc6ae6ba5f230fc1d4931d1 Mon Sep 17 00:00:00 2001 From: magnificentlycursed <190320250+magnificentlycursed@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:56:42 -0700 Subject: [PATCH 09/15] docs(changelog): restore the release history 4b9f988c truncated Rewriting this PR's [Unreleased] release-flow entry in 4b9f988c kept only the text before it, dropping every release section from 0.9.0-beta.1 back (1,089 lines down to 43). The file is now the base file with this PR's additions inserted under [Unreleased] > Added, plus an entry for the launch-time version check that was missing. --- CHANGELOG.md | 1084 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1084 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c6d35ecd..7f9b957d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,3 +41,1087 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - The agent image pins its base image by digest and its tools by version (GitHub CLI, Codex, Claude Code, uv, gosu), and verifies gosu by checksum, so two builds of the same commit contain the same tools (gh#128). +- Kickoff and `container start` compare the crosslink version an agent image + carries (its `org.opencontainers.image.version` label, set by CI and + `container build`) with the CLI's, warning when they differ, since both write + the same hub (gh#127). The old binary-hash staleness check, which never fired + for published images, is removed. +- `crosslink container auth login|status|refresh|logout --provider github` + keeps a GitHub CLI login in a per-user docker volume so container agents + can publish hub refs over HTTPS (gh#109). Kickoff and `container start` + mount it read-only when the hub remote is an HTTPS URL, refuse non-HTTPS + remotes up front, and the entrypoint requires a working login before the + agent starts. +- First-class Claude and Codex provider support. `agent.provider` now selects + protocol semantics independently from the optional `agent.binary` override; + kickoff, design, orchestrator, swarm, Sentinel, status, usage, and containers + share structured provider adapters and normalized runtime events. +- `crosslink init` installs Claude and Codex repository integrations by default, + with `--agent-integration claude|codex|both` for explicit selection. Codex gets + merged hooks/MCP configuration, canonical skills, a managed `AGENTS.md` block, + and a versioned local plugin generated from provider-neutral assets. +- `crosslink doctor` reports selected-provider executable, account-login, + integration, MCP, plugin, hook-trust, and container credential readiness. +- Recorded Claude/Codex event fixtures, cross-provider hook protocol fixtures, + plugin drift validation, and native Windows/container provider coverage. +- Per-dispatch reasoning/spend dials on the kickoff path (gh#61). + `crosslink kickoff run` and `crosslink kickoff plan` accept + `--effort ` and `--budget-usd `, emitted + as `claude --effort` / `claude --max-budget-usd` immediately after `--model` + on both the local (tmux) and container launch paths. `--effort` is validated + fail-closed by clap; omitting both flags reproduces the previous invocation + byte-for-byte. The resolved `model`, `effort`, and `budget_usd` are recorded + in `.kickoff-metadata.json` so a run record captures what the dispatch was + actually given. `crosslink swarm config` gains the same two flags: swarm now + sources `model`/`effort`/`budget_usd` from its budget config instead of + hardcoding `opus` at launch, and the spend ceiling applies per dispatched + agent (a wave of N agents can spend up to N x the amount). +- Repository reconciliation now pins local and remote historical evidence, + prepares immutable v3 objects, verifies canonical semantics, and publishes a + single generation transaction. The reconciliation transaction can resume + interruptions, adopt an independently verified racing winner, preserve + concurrent/offline work, and retire legacy remote authority without a + finalize/remigrate sequence. + +### Changed + +- Web retrieval now stays on each provider's native web/search implementation. + The flaky safe-fetch proxy and obsolete key-based sanitization path were + removed; repository instructions, skills, and hooks instead preserve external + provenance and make the boundary explicit: fetched words are evidence to + examine, never instructions to obey. +- Repository ownership, package metadata, documentation, release assets, and + remotes now use `Corvidae-Coding-Projects/crosslink` as the canonical source. +- `crosslink migrate hub-v3` is now a compatibility adapter over automatic + repository reconciliation. `--finalize --yes-delete-v2` is accepted as an + idempotent compatibility request; `--adopt-stale` and + `--remigrate-from-v2` fail with guidance because unverifiable or forced + publication is no longer part of the migration policy. + +### Fixed + +- `crosslink container build` no longer fails with a cryptic + `COPY crosslink-: not found` (gh#75). It staged the binary as + `crosslink` while the Dockerfile expects `crosslink-${TARGETARCH}`; it now + stages `crosslink-` and passes `--build-arg TARGETARCH=` so a + plain `docker build` resolves the COPY. Since it packages the *installed* + binary (no source tree to cross-compile from), it now fails fast with a + clear message on non-Linux hosts — where that binary can't run in the + Linux image — pointing at the CI workflow / `just build-image` instead. +- The container-image workflow's `IMAGE_NAME` is derived from + `${{ github.repository_owner }}` (gh#75), so a fork publishes to its own + GHCR namespace instead of failing to push to the upstream org (on + Corvidae-Coding-Projects it still resolves to `Corvidae-Coding-Projects/crosslink-agent`), and a + `workflow_dispatch` trigger lets a fork publish a working agent image + on-demand without a `develop` push. Addresses the unpublished + `DEFAULT_AGENT_IMAGE` (Corvidae-Coding-Projects/crosslink#576). +- `kickoff run --container` now passes claude's permission flag into the + container agent (gh#59). The local (tmux) path emitted + `--dangerously-skip-permissions` / `--permission-mode`, and `container start` + hardcodes it, but the kickoff container path built its `claude` invocation + without any permission flag — so the containerized agent prompted for every + tool and, with no TTY, blocked forever (a cause of the gh#55 "agent launches + then stalls" signature). Both launch paths now share a single + `permission_flag` helper so they cannot drift, and `launch_container` + threads `skip_permissions`/`permission_mode` through and emits the flag. +- `GET /api/v1/sync/status` reports an accurate `last_fetch_at` on v3 hubs + (gh#53). It was derived from the hub-cache directory's mtime, which tracks a + fetch on v2 (the worktree is reset) but freezes on v3, where a fetch adopts + refs into `.git` and never touches the worktree. On v3 it now reads + `FETCH_HEAD`'s mtime (git rewrites it on every fetch), located via + `rev-parse --git-path` so it is correct across worktree layouts. +- `kickoff plan` accepts `--skip-permissions` / `--permission-mode` (gh#66). + Plan mode is read-only, so it launched its claude session with no permission + flag — which meant a fresh worktree triggered claude's interactive + workspace-trust dialog, and a headless dispatcher (no TTY to answer it) hung + forever (the plan/tmux half of the gh#55 stall). The flags now match + `kickoff run`/`launch`; the default stays off (interactive users still get + the dialog — fail-closed), and a headless caller passes `--skip-permissions`, + which is what actually clears the trust dialog (`--permission-mode` alone does + not). The `kickoff --plan` form now threads the flags too (they were + previously accepted and silently dropped). +- The dashboard agent-request pane is populated on v3 hubs (gh#49, agent + requests half). `read_snapshot_v3` hardcoded `agent_requests` empty because + the request/ack streams live on the agent refs, not a worktree the snapshot + reader scans. A new `hub_v3::read_all_agent_requests` walks the agent refs, + reads each driver's `requests-out/--.json` and each target's + `requests-ack/.json`, and pairs them by ulid grouped by target — the + whole-fleet view the dashboard needs, distinct from the per-inbox + `poll_requests_for_agent`. (The `ci_status` half of gh#49 remains: v3 has no + CI-status ref home or writer yet — separate work.) +- The dashboard `unreachable_project` alert can now fire (gh#48). The alert and + the frontend tile severity key on `projects.status == "error"`, but the poll + loop never wrote that value — it discarded the fetch outcome and propagated a + snapshot-read failure without recording it — so a genuinely unreachable + project showed stale tiles and stayed silent. `poll_project` now marks + `status = "error"` when the clone is gone/unreadable, or when a fetch failed + and no local hub data exists, and clears it back to `"active"` on a + successful poll (a transient fetch failure over existing local data does not + flap). +- `kickoff plan --dry-run` and `kickoff run --dry-run` are side-effect-free + (gh#19). Both created a real git worktree, branch, and sentinel files — and + `plan` a permanent `PlanRecord` — before their dry-run guard, so repeated + dry-runs accumulated orphan worktrees and phantom "planning" rows that no + cleanup flag reclaimed. The guards now run before any creation and print the + would-be worktree/branch/agent names. (`run --dry-run` still creates the + tracker issue when none is passed, since the printed prompt embeds its id.) +- The launch command writes a `TIMEOUT` sentinel to `.kickoff-status` when the + timeout wrapper kills the agent (gh#60) — on both the local (tmux) and + container paths, via an exit-code-124 trailer. Killed agents previously kept + their `RUNNING` sentinel forever and only the wall-clock check ever noticed; + `TIMEOUT` now classifies as `timed-out` in status normalization. +- Design docs using the documented H2 `## Phase:` / `## Layer:` headers + produce requirement groups for swarm phasing (gh#57). The parser previously + recognized only H3 `### Phase N:` headers inside `## Requirements`; the + documented H2 form silently fell into `unknown_sections` and phasing intent + degraded to auto-decomposition. +- The `/design` skill no longer resets `pipeline.json` on `--continue` + (gh#56). Its pipeline-state step wrote the file via an unconditional + heredoc, wiping `plans`/`runs` history and regressing `stage` on designs + that had already advanced; it now creates the file only when absent and + otherwise updates `doc_hash`/`design_doc` in place, preserving history. +- Agent hook enforcement matches the documented contract (gh#58). The shipped + `agent_overrides` (and the hook's built-in agent defaults) blocked only + destructive operations while the guides and the generated KICKOFF.md prompt + claimed "no push, no merge, gated commits": `git merge`/`rebase`/ + `cherry-pick`/`reset`, stash/tag/patch, and branch surgery are now + mechanically blocked for agents and `git commit` is gated on an active + issue. Plain `git push` remains permitted because the CI-verify flow + instructs the agent to push a draft PR (force-push stays always blocked) — + the prompt and guides now state that contract accurately instead of + overclaiming. +- Hub-cache init no longer fails with "Author identity unknown" on a host whose + git identity is present but empty (gh#34). `ensure_cache_git_identity` checked + only that `git config user.email` *succeeded* — but `git config` exits 0 for a + key set to an empty string (e.g. a global `user.email = ` used to force + per-repo identities), so the empty value slipped past as "configured" and the + `Initialize crosslink v3 hub worktree` commit then died with no author. It now + requires a non-empty value on both `user.email` and `user.name` (the check + previously ignored `user.name` entirely), falling back to the + `crosslink`/`crosslink@localhost` identity otherwise — hardening real + `crosslink agent bootstrap` on CI runners and identity-less/empty-identity + hosts. +- Dashboard git calls can no longer hang on a credential prompt (gh#34). The + poll loop's per-tick `git fetch` and the track-time `git remote show origin` + default-branch probe now run with `GIT_TERMINAL_PROMPT=0`, a neutralized + `GIT_ASKPASS`, and `-c credential.helper=`, so a private, moved, or deleted + remote (or an interactive askpass like VS Code's) makes them fail fast + instead of blocking a headless `dashboard serve` worker indefinitely. The + `detect_default_branch` test fixtures now record a local remote-tracking + HEAD so they resolve without any network I/O (previously they reached the + live network against hardcoded URLs, hanging on interactive-helper hosts). +- The dashboard is v3-aware (gh#4, final symptom): migrated+finalized repos no + longer surface as `unreachable_project`. The poll fetch uses the + `+refs/heads/crosslink/*` glob (the exact v2-only refspec failed every tick + against migrated remotes, so v3 refs never became local and the + already-mode-routed reader saw nothing), the v2 hub-cache worktree + materialization and the post-action `reset --hard crosslink/hub` are gated + off on v3, tile freshness (`hub_sha`/`last_commit_at`) keys off the + checkpoint ref, GitHub org discovery falls back to probing the + `crosslink/checkpoint` branch, the track-time hub check recognizes v3 + markers (local refs only), `GET /api/v1/sync/status` names the mode's + actual hub ref, and hub signature verification checks the checkpoint tip + on v3 instead of the nonexistent `locks.json` history. +- `migrate hub-v3 --finalize` no longer refuses after legitimate post-migration + hub use (gh#45). The finalize gate reused the migration-time invariant + (`reduce(current refs) == genesis`), which only holds in the instant after + seeding - a single lock release, or even the dispatcher's own v3 fetch + advancing the checkpoint ref, permanently blocked the cutover with a + misleading "v2 and v3 no longer agree". Migration now persists the seeded + genesis checkpoint commit and per-agent seed tips in `HubMeta` (serde-default + fields, compatible both directions), and finalize verifies what actually + gates safe v2 deletion: the v2 files still reproduce the SEEDED genesis + (content-addressed at the recorded commit), every seed tip remains an + ancestor of its ref (ancestry, not byte-prefix, so compaction and REQ-11 + pruning never false-fail), and reducing AT the seeded tips reproduces the + genesis. Hubs migrated before the metadata existed fall back to the strict + check with an actionable `--remigrate-from-v2` message. +- `crosslink integrity` no longer false-FAILs `counters` and `hydration` on a + v3 hub. Both checks read legacy v2 worktree artifacts (meta/counters.json, + issues/*.json) that v3 never materializes, fabricating "next_display_id is + 1" and "0 issues in JSON, N in SQLite" against healthy hubs - and steering + users toward a `--repair --accept-data-loss` that wiped local shared tables + and restored nothing. On v3 the checks now compare against the reduced + checkpoint state (the same source the already-v3-aware `locks` check uses), + and hydration repair re-hydrates from that state instead of the empty + worktree (gh#7). +- Hydration no longer multiplies hub comments. Comments have no unique uuid + index, so a hub comment adopted into the sqlite-only preservation snapshot + (via a numeric issue-id collision, e.g. after a legacy import) was restored + next to the fresh copy each pass re-inserted - one extra byte-identical + copy per mutating invocation. Comment hydration and the preservation + restore now dedup by uuid, which also collapses previously accumulated + duplicates back to a single copy on the next hydration. Refused v2 + mutations are additionally side-effect-free: the legacy-layout refusal now + fires before the hub write lock is acquired (gh#12). +- `crosslink locks release` is no longer a silent no-op from a fresh process + on a v3 hub. `read_lock_v2`'s v3 branch consulted only the in-process + `last_v3_state` cache - empty in any process that had not yet performed a + v3 mutation - so release reported "was not locked" while `locks list` + (which reads the checkpoint) showed the claim. The v3 read now lazily + reduces the persisted ref namespace when the cache is cold, the same idiom + `load_milestone_by_id` already uses; this also repairs the `AlreadyHeld` + pre-check in `claim_lock_v2` (gh#8). +- Claude hooks now resolve the crosslink binary via `find_crosslink_binary()` + instead of a bare PATH lookup (`work-check.py` control-flags check, + `heartbeat.py`, `session-start.py`), and `check_control_flags` fails open + unless stdout parses as flag-state JSON with a blocking flag actually set. + Previously, a PATH binary too old to know `agent flags` exited 2 with a clap + usage error - the same exit code as the flags protocol - and every + Write/Edit/Bash call was blocked with a false "AGENT PAUSED - an operator + has paused this agent" message (gh#31). +- `crosslink migrate to-shared` now works on v3 hubs (gh#4, second half). The + v2 body wrote worktree JSON and counters the reduction never reads, then + pushed the nonexistent legacy `crosslink/hub` ref ("src refspec does not + match any"). On v3 it now promotes SQLite-only issues (uuids the reduced + state does not know) through the event log via the same batch path + `import` uses - one commit, one hydration - preserving existing uuids (so + hydration replaces local rows in place instead of duplicating them) and + carrying free positive local ids into the reduction so local numbering + survives. Idempotent: already-promoted issues are skipped, so it doubles + as the recovery sweep for rows created before the hub was established. +- Post-migrate `crosslink sync` no longer aborts with SQLite `Error code + 1555: constraint failed` (gh#11). Preserved sqlite-only comments carry + positive v2/import-era ids while hydration inserts hub comments at frozen + positive display ids through a plain INSERT - a collision killed the whole + hydration transaction. The preservation restore now re-keys colliding + preserved comment ids to fresh negative local ids (comment ids are not FK + targets), mirroring the issue-id remap above it. +- `crosslink create` / `quick` after a legacy `import` no longer silently lose + the new issue while reporting success (#5, related #4). Three fixes: + `crosslink import` on a v3 hub now promotes the whole batch through the + event log in a single commit, so imported issues get reduction-assigned + display ids and are hub-visible like created issues; hydration no longer + lets a preserved local-only issue silently REPLACE (INSERT OR REPLACE) a hub + issue occupying the same id — the local row is remapped to a negative local + id with a warning and both survive; and `create` verifies the new issue is + actually visible in `SQLite` after hydration, failing loudly instead of + reporting a phantom id. Also starts v3 negative comment-id numbering below + preserved comment ids (the v3 analogue of #681). +- `crosslink sync` / `crosslink init` no longer fail on Git < 2.42.0 (e.g. + Ubuntu 22.04 LTS, which ships Git 2.34.1) with `unknown option 'orphan'`. + Hub-v3 and knowledge cache setup used `git worktree add --orphan` (added in + Git 2.42.0); on older Git they now fall back to an equivalent sequence + (detached worktree + `git checkout --orphan` + clearing the tree) that yields + the same empty unborn orphan branch (Corvidae-Coding-Projects/crosslink#655). +- The container entrypoint no longer `source`s `/host-auth/*.env` (gh#10). + Sourcing executed the file as shell: a token containing spaces or shell + metacharacters ran as commands, leaked credential fragments into the + container log, and silently truncated the exported value. The entrypoint + now parses the files line-by-line, honors only `CLAUDE_CODE_OAUTH_TOKEN` + and `ANTHROPIC_API_KEY` (optionally `export`-prefixed, quoted, or CRLF), + takes the value verbatim after the first `=` without executing or echoing + it, and reports `env-file:` auth resolution only when a recognized key was + actually found. +- The agent container image installs `build-essential` (gh#9). The + entrypoint's rustup install provided `rustc`/`cargo` but the image had no + C toolchain or linker, so `cargo test`/`clippy`/`check` failed at link + inside every container agent — one of the tracked contributors to the + gh#55 kickoff stall. + +## [0.9.0-beta.1] - 2026-06-13 + +> First beta of the 0.9 line. The 0.6.0-0.8.0 sections below were authored on +> their release branches and are reconciled into this shared history by this cut. + +### Breaking + +- **Hub v3: per-agent-ref coordination storage.** The shared `crosslink/hub` + branch with mutable JSON state is replaced by single-writer git refs - + visible branches `crosslink/agents/`, `crosslink/checkpoint`, + `crosslink/meta` - carrying append-only signed event logs reduced + deterministically to materialized state. Pushes to your own ref are always + fast-forward; the conflict can no longer occur, and ~10,000 lines of + rebase-retry/recovery machinery were deleted outright (guarded by a + permanent regression test). Mutations on un-migrated v2 hubs refuse with a + migrate prompt; v2 hubs remain readable forever for inspection and + migration. The checkpoint branch carries a browsable state tree (per-issue + JSON with inline comments, README, milestones) and each agent's branch is + its own visible activity/heartbeat feed. +- `crosslink serve` is deprecated in favor of `crosslink dashboard serve`. + +### Added + +- `crosslink migrate hub-v3` - verified one-shot migration (full-state gate + with automatic rollback; idempotent re-run doubles as push retry), adoption + path for machines whose remote was already migrated, `--finalize + --yes-delete-v2` hard cutover (survives corrupt legacy worktree + registrations), and `migrate hub-branches` for the hidden-refs-to-branches + rename. +- Full event sourcing: every issue/milestone mutation emits a signed event; + new event variants for comments, time entries, deletion tombstones, + milestones, and schedule changes, with deterministic display-id assignment. +- Dashboard - multi-project control panel (`crosslink dashboard serve`): + SCADA-style aggregation of every tracked project with live tiles, alerts + (stale locks, silent agents, overdue issues), full write surface with audit + trail, webhook alerting (Slack/Discord/JSON), CSV export, GitHub PAT + management, filesystem autodiscovery, and a git-native agent control + protocol (`crosslink agent request/poll-requests/requests`). +- Sentinel cpitd source: scheduled code-clone scans filing triage issues + (`sentinel.sources.cpitd.*`, default off) - plus pipx-first cpitd install + with actionable PEP 668 guidance (GH#621). +- `--contributor` attribution override on `knowledge add/edit/import` + (GH#628). +- `argument-hint` frontmatter on all arg-taking command templates (GH#626). + +### Fixed + +- Hook blocking messages now reach the model: exit-2 messages write to + stderr per the Claude Code hook contract (GH#624). +- Stray `.crosslink/` directories from cwd drift: hooks can no longer seed + them and discovery refuses to bind them (GH#625). +- Kickoff pipeline `runs` records are reconciled on completion instead of + accumulating stale `running` rows forever (GH#614). +- Hub-sync concurrency hardening: unique fsynced temp files, no live-holder + lock stealing, unified worktree locking (#750); plumbing ref writes pinned + immune to git signing config (GH#627). +- Hub-state recovery could rewrite a feature branch with hub contents when + the cache worktree link broke (GH#574); corrupt-JSON-committed-over-good- + history class eliminated by architecture (GH#616); unrelated-histories + divergence error eliminated (GH#629). +- `/design` first-run zsh glob failure - fixed templates; refresh deployed + copies with `crosslink init --force` (GH#615). + +## [0.8.0] - 2026-04-17 + +### Added + +#### Sentinel — Autonomous Maintenance Agent +- Full `crosslink sentinel` module — autonomous dispatch engine that monitors the repo and launches agents to fix issues ([GH-650]–[GH-672]) +- Database schema v16 migration for sentinel state ([GH-651]) +- `GitHubLabelSource` — poll GitHub labels to trigger dispatches ([GH-652]) +- 4-layer deduplication system to prevent redundant agent dispatches ([GH-653]) +- Dispatch engine with triage logic and kickoff integration ([GH-654]) +- Structured result collection with GitHub comment reporting ([GH-655]) +- `sentinel history` command for reviewing past dispatches ([GH-656]) +- Sonnet→Opus escalation on exhausted outcomes ([GH-657]) +- `sentinel watch` daemon mode for continuous monitoring ([GH-659]) +- `agent-todo:fix` dispatch rule with `VerifyLevel::Ci` and draft PR creation ([GH-660]) +- Per-agent in-flight details in `sentinel status` ([GH-661]) +- `InternalHygieneSource` — stale issues, orphan detection, unlabeled items ([GH-663]) +- `GitHubCISource` — poll failed CI workflow runs ([GH-664]) +- `sentinel metrics` command — success rates per model and dispatch rule ([GH-665]) +- `MaintenanceSweepSource` — lint drift and test regression detection ([GH-666]) +- Webhook receiver for real-time GitHub events ([GH-667]) +- Slack/webhook notifications for dispatch results ([GH-668]) +- Historical pattern detection for sentinel dispatches ([GH-669]) +- Self-tuning model selection from historical success rates ([GH-670]) +- Async watch loop with webhook server ([GH-672]) + +#### Integrity & Security +- `integrity sign-backfill` command for human attestation of unsigned hub entries ([GH-546]) +- Hub signing bootstrap deadlock fix for new repos ([GH-644]) + +### Fixed +- Display-ID and milestone counter collision from stale counters — reconcile against on-disk state ([GH-564]) +- V2 comment ID collision with preserved SQLite-only comments during hydration ([GH-563]) +- Sentinel: don't harvest dispatches while agent is still running ([GH-561]) +- Sentinel: keep watch daemon alive when stdin is `/dev/null` ([GH-561]) +- Kickoff: propagate `CLAUDE_CONFIG_DIR` through tmux to agent ([GH-555]) +- Dashboard: wire bearer-token auth for `crosslink serve` ([GH-556]) +- Init: respect `--dry-run` flag when combined with `--force` ([GH-542]) +- Init: scaffold `agent-prompt-server.py` during `crosslink init` ([GH-549]) +- Knowledge: read stdin body content in `knowledge add` ([GH-548]) +- Suppress unsigned warning for bootstrap locks commit +- QA audit — removed all `allow(dead_code)`, wired up unused code ([GH-671]) +- Zero clippy errors crate-wide under pedantic+nursery lints +- Clippy 1.95 strict lint compliance (`sort_by_key`, `map_or_else`, `doc_markdown`) + +### Changed +- Design doc gaps closed for V0/V1 — templates, `GH_TOKEN` handling, test coverage ([GH-662]) +- Crosslink-guide skill updated to match current CLI ([GH-547]) +- Init test coverage expanded to cover all three MCP servers ([GH-554]) + +## [0.7.0] - 2026-03-30 + +### Added +- `crosslink init --update` with manifest-tracked safe upgrades — tracks installed resource versions and applies incremental updates without overwriting user customizations +- First-class Shell/Bash support in language rules, detection, and hooks +- QA architectural review skill (`/qa`) shipped with `crosslink init` +- Team and solo configuration preset documentation + +### Fixed +- Full-codebase QA audit — 180+ fixes across security, correctness, and architecture: shell injection, fail-open hooks, CORS, transaction safety, hydration data loss, non-atomic writes, TOCTOU races, N+1 queries, and structural refactors (init.rs split, config registry extraction, `status.rs` → `lifecycle.rs`) +- `swarm merge --base` flag for repos without a `develop` branch +- `gh` added to allowed bash prefixes; session status caching in work-check hook +- `.hub-write-lock` excluded from git tracking to prevent recovery commit loop +- Consistent signing bypass for all hub-cache commits +- Resolved clippy pedantic and nursery warnings across codebase + +### Changed +- `init.rs` split into `init/mod.rs`, `init/merge.rs`, `init/python.rs`, `init/signing.rs`, `init/walkthrough.rs` for maintainability +- Config command logic extracted to `config_registry.rs` +- `status.rs` renamed to `lifecycle.rs` +- Shared error helpers module added to server (`server/errors.rs`) +- TUI tabs refactored with shared helpers to reduce duplication + +## [0.6.0] - 2026-03-24 + +### Added + +#### Kickoff & Multi-Agent +- Unified design-plan-run pipeline UX for `crosslink kickoff` — bare `kickoff` launches interactive wizard ([GH-445]) +- `crosslink kickoff graph` command for branch topology visualization with merge detection ([GH-502]) +- Compact base62 naming for agents, branches, and tmux sessions — `--` format ([GH-494]) +- Generate dedicated signing keys and auto-approve kickoff subagents ([GH-511]) +- Add CLI command and MCP tool for reliable tmux prompt delivery to agents ([GH-513]) +- Auto-detect and configure lint/test commands for kickoff agents based on project tooling ([GH-515]) + +#### Config System +- Config system overhaul with registry-based validation, layered loading (defaults → team → local), provenance tracking, and TUI inline editing ([GH-490]) +- `--team` / `--solo` presets and ConfigGroup categorization (Workflow, Security, Infrastructure, Agents) + +#### Workflow Enforcement +- Enforce comment discipline via hooks — `git commit` and `issue close` require typed comments ([GH-512]) +- Lazy auto-hydration on read when hub branch ref moves — no more explicit `crosslink sync` for reads ([GH-514]) + +#### Onboarding +- Improve first-use experience and onboarding clarity ([GH-516]) +- Add jj (Jujutsu) read-only commands to allowed_bash_prefixes ([GH-517]) + +### Fixed + +#### Hub Stability +- Hub stability bundle — self-healing health checks, FK protection, lock verification, orphan cleanup ([GH-464]) +- Hub structural fixes — lock serialization, promotion tracking, lock ownership refactor ([GH-482]) +- Hub health check — remove index.lock first, escalate detached HEAD recovery ([GH-483]) +- Stage untracked files before fetch to prevent heartbeat race ([GH-488]) +- Replace `--amend` with new revert commit in promotion rollback ([GH-463]) + +#### Agent Coordination +- Persist tmux session name so `kickoff list` detects swarm-launched agents ([GH-510]) +- Fall back to driver signing key when agent key is missing after cleanup ([GH-509]) +- Stabilize local ID assignment for offline issues across re-hydration ([GH-508]) +- Handle branch name collision from prior kickoff/swarm phases ([GH-487]) +- Integrity locks `--repair` releases stale locks instead of stealing them ([GH-497]) + +#### Init & Config +- `crosslink init` verifies git repo and initial commit exist before proceeding ([GH-489]) + +### Changed +- Config loading is now layered: embedded defaults → `.crosslink/hook-config.json` (team) → `.crosslink/hook-config.local.json` (local). Pre-overhaul configs are fully backward-compatible. +- New agent IDs, branch names, and tmux sessions use compact `--` format. Existing agents are preserved. + +### Known Issues +- `crosslink serve` dashboard frontend is not included in `cargo install crosslink` — build from source or use release binaries ([GH-429]) + +## [0.5.2] - 2026-03-19 + +### Added + +#### External Source Querying +- `crosslink issue --from` and `crosslink knowledge --from` flags for querying external repositories ([CL-206], [GH-428]) +- External repository resolution with GitHub API integration + +#### Init & Onboarding +- Greenfield scaffold with interactive design questions and CLAUDE.md template generation ([CL-369]) +- `crosslink-guide` Claude skill for in-session feature discoverability ([GH-404]) +- Implementation rigor guardrail with auto-discovered rule files ([GH-366]) + +#### Swarm Enhancements +- Multi-swarm UUID data model and swarm lifecycle commands (`swarm create`, `swarm list`, `swarm switch`) ([CL-371]) +- Swarm plan editing commands: `move`, `merge`, `split`, `remove`, `reorder`, `rename` ([CL-373]) +- Explicit layer/phase header support in `swarm init` design doc parsing ([CL-373]) + +#### CLI Enhancements +- `--json` support for `swarm status`, `session status`, `issue tree`, `blocked`, `ready`, and `next` commands ([CL-375], [CL-377]) +- Local time displayed alongside UTC timestamps in TUI issue detail ([GH-402]) +- Token-budget-aware behavioral guard reinjection for long sessions ([CL-384]) + +#### Testing +- Concurrency, coordination, and lifecycle smoke test suites ([GH-364]) +- Dashboard unit tests (App, color utilities, general utils) with Vitest ([GH-364]) +- VS Code extension platform detection tests ([GH-364]) + +#### Build & Infrastructure +- Integrity layout check and centralized hub file path constants ([GH-428]) +- Auto-discover rule files and command files from resources directories in `build.rs` ([CL-387]) + +### Fixed +- investigate two mcp server failures reported by user (#722) +- Signing: human key always signs hub commits; agent keys only for identity (#718) +- Fix piped shell commands in skill templates that fail permission checks (#254) +- Fix hub cache recovery loop caused by tracked .hub-write-lock file (#634) +- Fix auth token refresh (#146) +- Alert lock actions: surface holder + disambiguate Release vs Steal (#716) +- Dashboard InitBanner doesn't refresh on init success + track-all init fails on subset of repos (#715) +- Clone target should be $HOME/, not $HOME// (#714) +- Resolve crosslink binary via PATH first, not current_exe (#713) +- InitBanner error 'spawn crosslink init' hides the real underlying cause (#712) +- Change default clone_root from ~/crosslink-tracked to $HOME (#711) +- Alerts page mutations don't invalidate alerts query; orphan_subissue close appears to no-op (#709) +- Dashboard polish: token refresh UX + issue ordering + remote-repo hub data missing (#707) +- Dashboard: project detail page blank on click + gh CLI auth fallback (#706) +- Fix crosslink init: deploy agent-prompt-server.py (GH#554) (#677) +- Fix kickoff env propagation: CLAUDE_CONFIG_DIR not reaching tmux agent (GH#555) (#676) +- Fix dashboard auth: wire API client to bearer token (GH#556) (#675) + +#### Hub & Sync +- V1/V2 hub layout coexistence — resolve inconsistent write paths and cache corruption ([GH-428]) +- Prevent hydration data loss and resolve `--parent` cache lookup failure ([GH-427]) +- Preserve local-only close events during sync fetch ([GH-430]) +- Preserve session work state across hydration cycles ([GH-443]) +- Prevent hub cache corruption and ensure agent issue mutations sync correctly ([CL-372]) + +#### CLI & Display +- Render local issues as `Ln` instead of `#-n` across all commands ([GH-424]) +- Resolve worktree paths relative to main repo root, not pwd ([GH-425]) +- Recognize local issue `L-` prefix in `work-check` hook ([CL-370]) +- Detect Claude Code sub-agent worktrees in `is_agent_context` ([CL-420]) +- Resolve main repo root in `kickoff show-plan` for worktree support ([CL-374]) + +#### Reliability +- Make `delete_issue` atomic — restore files on commit failure ([GH-427]) +- Migrate `eprintln!` to `tracing` in sync/hydration paths to prevent TUI screen corruption ([GH-447]) +- Replace `unwrap`/`expect` with proper error handling in `build.rs` and sync paths ([CL-206]) +- Make `detect_hostname` test non-flaky in parallel test suites + +#### Adversarial Review Fixes +- Apply all Group A correctness fixes from adversarial review ([CL-364]) +- Resolve clippy errors across Group A fixes ([CL-364]) +- Replace lazy `INTENTIONAL` suppressions with `eprintln!` warnings ([CL-L6]) +- Add `INTENTIONAL` comments to deliberate error suppression patterns ([CL-419]) + +### Changed +- Release v0.6.0 — version bump, changelog, docs, tests (#258) +- Add VHS tape and screenshot scripts for docs visuals (#131) +- Manual test container-based agent execution (#73) +- Configure GitHub rulesets for release/* branches and create RELEASING.md (#170) +- Repo cleanup: migrate design docs to knowledge, move docs, remove root scripts (#171) +- Configure GitHub rulesets for release/* branches and create RELEASING.md (#170) +- Repo cleanup: migrate design docs to knowledge, move docs, remove root scripts (#171) +- Configure GitHub rulesets for release/* branches and create RELEASING.md (#170) +- Repo cleanup: migrate design docs to knowledge, move docs, remove root scripts (#171) +- Configure GitHub rulesets for release/* branches and create RELEASING.md (#170) +- Repo cleanup: migrate design docs to knowledge, move docs, remove root scripts (#171) +- P1.11: agent request protocol — hub-branch agents//requests/*.json + agent-side polling lib + kill/pause/resume/reprioritise verbs + audit log (#700) +- P1.10: lock controls — claim, release, steal (#699) +- P1.9: write surface — milestones + relations + blockers (#698) +- P1.8: write surface — issues + labels (close, reopen, comment, update, label, unlabel) (#697) +- P1.7: alert UI — banner, sidebar rail, /alerts page, desktop notifications (#696) +- P1.6: alert engine — derived alerts (stale_lock, silent_agent, overdue_issue, ci_failure, unreachable, signature_invalid, orphan_subissue, hub_diverged, hub_parse_error, untrusted_signer, pending_request, compaction_lag) (#695) +- P1.5: WebSocket live updates — push change notifications, frontend refetch-on-signal (#694) +- P1.4: project grid (read-only frontend) — tile component, fetch projects, show counts (#693) +- P1.3: REST API — projects + activity endpoints, bearer auth (#692) +- P1.2: poll loop + single-project index — git-fetch every 5s, populate projects/project_state/activity (#691) +- P1.1: scaffolding — crosslink dashboard subcommand + serve deprecation + empty SPA + SQLite bootstrap (#690) +- Manual QA for PR #553 sentinel run + webhook tests (#678) +- Manual QA for PR #553 sentinel run + webhook tests (#678) + +#### Codebase Decomposition +- Decompose 6 god files into focused submodules — `shared_writer.rs`, `kickoff.rs`, `db.rs`, `sync.rs`, `knowledge.rs`, `commands/knowledge.rs` ([CL-413]) +- Split `kickoff.rs` into 10 submodules (conventions, container, plan, prompt, report, status, watchdog, worktree, wizard, cleanup) ([CL-413]) +- Split `commands/knowledge.rs` into `mod.rs` + `operations.rs` ([CL-413]) +- Properly wire all swarm paths through `SwarmContext`, remove `allow(dead_code)` annotations + +#### Observability +- Migrate logging from `eprintln!` to `tracing` crate across sync, hydration, and daemon modules ([GH-364], [GH-447]) +- Route tracing output to stderr to avoid polluting structured CLI output + +## [0.5.1] - 2026-03-15 + +### Added + +#### Swarm Review System +- `swarm review` command for parallel adversarial codebase exploration ([GH-342]) +- `swarm fix` command for parallel issue-to-agent fix execution ([GH-345]) +- `swarm merge` subcommand for combining parallel agent worktree changes ([GH-346]) +- End-to-end `swarm review --fix` pipeline orchestrator ([GH-348]) +- Finding consolidation and deduplication for swarm reviews ([GH-343]) +- Seam detection and codebase auto-partitioning module ([GH-341]) +- Trust model configuration for swarm review triage ([GH-347]) +- Automatic GitHub issue creation from swarm review findings ([GH-344]) + +#### Testing +- Adversarial smoke test harness with 134 tests ([GH-354]) +- Test coverage boosted to 92.73% ([GH-355]) + +#### Language Support +- First-class Elixir support in kickoff conventions, context detection, hooks, and command docs ([GH-352]) + +#### CLI Enhancements +- `--skip-permissions` flag for `kickoff run` ([GH-357]) +- Git commit hash included in version string ([GH-339]) + +#### Build Tooling +- `justfile` with `render-docs` pipeline: SVG generation, quarto render via staging dir, collision detection for manually-maintained docs, asset lint for broken images/styles/scripts/links ([CL-192], [CL-193]) + +### Fixed +- Missing banner and wordmark SVGs in docs site deployment — banner ref escaped the `docs/` boundary, wordmark not copied by Quarto ([CL-192]) +- Swarm launch failure — write `.kickoff-status` sentinel on launch and treat missing status as failed ([GH-359]) +- V2 comment hydration bug found during smoke testing ([GH-354]) +- Swarm gate worktree bug ([GH-355]) +- Update resource templates and hooks to use canonical `crosslink issue` syntax ([GH-338]) +- 9 Windows compatibility issues across codebase ([GH-337]) +- Windows clipboard support via `clip.exe` ([GH-325]) + +### Changed +- Remove all `#[allow(dead_code)]` annotations and wire in unused code +- Update CLAUDE.md to document canonical CLI syntax, `--quiet`, and `--json` flags ([GH-338]) +- Docs CI workflow now uses `just render-docs` with asset verification gate + +## [0.5.0] - 2026-03-11 + +### Added + +#### Web Dashboard (`crosslink serve`) +- `crosslink serve` subcommand with axum HTTP server scaffold ([GH-290]) +- React Vite dashboard scaffold with TypeScript, TailwindCSS 4, and shadcn/ui components +- Agent monitoring REST endpoints and real-time WebSocket updates with filesystem watcher +- Agent list page with AgentCard component and agent detail drilldown with HeartbeatTimeline and LockList +- Issues CRUD REST endpoints, issue list and detail views, session management UI +- Label manager, dependency editor, and bulk issue operations +- Sessions, milestones, knowledge, search, sync, and config REST API endpoints +- Knowledge browser, milestones, and command palette pages +- Sync dashboard, config editor, and lock visualization +- Usage graphs and cost breakdown components with token usage collection and storage +- DAG and Gantt visualization for orchestrator execution +- Execution controls and live monitoring components +- Document import, stage editor, and LLM-assisted document decomposition orchestrator +- DAG execution engine with topological sort and executor lifecycle management +- Appearance settings page and orchestrator endpoint wiring + +#### CLI Enhancements +- `crosslink prune` command for hub/knowledge history pruning ([GH-297]) +- `crosslink kickoff cleanup` command for pruning stale worktrees and tmux sessions ([GH-298]) +- `crosslink kickoff list` command with worktree, tmux, and Docker discovery +- Refactor CLI into `issue`/`timer`/`migrate` subcommand groups ([CL-157]) +- Watchdog sidecar to nudge idle kickoff agents + +#### Knowledge Management +- `--replace-section` and `--append-to-section` flags for `knowledge edit` command ([GH-264]) + +#### TUI Improvements +- Startup sync, periodic background sync, and manual `r` keybinding for refresh ([CL-169]) + +#### CI +- Fix CI concurrency groups and repo cleanup ([GH-287], [GH-291]) + +### Fixed +- Address 14 findings from adversarial codebase review +- Restore view state on issue detail back navigation and clamp scroll bounds ([GH-293]) +- Add agent init verification and sync steps to kickoff instructions ([GH-289]) +- `ssh-keygen` verify checks both stdout and stderr, allow unsigned hub writes ([GH-299], [GH-301]) +- Replace `unwrap()` calls with `ok_or_else()` for strict clippy CI compliance +- Resolve duplicate Agent type and stale field names in dashboard types +- Fix clippy warnings in adversarial review fixes + +### Changed +- Move `.crosslink/` ignores to inner `.gitignore` ([CL-175]) + +## [0.4.0] - 2026-03-10 + +### Added + +#### Swarm Orchestration (`crosslink swarm`) +- `crosslink swarm init/status/resume` commands for multi-agent swarm lifecycle (Phase 1, [GH-233]) +- `crosslink swarm launch/gate/checkpoint` commands for coordinated agent execution (Phase 2, [GH-233]) +- Swarm budget estimation and throttling (Phase 3, [GH-233]) +- Swarm multi-window planning (Phase 4, [GH-233]) + +#### Mission Control +- `crosslink mission-control` command for monitoring active agents in a unified dashboard + +#### Agent Hooks & Liveness +- Agent-aware hooks with git flag bypass fix ([GH-164], [GH-226]) +- Hook-based heartbeats for kickoff agent liveness detection +- Custom sandbox wrapper support for agent isolation (alternative to Docker) + +#### Kickoff & Preflight +- Unified preflight check with macOS `gtimeout` support for kickoff command +- Platform-specific remediation hints for preflight dependency checks ([GH-260]) + +#### TUI Improvements +- Async data loading for TUI agents and config tabs ([GH-254]) +- Table scroll-to-follow across all tabs ([GH-240]) + +#### Knowledge & Search +- Word-level fuzzy matching for knowledge search ([GH-263]) + +#### CI +- Tiered smoke tests for CI pipeline ([GH-242]) +- Restrict fuzz tests to release branches and PRs targeting main + +### Fixed +- Mission-control pane liveness and auto-attach robustness +- Load `knowledge.md` rules into Claude prompt via `prompt-guard.py` +- Publish parent SSH key under kickoff agent ID after creation ([GH-261]) +- Degrade gracefully when `gpg.ssh.allowedSignersFile` is not configured ([GH-262]) +- Replace `unwrap()` calls with proper error handling for strict clippy compliance +- Restore deleted tests and update preflight test signatures +- Swarm coherence fixes across all 4 phases ([GH-233]) +- Skip headings inside code fences in design doc parser ([GH-248]) +- Simplify drift reminder to fixed 3-turn interval +- Pre-flight check for required external commands in kickoff + +### Changed +- Clean up feature worktrees and tmux sessions (#180) +- README updated with multi-agent orchestration, swarm, kickoff, knowledge, TUI, and hooks features + +## [0.3.0] - 2026-03-05 + +### Added + +#### Kickoff & Agent Orchestration +- `crosslink kickoff` CLI command with local and container agent execution ([GH-175]) +- Design document parser and `--doc` flag for kickoff, importing design docs to knowledge ([GH-215], [GH-216]) +- `crosslink kickoff plan` subcommand for read-only gap analysis +- Spec validation loop for structured validation of kickoff agents ([GH-217]) +- Structured machine-readable build reports for kickoff agents ([GH-219]) + +#### Knowledge Integration +- Structured queries, bulk import, MCP server, and auto-inject for knowledge branch ([GH-221]) +- `--from-doc` flag on `knowledge add` for design doc import + +#### CLI & Workflow +- `/design` skill for interactive design document authoring ([GH-225]) +- `/maintain` skill for codebase maintenance ([GH-205]) +- Configurable auto-steal for stale locks ([GH-223]) +- Atomic lock claims — bail on contended lock claims in `session work` to close timing race ([GH-224]) +- `rules.local/` directory for gitignored local rule overrides ([GH-234]) +- Configurable git remote for hub/knowledge branches via `tracker_remote` setting ([GH-235]) +- VHS tape files and screenshot scripts for docs visuals ([GH-227]) + +#### Code Quality +- Module dispatch refactor — extract monolithic dispatch from `main.rs` into module-level `run()` functions ([GH-222]) +- Extract pure functions from kickoff module for testability ([GH-209]) +- Dry-run integration tests and `build_prompt` unit tests for kickoff ([GH-214]) +- Unit tests for shared writer lock claim, release, and contention ([GH-208]) +- Clock skew detection using git commit timestamps as witness ([GH-173]) + +### Fixed +- Agent signing chicken-and-egg during init — defer key publish and use unsigned bootstrap commits ([GH-237]) +- TUI agents tab not forming agent list — read V2 heartbeats and refresh data on tab focus ([GH-232]) +- Milestone add/remove now persists to coordination branch ([GH-174]) +- Hub cache hooks — propagate canonical integration hooks into the hub cache worktree on init ([GH-213]) +- Hub sync dirty state — deduplicate hub cache issues during hydration, prevent vicious sync loop ([GH-210]) +- Hub sync push warnings — surface visible warnings when push falls back to local-only ([GH-206]) +- Use `--worktree` scope for agent signing config in linked worktrees ([GH-167]) +- Skip worktree agent init and tmux/claude prerequisite checks in dry-run mode + +### Security +- Security check: verify allowed_signers on meta branch is public-key-only (#768) +- VS Code extension security hardening (E1-E3) ([GH-169], [GH-175]) +- CI/CD fuzz testing improvements (T1-T5) ([GH-168]) + +### Changed +- Restrict proptest CI job to release branches and PRs to main to reduce CI minutes ([GH-228]) + +## [0.2.0] - 2026-03-03 + +First stable release. Promotes all changes from v0.1.3-beta.1 plus major new features. + +### Added + +#### Terminal UI (`crosslink tui`) +- `crosslink tui` command — read-only terminal dashboard for browsing crosslink data ([GH-152]) +- Issues tab with tree view, detail view, filtering, and sorting +- Agents tab with active session monitoring +- Knowledge tab with page browser and vivid syntax highlighting +- Milestones tab and Config tab +- Mouse support, command palette (`Ctrl-P`), and clipboard export +- Keyboard navigation with help overlay (`?`) + +#### Container-Based Agent Execution +- `crosslink container` command for isolated agent environments ([GH-110]) +- Container bootstrap command for setting up agent containers +- Updated `/kickoff` and `/check` skills to support container backend + +#### Context Management +- `crosslink context` command — context injection optimization with skills, adaptive reminders, and measurement + +#### Hub Layout V2 +- Compact command, `--defer-id`, lock timeout, V2 stale detection (Phase 5) ([GH-113]) +- Event-based lock confirmation protocol (Phase 3) +- Container bootstrap command (Phase 4) +- SharedWriter updated for v2 hub layout paths ([GH-132]) + +#### Knowledge Management +- Accept-both conflict resolution for knowledge branch sync/push +- Knowledge management prompting norms for agents +- KnowledgeManager for shared research via `crosslink/knowledge` branch with page CRUD and full-text search ([GH-62], [GH-63]) + +#### Init Experience +- Ratatui-based setup wizard with styled progress output for `crosslink init` +- Confirmation screen, taller viewport, and smoother progress output +- Inline TUI rendering with clean Esc cancel +- Interactive TUI walkthrough with `--defaults` and `--reconfigure` flags ([GH-60]) +- Managed `.gitignore` section injected by `crosslink init` +- Document blocked actions and lint checks in default init templates + +#### SSH Signing +- SSH signing foundation — agent key generation, driver key setup, per-commit signing, allowed_signers management ([GH-71]-[GH-76]) +- Track driver signing key fingerprint in interventions and trust approvals + +#### Other +- House style syncing — `crosslink style set/sync/diff/show/unset` for portable project conventions ([GH-91]) +- Consolidated `crosslink config` command — show, get, set, list, reset, diff with typed validation +- Typed comments and auto-documentation trail — comments carry `kind`, `trigger_type`, and `intervention_context` +- Driver intervention tracking with `crosslink issue intervene` command +- cpitd (code clone detection) integration with `crosslink cpitd` command +- Scope session queries to `agent_id` for multi-agent isolation +- Crates.io publish CI workflow on release tags +- GitHub Pages CD workflow for docs site + +### Fixed +- Address TUI adversarial review findings (C1, C2, H2-H4, M3-M7) +- Resolve `.crosslink` directory through git worktrees so hooks work in worktree checkouts ([GH-131]) +- Make hooks resilient to invocation from non-project directories +- Resolve container startup failures found during manual testing +- Resolve `sessions_new` migration batch error from wrong pragma column name ([GH-138]) +- Allow `agent init` to reinit when existing `agent.json` is malformed ([GH-137]) +- Auto-claim lock in `quick`/`create --work` to match `session work` behavior +- Configure fallback git identity in hub cache for CI environments +- Re-check lock ownership after push conflicts in `claim_lock` +- Guard against clock skew in stale lock detection +- Use atomic write (temp + rename) for issue and lock files +- Register SIGTERM/SIGINT handlers for graceful daemon shutdown +- Generate UUID at local issue creation time (not deferred) + +### Security +- Enforce restrictive Unix permissions (0600) on generated SSH keys ([GH-105]) +- Validate key type and principal format in `allowed_signers` parser +- Parse `ssh-keygen` stderr and add timeout to signature verification +- Prevent path traversal in knowledge page names +- Add 10MB file size limit on JSON import +- Add maximum length validation for string inputs (512-char titles) +- Require minimum 3-character agent IDs +- Stop `crosslink init` from overriding project worktree signing key + +### Changed +- Restructure CI/CD workflows for git flow branching model (develop/feature/release/hotfix) +- Add hotfix/release branch triggers and PRs-to-main to CI workflow +- Decouple publish.yml from ci.yml, rely on branch protection +- Rename `crosslink review` subcommand to `crosslink workflow` +- Untrack auto-generated `.claude` and `.crosslink` files from git +- Comprehensive documentation review and quarto site updates +- Full-system adversarial review + +## [0.1.2-alpha.1] - 2026-02-27 + +### Fixed +- SyncManager now detects when running inside a git worktree and reuses the main repo's hub cache instead of trying to create a duplicate `crosslink/hub` worktree (#41) +- Set git user config in test helper for CI compatibility + +### Changed +- Kickoff skill (`/kickoff`) now supports `--verify` flag with three levels: `local` (default), `ci`, and `thorough` for post-implementation verification (#39) +- Updated `/kickoff` and `/featree` skill permissions to cover all tools used during execution (added `Write`, `Read`, `Bash(echo *)`, `Bash(crosslink *)`) (#42) +- Featree skill now uses `crosslink init --force` and `crosslink sync` instead of manual database symlinking for worktree initialization (#42) + +### Removed +- Stale `.chainlink/` directory (legacy issue tracker artifacts) +- Tracked `crosslink/.crosslink/issues.db` (should be local-only) + +## [0.1.1-alpha.1] - 2026-02-26 + +### Multi-Agent Collaboration + +Distributed issue locking and agent coordination, ported from crosslink-enterprise. + +#### Agent Identity +- `crosslink agent init ` — register a machine-local agent identity (stored in `.crosslink/agent.json`, gitignored) +- `crosslink agent status` — show agent identity and currently held locks + +#### Distributed Locking +- `crosslink locks list` — show all active issue locks with stale detection +- `crosslink locks check ` — check if a specific issue is available or claimed +- `crosslink sync` — fetch lock state from the `crosslink/locks` coordination branch, verify GPG signatures, display cache path and commit info + +#### Lock-Aware Workflows +- `crosslink next` now skips issues locked by other agents +- `crosslink session work ` enforces lock ownership before allowing work +- `crosslink create --work` and `crosslink subissue --work` check locks before claiming +- Session start records agent identity in the database (schema v8 to v9 migration) + +#### Daemon Heartbeat +- Daemon pushes agent heartbeat every 2.5 minutes to the coordination branch +- Stale lock detection based on heartbeat freshness + +#### Hook Enhancements +- `session-start.py` runs `crosslink sync` and displays active locks on startup +- `work-check.py` warns (in strict mode) when working on an issue locked by another agent + +#### Init Improvements +- `crosslink init` now writes `.crosslink/.gitignore` to exclude machine-local files (`agent.json`, `.locks-cache/`) + +### Claude 4.6 Opus Optimization Epic (#99) + +Comprehensive overhaul to make crosslink work seamlessly with Claude 4.6 Opus, +reducing tool-call overhead, improving machine-parseable output, and adding +context-compression resilience. + +#### CLI Enhancements +- `crosslink quick` compound command — create + label + work in one call (#100) +- `--json` output flag on show command for structured machine-readable output (#101) +- `--quiet` / `-q` mode for minimal, pipe-friendly output (#108) +- `--work` and `--label` flags on `create` and `subissue` commands (#104) +- `close-all` batch command with label and priority filtering (#107) + +#### Session & Context Management +- Stale session auto-detection and cleanup (auto-ends sessions idle >4 hours) (#102) +- Context compression breadcrumbs via `session action` — records last action, auto-comments on active issue, and restores context on resume (#111) +- PreToolUse hook nudges agent when no active working issue is set (#105) + +#### Templates & Rules +- Three new AI-specific issue templates: `audit`, `continuation`, `investigation` (#110) +- Condensed behavioral guard mode — lighter rule injection after first prompt (#103) +- Reorganized rules into tiered priority system (critical/standard/optional) (#109) + +#### Hooks +- Debounced linting mode in post-edit hook to reduce noise (#106) + +#### Code Quality +- Fix all clippy warnings (introduced `CreateOpts` struct, removed dead imports, idiomatic Rust patterns) (#112) +- Database schema v7→v8 migration (adds `last_action` column to sessions, auto-applied on first use) + +### Added +- Add git clone fallback for cpitd install (#6) +- Add `crosslink integrity` subcommand with `--check` and `--repair` modes (#31) +- Add `--check` flag to `review diff` for CI policy drift detection (#28) +- Add kickoff workflow skills (`/feature`, `/featree`, `/kickoff`, `/check`) to `crosslink init` (#26) +- Add `+key` array-extend semantics in `hook-config.local.json` (#25) +- Add offline issue ID promotion flow with `crosslink promote` (#24) +- Add promotion notifications with reference tracking (#27) +- Add auto-detection of Python toolchain in `crosslink init` and template hook commands (#36) +- Update `crosslink export` to emit per-issue `IssueFile` JSON format (#32) +- Redesign milestones to per-file storage for conflict-free multi-agent writes (#35) +- Deduplicate config-loading logic into shared `crosslink_config.py` module (#29) +- Add `crosslink review diff` slash command for guided policy review (#7) +- Add `hook-config.local.json` support for machine-local overrides (#5) +- Add multi-agent shared issue coordination via `crosslink/hub` branch (#6) +- Add auto-detection of Python toolchain in crosslink init (#21) +- Update READMEs with hook configuration documentation (#119) +- Split tracking instructions into per-mode markdown files (#118) +- Make issue tracking strictness configurable (#117) +- Make blocked git commands user-configurable in work-check hook (#116) +- Update all dependencies to latest versions (#114) +- Add comprehensive edge case testing (proptest, CLI fuzzing, Unicode E2E) (#50) +- Improve session management with auto-start and stronger rules (#48) +- Add sanitizing MCP server for safe web fetching (#47) +- Add macOS binary support to VSCode extension with cross-compilation (#32) +- Auto-create CHANGELOG.md if it doesn't exist when closing issues +- Automatic CHANGELOG.md updates when closing issues (based on labels) +- `--no-changelog` flag to skip changelog entry for internal work +- `crosslink export` now outputs to stdout by default, use `-o` for file output + +### Fixed +- Fix hooks to always find parent .crosslink directory regardless of cwd (#123) +- Fix CI test failure on latest commit (#122) +- Fix vscode engine version to match @types/vscode (#115) +- Fix SQL injection vulnerability in milestone listing (#97) +- Fix cargo-mutants artifact left in production code (#97) +- Fix byte/char length mismatch for Unicode text truncation (#97) +- Fix tree view not filtering subissues by status (#97) +- Fix markdown export silently dropping archived issues (#97) +- Fix daemon log file corruption from duplicate file handles (#97) + +### Changed +- Rename coordination branch from `crosslink/locks` to `crosslink/hub` (#37) +- Optimize CI with tiered job dependencies to save minutes on early failures (#33) +- Rebrand chainlink to crosslink (#4) +- Fix display ID collision in rebase-retry logic (#21) +- Block git mutation commands via hook (#113) +- Fix wrong assertion directions and tautological property tests (#96) +- Fix overly loose CLI integration test assertions (#95) +- Fix display function tests to verify actual output or DB state (#94) +- Add unit tests for session.rs command (#64) +- Add security-focused tests (#82) +- Add unit tests for show.rs command (#58) +- Add unit tests for delete.rs command (#57) +- Add unit tests for update.rs command (#56) +- Add unit tests for label.rs command (#61) +- Add unit tests for status.rs command (#60) +- Add unit tests for search.rs command (#59) +- Add unit tests for models.rs (#75) +- Add unit tests for comment.rs command (#62) +- Add unit tests for create.rs command (#55) +- Add Unicode E2E integration tests (#53) +- Add CLI-layer fuzz target for list/show output (#52) +- Add proptest for string handling functions (#51) +- Issue titles are now expected to be changelog-ready (verb + description) + +## [1.4] - 2026-01-08 + +### Added +- Project infographic for README + +### Fixed +- Audit and fix tautological tests and logical flaws in test suite (#92) +- Fix UTF-8 panic in list truncation (#49) +- Fix macOS cross-compilation linker configuration (#34) +- Import/export roundtrip issues with parent relationships + +## [1.3] - 2026-01-07 + +### Added +- Elixir and Phoenix language rules (community contribution from @Viscosity4373) +- Build system automatically rebuilds Rust binaries when packaging extension +- Improved global.md defaults for AI agents + +### Fixed +- Extension binary update detection (now always overwrites) +- Packager issues + +## [1.2] - 2026-01-05 + +### Added +- VSCode extension for seamless integration +- Agent-agnostic context provider (works with any AI assistant) +- Fuzzing targets for security testing (fuzz_create_issue, fuzz_import, fuzz_search, fuzz_dependency_graph, fuzz_state_machine) +- Property-based testing with proptest +- Cross-platform CI (Windows, macOS, Linux) +- Database corruption recovery +- Daemon auto-start on session start +- ~88% code coverage + +### Security +- Add web.md prompt injection defense rule for external content (#33) +- Bump qs dependency to fix vulnerability + +### Fixed +- Path handling issues on Windows +- Various edge cases found through fuzzing +- Test reliability improvements + +## [1.1] - 2025-12-28 + +### Added +- Issue templates (bug, feature, refactor, research) +- Hook-based test reminder system +- Export/Import functionality (JSON format) +- Milestones for grouping issues +- Issue archiving for completed work +- Best practices rules for 15 programming languages: + - Rust, Python, JavaScript, TypeScript, Go, Java, C#, C++ + - Ruby, PHP, Swift, Kotlin, Scala, Haskell +- Composable rules system for better maintainability + +### Fixed +- Language detection now checks subdirectories + +## [1.0] - 2025-12-27 + +### Added +- Initial release +- Core issue management (create, show, update, close, reopen, delete) +- Issue hierarchy with subissues +- Labels and comments +- Dependencies (block/unblock) +- Issue relations (relate/unrelate) +- Session management with handoff notes +- Timer for time tracking +- Tree view for issue hierarchy +- Search functionality +- Priority levels (low, medium, high, critical) +- SQLite storage (`.crosslink/issues.db`) +- Claude Code hooks integration +- Smart navigation suggestions +- `crosslink next` command for work suggestions + +## Project Goals + +Crosslink is designed to be: +- **Simple**: No complex setup, just `crosslink init` +- **Lean**: Single binary, SQLite storage, no external dependencies +- **AI-First**: Built for AI-assisted development workflows +- **Context-Preserving**: Session handoff notes survive context resets From 498ecaf2d3c9053dd5c0f0f260645e109b2a04c9 Mon Sep 17 00:00:00 2001 From: magnificentlycursed <190320250+magnificentlycursed@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:14:25 -0700 Subject: [PATCH 10/15] fix(container): explicit build version, crosslink-specific version label, and a network-only offline fallback From the second review round of this PR: - build.rs honours an explicit CROSSLINK_VERSION (it must start with the crate version), so CI can give every architecture the same version string; the arm64 binary built with cross had none, because the cross container cannot see the repository's git metadata. - The version label is dev.crosslink.version. org.opencontainers.image.version is inherited from the Ubuntu base ("24.04"), so unlabelled images would have raised false version warnings. - A failed pull falls back to a local copy only when the network failed and only for floating tags. A registry that answers "manifest unknown", "denied" or "unauthorized" has withdrawn or hidden the image, and a cached copy no longer outlives that. - A pinned tag or digest missing locally is pulled explicitly before launch, so its failures carry the same hints and the version check runs on its first launch too. - The image is resolved only for container launches; a local launch (and every swarm and sentinel launch) no longer reads the image settings. - Tests: transport-versus-refusal classification, the full preparation matrix with a fake runtime, the exact inspect arguments of the version check; the namespace test that the always-set repository variable made tautological is removed. --- crosslink/build.rs | 21 ++- crosslink/src/commands/container.rs | 202 ++++++++++++++++------ crosslink/src/commands/kickoff/mod.rs | 11 +- crosslink/src/commands/kickoff/types.rs | 16 ++ crosslink/src/commands/sentinel/engine.rs | 2 +- crosslink/src/commands/swarm/lifecycle.rs | 2 +- 6 files changed, 191 insertions(+), 63 deletions(-) diff --git a/crosslink/build.rs b/crosslink/build.rs index 5ee6136ba..2abcf036c 100644 --- a/crosslink/build.rs +++ b/crosslink/build.rs @@ -5,7 +5,26 @@ use std::path::Path; fn main() { emit_agent_image(); - if let Ok(output) = std::process::Command::new("git") + // CI passes the version string explicitly so every architecture reports + // the same one; cross-compile containers may not see the repository's git + // metadata. Otherwise derive it from git. + println!("cargo:rerun-if-env-changed=CROSSLINK_VERSION"); + let explicit_version = std::env::var("CROSSLINK_VERSION") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + if let Some(version) = explicit_version { + let valid = version.starts_with(env!("CARGO_PKG_VERSION")) + && version + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')); + assert!( + valid, + "CROSSLINK_VERSION={version:?} must start with the crate version {} and use only [A-Za-z0-9.+-]", + env!("CARGO_PKG_VERSION") + ); + println!("cargo:rustc-env=CROSSLINK_VERSION={version}"); + } else if let Ok(output) = std::process::Command::new("git") .args(["rev-parse", "--short", "HEAD"]) .output() { diff --git a/crosslink/src/commands/container.rs b/crosslink/src/commands/container.rs index 00a687c3e..09a7febc7 100644 --- a/crosslink/src/commands/container.rs +++ b/crosslink/src/commands/container.rs @@ -381,13 +381,57 @@ pub(crate) fn github_login_preflight( } } -/// Bring a floating tag (`:nightly`, `:latest`) up to date before a launch. -/// When the registry cannot be reached, an existing local copy is used with a -/// warning, so an offline launch keeps working; without a local copy the error -/// names the reason. Pinned tags and digests are left to the runtime, which -/// pulls them once. +/// Whether a failed pull was the network rather than the registry answering: +/// only then may a launch fall back to a local copy. A registry that answers +/// "not found" or "denied" has withdrawn or hidden the image, and a cached +/// copy must not outlive that. +pub(crate) fn is_transport_failure(stderr: &str) -> bool { + let lower = stderr.to_ascii_lowercase(); + if [ + "manifest unknown", + "not found", + "denied", + "unauthorized", + "forbidden", + ] + .iter() + .any(|needle| lower.contains(needle)) + { + return false; + } + [ + "no such host", + "dial tcp", + "i/o timeout", + "timeout", + "connection refused", + "connection reset", + "network is unreachable", + "temporary failure in name resolution", + "tls handshake", + "eof", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +fn image_present_locally(runtime: &str, image: &str) -> bool { + Command::new(runtime) + .args(["image", "inspect", image]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +/// Make the image ready to launch. A floating tag (`:nightly`, `:latest`) is +/// refreshed every time; a pinned tag or digest is pulled once, when it is not +/// present locally. When the registry cannot be reached and a local copy of a +/// floating tag exists, the launch warns and uses it; any other failure stops +/// the launch with the runtime's reason. pub(crate) fn refresh_floating_image(runtime: &str, image: &str) -> Result<()> { - if !crate::commands::kickoff::is_floating_image(image) { + let floating = crate::commands::kickoff::is_floating_image(image); + if !floating && image_present_locally(runtime, image) { return Ok(()); } let pull = Command::new(runtime) @@ -404,21 +448,18 @@ pub(crate) fn refresh_floating_image(runtime: &str, image: &str) -> Result<()> { .find(|line| !line.is_empty()) .unwrap_or("no error output") .to_string(); - let cached = Command::new(runtime) - .args(["image", "inspect", image]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|status| status.success()); - if cached { + if floating && is_transport_failure(&stderr) && image_present_locally(runtime, image) { eprintln!( - "warning: could not refresh {image} ({reason}); using the local copy, which may be out of date" + "warning: could not reach the registry to refresh {image} ({reason}); using the local copy, which may be out of date" ); return Ok(()); } - bail!( - "could not pull {image}: {reason}\nCheck network and registry access, or pass --image with an image you have locally (`just build-image` tags :local)." - ) + let advice = if is_transport_failure(&stderr) { + "Check network and registry access, or pass --image with an image you have locally (`just build-image` tags :local)." + } else { + "The registry refused the image (it may have been withdrawn, made private, or never published). Pass --image with another image, or build one locally (`just build-image` tags :local)." + }; + bail!("could not pull {image}: {reason}\n{advice}") } /// Pick the image: an explicit `--image` wins, then `CROSSLINK_CONTAINER_IMAGE`, @@ -660,7 +701,9 @@ fn compute_memory_limit(config_override: Option<&str>) -> String { /// The image label that records which crosslink an agent image contains: the /// exact `crosslink --version` of its binary. CI and `container build` set it. -pub(crate) const IMAGE_VERSION_LABEL: &str = "org.opencontainers.image.version"; +/// Crosslink-specific so that labels inherited from a base image (Ubuntu sets +/// `org.opencontainers.image.version`) are never mistaken for it. +pub(crate) const IMAGE_VERSION_LABEL: &str = "dev.crosslink.version"; /// This CLI's version as `crosslink --version` reports it (`+` for git /// builds). @@ -1242,17 +1285,6 @@ mod tests { ); } - #[test] - fn image_name_is_ghcr_namespaced() { - // Fork builds may override the repository at build time. - if std::env::var("CROSSLINK_AGENT_IMAGE_REPOSITORY").is_err() { - assert_eq!( - IMAGE_NAME, - "ghcr.io/corvidae-coding-projects/crosslink-agent" - ); - } - } - #[test] fn build_default_tag_is_distinct_from_lookup_tag() { assert_eq!(BUILD_DEFAULT_TAG, "local"); @@ -1443,7 +1475,15 @@ mod tests { || "exit 1".to_string(), |value| format!("printf '%s\\n' '{value}'"), ); - std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + let log = dir.join("inspect.log"); + std::fs::write( + &path, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n{body}\n", + log.display() + ), + ) + .unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); path.to_string_lossy().into_owned() } @@ -1476,19 +1516,52 @@ mod tests { check_image_version(&fake_inspect_runtime(dir.path(), None), &image), None ); + let calls = std::fs::read_to_string(dir.path().join("inspect.log")).unwrap(); + let expected = format!( + "image inspect --format {{{{ index .Config.Labels \"{IMAGE_VERSION_LABEL}\" }}}} {image}" + ); + assert!(calls.contains(&expected), "{calls}"); + assert_eq!(IMAGE_VERSION_LABEL, "dev.crosslink.version"); + } + + #[test] + fn only_network_failures_count_as_transport_failures() { + for transport in [ + "Error response from daemon: Get \"https://ghcr.io/v2/\": dial tcp: lookup ghcr.io: no such host", + "Error: net/http: TLS handshake timeout", + "dial tcp 140.82.112.34:443: i/o timeout", + "connect: network is unreachable", + ] { + assert!(is_transport_failure(transport), "{transport}"); + } + for refused in [ + "Error response from daemon: manifest unknown", + "Error response from daemon: pull access denied for ghcr.io/x/y, repository does not exist", + "Error: unauthorized: authentication required", + "Error response from daemon: ghcr.io/x/y:nightly not found", + ] { + assert!(!is_transport_failure(refused), "{refused}"); + } } - /// A stand-in runtime for the refresh: `pull` and `image inspect` exit - /// with the given codes, and every call is appended to `calls.log`. + /// A stand-in runtime for image preparation: `pull` prints `pull_stderr` + /// and exits `pull_rc`, `image inspect` exits `inspect_rc`, and every call + /// is appended to `calls.log`. #[cfg(unix)] - fn fake_pull_runtime(dir: &Path, pull_rc: i32, inspect_rc: i32) -> String { + fn fake_pull_runtime( + dir: &Path, + name: &str, + pull_rc: i32, + pull_stderr: &str, + inspect_rc: i32, + ) -> String { use std::os::unix::fs::PermissionsExt; - let path = dir.join(format!("pull-{pull_rc}{inspect_rc}")); + let path = dir.join(name); let log = dir.join("calls.log"); std::fs::write( &path, format!( - "#!/bin/sh\necho \"$*\" >> '{}'\ncase \"$1\" in\n pull) echo 'dial tcp: lookup ghcr.io: no such host' >&2; exit {pull_rc} ;;\n image) exit {inspect_rc} ;;\nesac\nexit 1\n", + "#!/bin/sh\necho \"$*\" >> '{}'\ncase \"$1\" in\n pull) echo '{pull_stderr}' >&2; exit {pull_rc} ;;\n image) exit {inspect_rc} ;;\nesac\nexit 1\n", log.display() ), ) @@ -1499,32 +1572,51 @@ mod tests { #[cfg(unix)] #[test] - fn floating_images_are_refreshed_and_fall_back_to_a_local_copy_offline() { + fn images_are_prepared_with_a_network_only_fallback() { let dir = tempfile::tempdir().unwrap(); let nightly = format!("{IMAGE_NAME}:nightly"); - // Registry reachable: refreshed. - refresh_floating_image(&fake_pull_runtime(dir.path(), 0, 1), &nightly).unwrap(); - // Registry unreachable, local copy present: launch continues. - refresh_floating_image(&fake_pull_runtime(dir.path(), 1, 0), &nightly).unwrap(); - // Unreachable and nothing local: the error carries the runtime's reason. - let error = refresh_floating_image(&fake_pull_runtime(dir.path(), 1, 1), &nightly) + let pinned = format!("{IMAGE_NAME}:0.10.0"); + let offline = "dial tcp: lookup ghcr.io: no such host"; + let withdrawn = "Error response from daemon: manifest unknown"; + let prepare = |name: &str, pull_rc, stderr: &str, inspect_rc, image: &str| { + refresh_floating_image( + &fake_pull_runtime(dir.path(), name, pull_rc, stderr, inspect_rc), + image, + ) + }; + // Floating tag, registry reachable: refreshed. + prepare("a", 0, "", 1, &nightly).unwrap(); + // Floating tag, network down, local copy present: the launch continues. + prepare("b", 1, offline, 0, &nightly).unwrap(); + // Floating tag withdrawn by the registry: a cached copy must not be used. + let error = prepare("c", 1, withdrawn, 0, &nightly) .unwrap_err() .to_string(); - assert!(error.contains("no such host"), "{error}"); - assert!(error.contains("--image"), "{error}"); - // A pinned tag is never pulled here. - let calls_before = std::fs::read_to_string(dir.path().join("calls.log")).unwrap(); - refresh_floating_image( - &fake_pull_runtime(dir.path(), 1, 1), - &format!("{IMAGE_NAME}:0.10.0"), - ) - .unwrap(); - let calls_after = std::fs::read_to_string(dir.path().join("calls.log")).unwrap(); - assert_eq!(calls_before, calls_after); assert!( - calls_after.contains(&format!("pull --quiet {nightly}")), - "{calls_after}" + error.contains("manifest unknown") && error.contains("refused"), + "{error}" ); + // Network down and nothing local: the error carries the reason. + let error = prepare("d", 1, offline, 1, &nightly) + .unwrap_err() + .to_string(); + assert!( + error.contains("no such host") && error.contains("network"), + "{error}" + ); + // Pinned tag present locally: no pull at all. + let log = dir.path().join("calls.log"); + let _ = std::fs::remove_file(&log); + prepare("e", 1, withdrawn, 0, &pinned).unwrap(); + let calls = std::fs::read_to_string(&log).unwrap(); + assert!(!calls.contains("pull"), "{calls}"); + // Pinned tag missing locally: pulled once, and a refusal is reported. + let error = prepare("f", 1, withdrawn, 1, &pinned) + .unwrap_err() + .to_string(); + assert!(error.contains("refused"), "{error}"); + let calls = std::fs::read_to_string(&log).unwrap(); + assert!(calls.contains(&format!("pull --quiet {pinned}")), "{calls}"); } /// A stand-in container runtime: `info`, `volume inspect` and `run` exit diff --git a/crosslink/src/commands/kickoff/mod.rs b/crosslink/src/commands/kickoff/mod.rs index 1d8daf121..ea076a3df 100644 --- a/crosslink/src/commands/kickoff/mod.rs +++ b/crosslink/src/commands/kickoff/mod.rs @@ -14,8 +14,9 @@ mod wizard; mod tests; pub use types::{ - is_floating_image, resolve_agent_image, ContainerMode, KickoffOpts, KickoffReport, PlanOpts, - ReportFormat, VerifyLevel, AGENT_IMAGE_REPOSITORY, DEFAULT_AGENT_IMAGE, EFFORT_LEVELS, + agent_image_for, is_floating_image, resolve_agent_image, ContainerMode, KickoffOpts, + KickoffReport, PlanOpts, ReportFormat, VerifyLevel, AGENT_IMAGE_REPOSITORY, + DEFAULT_AGENT_IMAGE, EFFORT_LEVELS, }; pub use types::{parse_container_mode, parse_duration, parse_verify_level}; @@ -78,7 +79,7 @@ pub fn dispatch( None }; let parsed_container = parse_container_mode(&container)?; - let (image, image_source) = resolve_agent_image(image.as_deref())?; + let (image, image_source) = agent_image_for(&parsed_container, image.as_deref())?; if parsed_container != ContainerMode::None && !quiet { println!("Agent image: {image} (from {image_source})"); } @@ -315,7 +316,7 @@ fn dispatch_launch( (parsed_container != ContainerMode::None) .then_some(crate::agents::SandboxPosture::ExternalIsolation), )?; - let (agent_image, _) = resolve_agent_image(None)?; + let (agent_image, _) = agent_image_for(&parsed_container, None)?; let opts = KickoffOpts { description: &description, issue, @@ -421,7 +422,7 @@ fn dispatch_launch( (parsed_container != ContainerMode::None) .then_some(crate::agents::SandboxPosture::ExternalIsolation), )?; - let (agent_image, _) = resolve_agent_image(None)?; + let (agent_image, _) = agent_image_for(&parsed_container, None)?; let opts = KickoffOpts { description: &description, issue: config.issue, diff --git a/crosslink/src/commands/kickoff/types.rs b/crosslink/src/commands/kickoff/types.rs index 19b9c09c3..44e9981a7 100644 --- a/crosslink/src/commands/kickoff/types.rs +++ b/crosslink/src/commands/kickoff/types.rs @@ -76,6 +76,22 @@ pub fn resolve_agent_image_from( Ok((image, source)) } +/// The agent image for a launch in `mode`: resolved as for every container +/// command when a container is used, and this build's default otherwise (a +/// local launch never reads the image settings, so they cannot fail it). +pub fn agent_image_for( + mode: &ContainerMode, + explicit: Option<&str>, +) -> Result<(String, AgentImageSource)> { + if *mode == ContainerMode::None { + return Ok(( + DEFAULT_AGENT_IMAGE.to_string(), + AgentImageSource::BuildDefault, + )); + } + resolve_agent_image(explicit) +} + /// `resolve_agent_image_from` over the live `CROSSLINK_CONTAINER_IMAGE`. pub fn resolve_agent_image(explicit: Option<&str>) -> Result<(String, AgentImageSource)> { resolve_agent_image_from(explicit, std::env::var(AGENT_IMAGE_ENV).ok().as_deref()) diff --git a/crosslink/src/commands/sentinel/engine.rs b/crosslink/src/commands/sentinel/engine.rs index 5ec2f204c..04f9ef7c4 100644 --- a/crosslink/src/commands/sentinel/engine.rs +++ b/crosslink/src/commands/sentinel/engine.rs @@ -446,7 +446,7 @@ fn spawn_agent( None, )?; - let (agent_image, _) = crate::commands::kickoff::resolve_agent_image(None)?; + let (agent_image, _) = crate::commands::kickoff::agent_image_for(&ContainerMode::None, None)?; let opts = KickoffOpts { description: &scoped_description, issue: Some(issue_id), diff --git a/crosslink/src/commands/swarm/lifecycle.rs b/crosslink/src/commands/swarm/lifecycle.rs index 987bb8672..3ecaa3519 100644 --- a/crosslink/src/commands/swarm/lifecycle.rs +++ b/crosslink/src/commands/swarm/lifecycle.rs @@ -700,7 +700,7 @@ pub fn launch(crosslink_dir: &Path, db: &Database, phase_slug: &str, quiet: bool timeout, None, )?; - let (agent_image, _) = kickoff::resolve_agent_image(None)?; + let (agent_image, _) = kickoff::agent_image_for(&ContainerMode::None, None)?; let opts = KickoffOpts { description: &description, issue: issue_id, From 881ce0d4c39f4f91d06e748086f14bb5214e9bda Mon Sep 17 00:00:00 2001 From: magnificentlycursed <190320250+magnificentlycursed@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:17:22 -0700 Subject: [PATCH 11/15] ci(release): fail closed, release only from main, build releases without the shared cache, and verify what binaries default to From the second review round of this PR: - The gate requires the tagged commit to be on main and proceeds only when the registry answers "not found" for :; a missing token or any other answer stops the release instead of republishing. - The gate sets one version string (crate version + commit) that every binary is built with, passed through cross, and the image's dev.crosslink.version label comes from it; the arm64 binary built with cross previously reported no commit, which would have failed its smoke leg after the release image was already pushed. - Release images build with no cache; the registry cache is written by develop pushes and is no longer trusted for releases. - The smoke test checks, on both architectures, that the binary defaults to this image and that the bundled tools (Claude Code, Codex, gh, uv) run; release-builds checks each binary's default image and version. - Builds also set the image repository from the publishing owner, so a fork's binaries default to the fork's image. - Rehearsal dispatches get their own concurrency group, so a develop push no longer cancels them. - The Dockerfile builds with pipefail, so a failed installer download fails the build, and says which inputs still float. - `just build-image` labels its image with the version it builds. - CI runs actionlint (with shellcheck) on every workflow. - RELEASING.md: the gate's new conditions, deleting a failed stray tag, re-running only failed jobs, what "never republished" covers, and that the intended rulesets are not configured yet. --- .github/workflows/ci.yml | 8 +++ .github/workflows/container-image.yml | 77 +++++++++++++++++++----- .github/workflows/release-builds.yml | 20 ++++++ CHANGELOG.md | 23 ++++--- RELEASING.md | 38 +++++++----- crosslink/Cross.toml | 8 ++- crosslink/resources/container/Dockerfile | 11 +++- justfile | 5 +- 8 files changed, 146 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cc26bf57..05e02a4a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,14 @@ jobs: + workflow-lint: + name: Workflow lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: actionlint (includes shellcheck of run steps) + run: docker run --rm -v "$PWD":/repo -w /repo rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 -color + lint: name: Lint runs-on: ubuntu-latest diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml index d2ab05485..fe94aa4f7 100644 --- a/.github/workflows/container-image.yml +++ b/.github/workflows/container-image.yml @@ -39,7 +39,7 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }}${{ github.event.inputs.rehearse_promotion == 'true' && '-rehearsal' || '' }} # Never cancel a release run part-way through publishing. cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} @@ -61,9 +61,12 @@ jobs: prerelease: ${{ steps.plan.outputs.prerelease }} rehearsal: ${{ steps.plan.outputs.rehearsal }} agent_image_tag: ${{ steps.plan.outputs.agent_image_tag }} + crosslink_version: ${{ steps.plan.outputs.crosslink_version }} cache_ref: ${{ steps.plan.outputs.cache_ref }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 - name: Decide what this run may publish id: plan env: @@ -89,20 +92,35 @@ jobs: echo "::error::Tag v${VERSION} does not match crosslink/Cargo.toml version ${CARGO_VERSION}; nothing was published" exit 1 fi + # Releases come from main: a tag on any other commit publishes nothing. + if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then + echo "::error::v${VERSION} points at ${GITHUB_SHA}, which is not on main; releases are tagged on main" + exit 1 + fi # A published version is never replaced: pinned tags are pulled # once and cached, so a second image under the same tag would - # split users across two builds. + # split users across two builds. Only an explicit "not found" + # lets the release proceed; anything else fails closed. token="$(curl -sS "https://${REGISTRY}/token?scope=repository:${REPO_PATH}:pull" | jq -r '.token // empty' || true)" - if [ -n "$token" ]; then - code="$(curl -sS -o /dev/null -w '%{http_code}' -I \ - -H "Authorization: Bearer ${token}" \ - -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \ - "https://${REGISTRY}/v2/${REPO_PATH}/manifests/${VERSION}")" - if [ "$code" = 200 ]; then + if [ -z "$token" ]; then + echo "::error::cannot confirm ${BASE}:${VERSION} is unpublished: no anonymous registry token (package private, missing, or registry unavailable)" + exit 1 + fi + code="$(curl -sS -o /dev/null -w '%{http_code}' -I \ + -H "Authorization: Bearer ${token}" \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \ + "https://${REGISTRY}/v2/${REPO_PATH}/manifests/${VERSION}" || true)" + case "$code" in + 404) ;; + 200) echo "::error::${BASE}:${VERSION} is already published; versions are never republished. Release a new version instead." exit 1 - fi - fi + ;; + *) + echo "::error::cannot confirm ${BASE}:${VERSION} is unpublished (registry answered HTTP ${code:-none}); retry when the registry is healthy" + exit 1 + ;; + esac TAGS=("${BASE}:${VERSION}") PUSH=true RELEASE=true AGENT_IMAGE_TAG="${VERSION}" case "$VERSION" in *-*) PRERELEASE=true ;; esac @@ -136,6 +154,7 @@ jobs: echo "prerelease=${PRERELEASE}" echo "rehearsal=${REHEARSAL}" echo "agent_image_tag=${AGENT_IMAGE_TAG}" + echo "crosslink_version=${CARGO_VERSION}+${SHORT_SHA}" echo "cache_ref=${BASE}:buildcache" } >> "$GITHUB_OUTPUT" echo "Plan: push=${PUSH} release=${RELEASE} prerelease=${PRERELEASE} rehearsal=${REHEARSAL} tags=${TAGS[*]}" @@ -148,6 +167,9 @@ jobs: # The default agent image the binaries in this image will use: the # release version on a tag, nightly otherwise. Set explicitly, never inferred. CROSSLINK_AGENT_IMAGE_TAG: ${{ needs.gate.outputs.agent_image_tag }} + CROSSLINK_AGENT_IMAGE_REPOSITORY: ${{ needs.gate.outputs.base }} + # One version string for every architecture (cross cannot see .git). + CROSSLINK_VERSION: ${{ needs.gate.outputs.crosslink_version }} strategy: fail-fast: true matrix: @@ -242,13 +264,18 @@ jobs: chmod +x crosslink/resources/container/crosslink-amd64 chmod +x crosslink/resources/container/crosslink-arm64 - - name: Read the crosslink version the image will contain + - name: Check the binary reports the gate's version id: label + env: + EXPECTED: ${{ needs.gate.outputs.crosslink_version }} run: | set -euo pipefail out="$(crosslink/resources/container/crosslink-amd64 --version)" - echo "crosslink_version=${out#crosslink }" >> "$GITHUB_OUTPUT" - echo "$out" + if [ "$out" != "crosslink ${EXPECTED}" ]; then + echo "::error::amd64 binary reports '${out}', expected 'crosslink ${EXPECTED}'" + exit 1 + fi + echo "crosslink_version=${EXPECTED}" >> "$GITHUB_OUTPUT" - name: Set up QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 @@ -276,12 +303,16 @@ jobs: push: ${{ needs.gate.outputs.push == 'true' }} tags: ${{ needs.gate.outputs.tags }} labels: | + dev.crosslink.version=${{ steps.label.outputs.crosslink_version }} org.opencontainers.image.version=${{ steps.label.outputs.crosslink_version }} org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} provenance: false - cache-from: type=registry,ref=${{ needs.gate.outputs.cache_ref }} - cache-to: ${{ needs.gate.outputs.push == 'true' && format('type=registry,ref={0},mode=max', needs.gate.outputs.cache_ref) || '' }} + # Releases build without the registry cache that develop pushes + # write, so a release image follows only from its tagged source. + no-cache: ${{ needs.gate.outputs.release == 'true' }} + cache-from: ${{ needs.gate.outputs.release != 'true' && format('type=registry,ref={0}', needs.gate.outputs.cache_ref) || '' }} + cache-to: ${{ needs.gate.outputs.push == 'true' && needs.gate.outputs.release != 'true' && format('type=registry,ref={0},mode=max', needs.gate.outputs.cache_ref) || '' }} - name: Attest the image build if: needs.gate.outputs.push == 'true' @@ -414,6 +445,11 @@ jobs: - name: Pull the built digest run: docker pull --platform ${{ matrix.platform }} "${BASE}@${DIGEST}" + - name: The image's tools run on this platform + run: | + docker run --rm --platform ${{ matrix.platform }} "${BASE}@${DIGEST}" \ + bash -c 'set -e; claude --version; codex --version; gh --version | head -n 1; uv --version' + - name: Manifest covers both architectures (once) if: matrix.platform == 'linux/amd64' run: | @@ -423,6 +459,7 @@ jobs: - name: Run crosslink --version and check the image's version label env: LABEL_VERSION: ${{ needs.publish-image.outputs.crosslink_version }} + AGENT_IMAGE_TAG: ${{ needs.gate.outputs.agent_image_tag }} RELEASE: ${{ needs.gate.outputs.release }} VERSION: ${{ needs.gate.outputs.version }} run: | @@ -430,11 +467,17 @@ jobs: out="$(docker run --rm --platform ${{ matrix.platform }} --entrypoint /usr/local/bin/crosslink "${BASE}@${DIGEST}" --version)" echo "version output: $out" echo "$out" | grep -E '^crosslink [0-9]+\.[0-9]+\.[0-9]+' - label="$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "${BASE}@${DIGEST}")" + label="$(docker image inspect --format '{{ index .Config.Labels "dev.crosslink.version" }}' "${BASE}@${DIGEST}")" if [ "crosslink ${label}" != "$out" ] || [ "$label" != "$LABEL_VERSION" ]; then echo "::error::image label '${label}' does not match its binary '${out}'" exit 1 fi + # The binary must default to this image: the build-matched contract. + expected_default="${BASE}:${AGENT_IMAGE_TAG}" + if ! docker run --rm --platform ${{ matrix.platform }} --entrypoint grep "${BASE}@${DIGEST}" -aqF "$expected_default" /usr/local/bin/crosslink; then + echo "::error::the ${{ matrix.platform }} binary does not default to ${expected_default}" + exit 1 + fi if [ "$RELEASE" = true ]; then actual="$(echo "$out" | awk '{print $2}' | sed 's/+.*//')" if [ "$VERSION" != "$actual" ]; then @@ -528,6 +571,8 @@ jobs: uses: ./.github/workflows/release-builds.yml with: agent_image_tag: ${{ needs.gate.outputs.version }} + agent_image_repository: ${{ needs.gate.outputs.base }} + crosslink_version: ${{ needs.gate.outputs.crosslink_version }} # The GitHub release is created only after the image for this version passed # its anonymous smoke test, because every attached binary defaults to it. diff --git a/.github/workflows/release-builds.yml b/.github/workflows/release-builds.yml index 7f1eb4c77..b42e17485 100644 --- a/.github/workflows/release-builds.yml +++ b/.github/workflows/release-builds.yml @@ -16,6 +16,14 @@ on: description: The default agent image tag the binaries bake in (the release version) type: string required: true + agent_image_repository: + description: The agent image repository the binaries default to + type: string + required: true + crosslink_version: + description: The version string every binary reports + type: string + required: true permissions: contents: read @@ -28,6 +36,8 @@ env: CARGO_TERM_COLOR: always # Release runs pass the version; pull-request check builds are development builds. CROSSLINK_AGENT_IMAGE_TAG: ${{ inputs.agent_image_tag || 'nightly' }} + CROSSLINK_AGENT_IMAGE_REPOSITORY: ${{ inputs.agent_image_repository }} + CROSSLINK_VERSION: ${{ inputs.crosslink_version }} jobs: package-codex-plugin: @@ -136,6 +146,16 @@ jobs: if: matrix.use_cross == true run: cross build --locked --release --target ${{ matrix.target }} + - name: Check the binary defaults to the release image (release runs) + if: inputs.agent_image_tag != '' + shell: bash + run: | + set -euo pipefail + bin="target/${{ matrix.target }}/release/crosslink${{ matrix.os == 'windows-latest' && '.exe' || '' }}" + expected="${{ inputs.agent_image_repository }}:${{ inputs.agent_image_tag }}" + grep -aqF "$expected" "$bin" || { echo "::error::${bin} does not default to ${expected}"; exit 1; } + grep -aqF "${{ inputs.crosslink_version }}" "$bin" || { echo "::error::${bin} does not carry version ${{ inputs.crosslink_version }}"; exit 1; } + - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f9b957d7..b2213bb50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,13 +17,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). valid tag or the build fails. `CROSSLINK_AGENT_IMAGE_REPOSITORY` lets fork builds default to their own registry. `:nightly` and `:latest` are refreshed before every launch, falling back to a local copy with a warning when the - registry is unreachable. Every container command (kickoff, `container + registry cannot be reached (but not when the registry refuses the image). + Every container command (kickoff, `container start`, `container auth`, swarm and sentinel) resolves its image the same way: `--image`, then `CROSSLINK_CONTAINER_IMAGE`, then the build default, and reports where the image came from. - Releases are one gated flow (gh#126, gh#122, gh#123). A `v*` tag must match - `crosslink/Cargo.toml` and must not already be published (versions are never - republished). It pushes only `:`; the smoke test pulls that exact + `crosslink/Cargo.toml`, must be on `main`, and the registry must confirm the + version is unpublished (versions are never republished; anything but an + explicit "not found" stops the release). Release images build without the + registry cache that `develop` pushes write, every binary reports one + version string set by the gate, and the smoke test also checks that the + binaries default to `:` and that the bundled tools run on both + architectures. It pushes only `:`; the smoke test pulls that exact digest anonymously and checks that every pushed tag resolves to it, so a private package or a missing tag fails CI; `:latest` then moves to the verified digest, one promotion at a time, and only when the version is the @@ -38,12 +44,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). `:latest`, and that the frozen `ghcr.io/forecast-bio/crosslink-agent:latest` 0.9.0-beta.1 uses still names the same image. - `RELEASING.md`: the release runbook (what a tag publishes and in which order, rollback, image guarantees, required repository settings) and a checklist for moving the repository or owner (gh#124). -- The agent image pins its base image by digest and its tools by version - (GitHub CLI, Codex, Claude Code, uv, gosu), and verifies gosu by checksum, so - two builds of the same commit contain the same tools (gh#128). +- The agent image pins its base image by digest and its agent tools by version + (GitHub CLI, Codex, Claude Code, uv, gosu), verifies gosu by checksum, and + fails the build when a piped installer download fails (gh#128). Ubuntu's apt + packages and the installer scripts still float. - Kickoff and `container start` compare the crosslink version an agent image - carries (its `org.opencontainers.image.version` label, set by CI and - `container build`) with the CLI's, warning when they differ, since both write + carries (its `dev.crosslink.version` label, set by CI, `container build` and + `just build-image`) with the CLI's, warning when they differ, since both write the same hub (gh#127). The old binary-hash staleness check, which never fired for published images, is removed. - `crosslink container auth login|status|refresh|logout --provider github` diff --git a/RELEASING.md b/RELEASING.md index 21b866fde..ea25d1d49 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -11,11 +11,11 @@ A `v` tag pushed to `main` starts the release flow in | Stage | Job | What happens | Fails when | |---|---|---|---| -| Gate | `gate` | Decides what the run may publish. | The tag does not match `crosslink/Cargo.toml`, or `:` is already published. | -| Image | `build-binary`, `publish-image` | Builds Linux amd64/arm64 binaries that default to `:`, pushes `ghcr.io//crosslink-agent:` with version, revision and source labels, and attests the build. | The build fails. | -| Smoke test | `smoke-verify` | Without logging in, checks that every pushed tag resolves to the built digest, pulls that digest on both architectures, runs `crosslink --version`, and checks the version label. | The package is private, a tag did not land, or the binary is not the tagged version. | +| Gate | `gate` | Decides what the run may publish and the version string every binary reports. | The tag does not match `crosslink/Cargo.toml`, the tagged commit is not on `main`, or the registry does not confirm `:` is unpublished (only an explicit "not found" proceeds). | +| Image | `build-binary`, `publish-image` | Builds Linux amd64/arm64 binaries that default to `:`, pushes `ghcr.io//crosslink-agent:` built without the registry cache (which `develop` pushes write), with `dev.crosslink.version`, revision and source labels, and attests the build. | The build fails. | +| Smoke test | `smoke-verify` | Without logging in, checks that every pushed tag resolves to the built digest, pulls that digest on both architectures, runs `crosslink --version` and the bundled tools, and checks the version label and that the binary defaults to `:`. | The package is private, a tag did not land, a tool is missing, or the binary is not the tagged version. | | `:latest` | `promote-latest` | Points `:latest` at the verified digest. One promotion runs at a time. | Skipped for prereleases and for any version that is not the highest stable `v*` tag. | -| Binaries | `release-binaries` (`release-builds.yml`) | Builds Linux x86_64/aarch64, macOS aarch64/x86_64 and Windows x86_64 binaries plus the Codex plugin archives, all defaulting to `:`. | A build fails. | +| Binaries | `release-binaries` (`release-builds.yml`) | Builds Linux x86_64/aarch64, macOS aarch64/x86_64 and Windows x86_64 binaries plus the Codex plugin archives, and checks each binary defaults to `:` and reports the gate's version. | A build or a check fails. | | GitHub release | `publish-release` | Creates the release with the binaries, plugin archives, `SHA256SUMS` and build attestations. Prereleases are marked as such. | The smoke test or a build failed. | The tag push also runs `publish.yml` (crates.io). That workflow is not part of @@ -55,8 +55,13 @@ Then merge `main` back into `develop` so the next release does not conflict ## If something fails -- **Gate fails:** nothing was published. Fix the tag or the version and tag - again. Delete a wrong tag only if nothing was published from it. +- **Gate fails:** nothing was published. Fix the version or the commit and tag + again. Delete the failed tag (an admin can, even once tags are protected): + promotion takes "highest stable" from git tags, so a stray higher tag would + stop `:latest` from moving and turn the canary red. +- **A later stage fails for a transient reason:** use **Re-run failed jobs**. + Re-running all jobs starts at the gate again, which refuses because + `:` now exists. - **Smoke test fails:** `:` exists but `:latest` did not move and no GitHub release was created. Find the cause, fix it, and release the next patch version. Do not republish the same version: the gate refuses, on @@ -76,7 +81,7 @@ Then merge `main` back into `develop` so the next release does not conflict | Tag | Moves? | Who uses it | |---|---|---| -| `:` | Never (the gate refuses to republish) | Released builds of that version | +| `:` | Not by the release flow (the gate refuses to republish); only someone with package write access acting outside it | Released builds of that version | | `:latest` | To each stable release, after its smoke test | Anyone who asks for it with `--image` | | `:nightly` | On every push to `develop` | Development builds; refreshed before each launch | | `:nightly-` | Not by design, but registry tags can be moved | Pinning a development build | @@ -100,10 +105,12 @@ make private an image that a released version defaults to. ## Updating pinned tools in the image `crosslink/resources/container/Dockerfile` pins the base image by digest and gh, -Codex, Claude Code, uv and gosu by version (gosu by checksum too). To update one, -change its `ARG` (and checksum) in a pull request; the new tools reach users in -the next `:nightly` and the next release. Released `:` images are not -rebuilt with newer tools: ship a patch release instead. +Codex, Claude Code, uv and gosu by version (gosu by checksum too). Still +floating: Ubuntu's apt packages, the gh apt keyring, Codex's npm dependencies, +and the Claude Code and uv installer scripts. To update a pin, change its `ARG` +(and checksum) in a pull request; the new tools reach users in the next +`:nightly` and the next release. Released `:` images are not rebuilt +with newer tools: ship a patch release instead. ## Required repository settings @@ -115,9 +122,12 @@ rebuilt with newer tools: ship a patch release instead. `attestations: write` themselves. - **Environments:** `github-pages` for the docs; `cargo` (tag-only deployment policy) once crates.io publishing is wired up. -- **Rulesets:** `v*` tags may be created only by maintainers and never moved or - deleted; `main` and `develop` require pull requests and passing CI and refuse - force pushes and deletion. +- **Rulesets (intended; not configured as of 2026-10-08):** `v*` tags may be + created only by maintainers and never moved (deletion by an admin only, for + tags whose release failed at the gate); `main` and `develop` require pull + requests and passing CI and refuse force pushes and deletion. Until they + exist, anyone with write access can push to `develop` (which every + development build's `:nightly` follows) and tag a commit on `main`. ## Moving the repository or owner diff --git a/crosslink/Cross.toml b/crosslink/Cross.toml index 9d7eb0d1e..5c7bbda2b 100644 --- a/crosslink/Cross.toml +++ b/crosslink/Cross.toml @@ -1,6 +1,10 @@ [build.env] -# The default agent image tag release builds bake in (see build.rs). -passthrough = ["CROSSLINK_AGENT_IMAGE_TAG"] +# The agent image defaults and version string CI bakes in (see build.rs). +passthrough = [ + "CROSSLINK_AGENT_IMAGE_TAG", + "CROSSLINK_AGENT_IMAGE_REPOSITORY", + "CROSSLINK_VERSION", +] diff --git a/crosslink/resources/container/Dockerfile b/crosslink/resources/container/Dockerfile index bb5cf7849..191d35df2 100644 --- a/crosslink/resources/container/Dockerfile +++ b/crosslink/resources/container/Dockerfile @@ -6,10 +6,15 @@ -# Everything the image installs is pinned, so two builds of the same commit -# contain the same tools. Bump a version (and its checksum) deliberately. +# The base image is pinned by digest and the agent tools by version (gosu also +# by checksum); bump them deliberately. Still floating: Ubuntu's apt packages, +# the gh apt keyring, Codex's npm dependencies, and the Claude Code and uv +# installer scripts (the installers fetch the pinned versions). FROM ubuntu:24.04@sha256:534baea6a22c03a63003dbc8dbe78fe34bc0d7e595d9a9dc9834884ff530eb55 +# A failed download in a piped install must fail the build. +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + ARG TARGETARCH=amd64 ARG GH_VERSION=2.102.0 ARG CODEX_VERSION=0.160.1 @@ -48,7 +53,7 @@ USER agent WORKDIR /home/agent -SHELL ["/bin/bash", "-c"] +SHELL ["/bin/bash", "-o", "pipefail", "-c"] # The installer verifies the checksum of the Claude Code binary it downloads. diff --git a/justfile b/justfile index 0d25a40fe..9bc110cca 100644 --- a/justfile +++ b/justfile @@ -162,7 +162,9 @@ build-image tag="local": aarch64|arm64) RUST_TARGET=aarch64-unknown-linux-musl; DOCKER_ARCH=arm64; PLATFORM=linux/arm64 ;; *) echo "Unsupported host arch: $HOST_ARCH"; exit 1 ;; esac - echo "==> Building crosslink for ${RUST_TARGET}" + CRATE_VERSION="$(sed -n 's/^version = "\(.*\)"$/\1/p' crosslink/Cargo.toml | head -n 1)" + export CROSSLINK_VERSION="${CRATE_VERSION}+$(git rev-parse --short=7 HEAD)" + echo "==> Building crosslink ${CROSSLINK_VERSION} for ${RUST_TARGET}" rustup target add "${RUST_TARGET}" >/dev/null cd crosslink && cargo build --locked --release --target "${RUST_TARGET}" cd .. @@ -172,6 +174,7 @@ build-image tag="local": docker buildx build \ --platform "${PLATFORM}" \ --build-arg "TARGETARCH=${DOCKER_ARCH}" \ + --label "dev.crosslink.version=${CROSSLINK_VERSION}" \ --load \ -t "ghcr.io/corvidae-coding-projects/crosslink-agent:{{tag}}" \ crosslink/resources/container From 45dff756d7cf0e697ae42fb1f9593f8f26b65754 Mon Sep 17 00:00:00 2001 From: magnificentlycursed <190320250+magnificentlycursed@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:17:37 -0700 Subject: [PATCH 12/15] ci(release): give the promotion job a static name A job name built from needs outputs is shown unexpanded when the job is skipped, as it is on every pull request. --- .github/workflows/container-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml index fe94aa4f7..a763ec2f8 100644 --- a/.github/workflows/container-image.yml +++ b/.github/workflows/container-image.yml @@ -493,7 +493,7 @@ jobs: # digest by hand is the rollback. A develop dispatch with # rehearse_promotion exercises this job against :promotion-rehearsal. promote-latest: - name: Promote ${{ needs.gate.outputs.rehearsal == 'true' && ':promotion-rehearsal' || ':latest' }} + name: Promote verified image (:latest or rehearsal) needs: [gate, publish-image, smoke-verify] if: >- (needs.gate.outputs.release == 'true' && needs.gate.outputs.prerelease == 'false') || From dd6a6709d0d0355eb614f27ff01e6828b2515a2e Mon Sep 17 00:00:00 2001 From: magnificentlycursed <190320250+magnificentlycursed@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:37:45 -0700 Subject: [PATCH 13/15] test(container): wait until fake runtime scripts are executable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux a test thread forking a process while another test's fake runtime script is open for writing keeps a write handle in that child until it execs, and executing the script meanwhile fails with ETXTBSY ("Failed to run …/c pull" on CI). The fake runtimes now probe until the script can be spawned before a test uses them. --- crosslink/src/commands/container.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crosslink/src/commands/container.rs b/crosslink/src/commands/container.rs index 09a7febc7..23c7647cd 100644 --- a/crosslink/src/commands/container.rs +++ b/crosslink/src/commands/container.rs @@ -1464,6 +1464,27 @@ mod tests { } /// A stand-in runtime whose `image inspect` prints the given label value. + /// Wait until a freshly written fake runtime script can be executed. On + /// Linux, another test thread forking a process while the script was open + /// for writing keeps a write handle alive in that child until it execs, + /// and executing the script meanwhile fails with ETXTBSY. + #[cfg(unix)] + fn wait_until_spawnable(path: &Path) { + for _ in 0..500 { + match Command::new(path) + .arg("--crosslink-test-probe") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + { + Err(error) if error.raw_os_error() == Some(26) => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + _ => return, + } + } + } + #[cfg(unix)] fn fake_inspect_runtime(dir: &Path, label: Option<&str>) -> String { use std::os::unix::fs::PermissionsExt; @@ -1485,6 +1506,7 @@ mod tests { ) .unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + wait_until_spawnable(&path); path.to_string_lossy().into_owned() } @@ -1567,6 +1589,7 @@ mod tests { ) .unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + wait_until_spawnable(&path); path.to_string_lossy().into_owned() } @@ -1634,6 +1657,7 @@ mod tests { ) .unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + wait_until_spawnable(&path); path.to_string_lossy().into_owned() } From 0a64807ab94b92fd9ede599ec70a15c17611b7d0 Mon Sep 17 00:00:00 2001 From: magnificentlycursed <190320250+magnificentlycursed@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:42:27 -0700 Subject: [PATCH 14/15] fix(container): only a registry refusal stops a cached launch; exact build version check From the third review round of this PR: - Pull failures are classified by whether the registry refused the image (manifest unknown, denied, unauthorized, not found), matched with the image reference removed. Only a refusal stops a launch that has a local copy of a floating tag. Network errors, registry outages (5xx), rate limits and unrecognised failures fall back to the local copy with a warning, and the advice names which case it is. Round 2's list of network phrases missed "context deadline exceeded", "no route to host" and server errors, so cached :nightly launches failed with wrong advice. - An explicit CROSSLINK_VERSION must be the crate version optionally followed by +; a mere prefix match accepted 0.9.01 for 0.9.0. - `--image` without a container now warns that it is ignored. - Tests: refusal classification including words inside the image reference, the resolver's precedence, sources and refusals, and that a local launch never reads the image settings. --- crosslink/build.rs | 5 +- crosslink/src/commands/container.rs | 83 ++++++++++++------------- crosslink/src/commands/kickoff/tests.rs | 43 +++++++++++++ crosslink/src/commands/kickoff/types.rs | 3 + 4 files changed, 90 insertions(+), 44 deletions(-) diff --git a/crosslink/build.rs b/crosslink/build.rs index 2abcf036c..0d03460fc 100644 --- a/crosslink/build.rs +++ b/crosslink/build.rs @@ -14,13 +14,14 @@ fn main() { .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); if let Some(version) = explicit_version { - let valid = version.starts_with(env!("CARGO_PKG_VERSION")) + let rest = version.strip_prefix(env!("CARGO_PKG_VERSION")); + let valid = rest.is_some_and(|rest| rest.is_empty() || rest.starts_with('+')) && version .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')); assert!( valid, - "CROSSLINK_VERSION={version:?} must start with the crate version {} and use only [A-Za-z0-9.+-]", + "CROSSLINK_VERSION={version:?} must be the crate version {} optionally followed by +, using only [A-Za-z0-9.+-]", env!("CARGO_PKG_VERSION") ); println!("cargo:rustc-env=CROSSLINK_VERSION={version}"); diff --git a/crosslink/src/commands/container.rs b/crosslink/src/commands/container.rs index 23c7647cd..259fa07f2 100644 --- a/crosslink/src/commands/container.rs +++ b/crosslink/src/commands/container.rs @@ -381,35 +381,26 @@ pub(crate) fn github_login_preflight( } } -/// Whether a failed pull was the network rather than the registry answering: -/// only then may a launch fall back to a local copy. A registry that answers -/// "not found" or "denied" has withdrawn or hidden the image, and a cached -/// copy must not outlive that. -pub(crate) fn is_transport_failure(stderr: &str) -> bool { - let lower = stderr.to_ascii_lowercase(); - if [ +/// Whether a failed pull is the registry refusing the image (it answered that +/// the image does not exist or may not be pulled). Only a refusal stops a +/// launch that has a local copy of a floating tag: a withdrawn or hidden image +/// must not outlive its withdrawal in caches. Network failures and registry +/// outages (5xx, rate limits) and anything unrecognised are not refusals. +/// The image reference is removed before matching so that words in it cannot +/// match. +pub(crate) fn is_registry_refusal(stderr: &str, image: &str) -> bool { + let lower = stderr.replace(image, "").to_ascii_lowercase(); + [ "manifest unknown", - "not found", - "denied", + "manifest_unknown", + "name unknown", + "pull access denied", + "denied:", + "access denied", "unauthorized", "forbidden", - ] - .iter() - .any(|needle| lower.contains(needle)) - { - return false; - } - [ - "no such host", - "dial tcp", - "i/o timeout", - "timeout", - "connection refused", - "connection reset", - "network is unreachable", - "temporary failure in name resolution", - "tls handshake", - "eof", + "repository does not exist", + ": not found", ] .iter() .any(|needle| lower.contains(needle)) @@ -448,16 +439,17 @@ pub(crate) fn refresh_floating_image(runtime: &str, image: &str) -> Result<()> { .find(|line| !line.is_empty()) .unwrap_or("no error output") .to_string(); - if floating && is_transport_failure(&stderr) && image_present_locally(runtime, image) { + let refused = is_registry_refusal(&stderr, image); + if floating && !refused && image_present_locally(runtime, image) { eprintln!( - "warning: could not reach the registry to refresh {image} ({reason}); using the local copy, which may be out of date" + "warning: could not refresh {image} ({reason}); using the local copy, which may be out of date" ); return Ok(()); } - let advice = if is_transport_failure(&stderr) { - "Check network and registry access, or pass --image with an image you have locally (`just build-image` tags :local)." - } else { + let advice = if refused { "The registry refused the image (it may have been withdrawn, made private, or never published). Pass --image with another image, or build one locally (`just build-image` tags :local)." + } else { + "The registry could not be reached or did not answer (network, outage or rate limit). Retry, or pass --image with an image you have locally (`just build-image` tags :local)." }; bail!("could not pull {image}: {reason}\n{advice}") } @@ -1547,22 +1539,29 @@ mod tests { } #[test] - fn only_network_failures_count_as_transport_failures() { - for transport in [ - "Error response from daemon: Get \"https://ghcr.io/v2/\": dial tcp: lookup ghcr.io: no such host", - "Error: net/http: TLS handshake timeout", - "dial tcp 140.82.112.34:443: i/o timeout", - "connect: network is unreachable", - ] { - assert!(is_transport_failure(transport), "{transport}"); - } + fn only_registry_refusals_stop_a_cached_launch() { + let image = "ghcr.io/geoff/crosslink-agent:nightly"; for refused in [ "Error response from daemon: manifest unknown", "Error response from daemon: pull access denied for ghcr.io/x/y, repository does not exist", "Error: unauthorized: authentication required", - "Error response from daemon: ghcr.io/x/y:nightly not found", + "Error response from daemon: denied: requested access to the resource is denied", + "Error response from daemon: ghcr.io/x/y:nightly: not found", + ] { + assert!(is_registry_refusal(refused, image), "{refused}"); + } + for not_refused in [ + "Error response from daemon: Get \"https://ghcr.io/v2/\": dial tcp: lookup ghcr.io: no such host", + "Error response from daemon: Get \"https://ghcr.io/v2/\": context deadline exceeded", + "dial tcp 140.82.112.34:443: connect: no route to host", + "lookup ghcr.io on 127.0.0.53:53: server misbehaving", + "received unexpected HTTP status: 503 Service Unavailable", + "toomanyrequests: retry later", + "something nobody has seen before", + // Words inside the image reference itself must not match. + "Error: failed to pull ghcr.io/geoff/crosslink-agent:nightly: EOF", ] { - assert!(!is_transport_failure(refused), "{refused}"); + assert!(!is_registry_refusal(not_refused, image), "{not_refused}"); } } diff --git a/crosslink/src/commands/kickoff/tests.rs b/crosslink/src/commands/kickoff/tests.rs index 3becdef07..9239fc93e 100644 --- a/crosslink/src/commands/kickoff/tests.rs +++ b/crosslink/src/commands/kickoff/tests.rs @@ -3523,3 +3523,46 @@ fn floating_tags_are_refreshed_and_pinned_ones_are_not() { assert!(!is_floating_image(&pinned), "{pinned}"); } } + +#[test] +fn one_resolver_picks_the_agent_image_for_every_command() { + let (image, source) = + resolve_agent_image_from(Some("example/agent:1"), Some("env/agent:2")).unwrap(); + assert_eq!( + (image.as_str(), source), + ("example/agent:1", AgentImageSource::Flag) + ); + let (image, source) = resolve_agent_image_from(Some(" "), Some("env/agent:2")).unwrap(); + assert_eq!( + (image.as_str(), source), + ("env/agent:2", AgentImageSource::Environment) + ); + let (image, source) = resolve_agent_image_from(None, Some("")).unwrap(); + assert_eq!( + (image.as_str(), source), + (DEFAULT_AGENT_IMAGE, AgentImageSource::BuildDefault) + ); + assert!(resolve_agent_image_from(Some("--privileged"), None).is_err()); + assert!(resolve_agent_image_from(None, Some("-v/:/host")).is_err()); + assert_eq!( + AgentImageSource::Environment.to_string(), + "CROSSLINK_CONTAINER_IMAGE" + ); +} + +#[test] +fn local_launches_use_the_build_default_without_reading_image_settings() { + let (image, source) = agent_image_for(&ContainerMode::None, Some("example/agent:1")).unwrap(); + assert_eq!( + (image.as_str(), source), + (DEFAULT_AGENT_IMAGE, AgentImageSource::BuildDefault) + ); + // A malformed explicit image cannot fail a local launch. + assert!(agent_image_for(&ContainerMode::None, Some("--privileged")).is_ok()); + assert!(agent_image_for(&ContainerMode::Docker, Some("--privileged")).is_err()); + let (image, source) = agent_image_for(&ContainerMode::Docker, Some("example/agent:1")).unwrap(); + assert_eq!( + (image.as_str(), source), + ("example/agent:1", AgentImageSource::Flag) + ); +} diff --git a/crosslink/src/commands/kickoff/types.rs b/crosslink/src/commands/kickoff/types.rs index 44e9981a7..21a7c728d 100644 --- a/crosslink/src/commands/kickoff/types.rs +++ b/crosslink/src/commands/kickoff/types.rs @@ -84,6 +84,9 @@ pub fn agent_image_for( explicit: Option<&str>, ) -> Result<(String, AgentImageSource)> { if *mode == ContainerMode::None { + if explicit.is_some_and(|image| !image.trim().is_empty()) { + eprintln!("warning: --image is ignored without --container docker|podman"); + } return Ok(( DEFAULT_AGENT_IMAGE.to_string(), AgentImageSource::BuildDefault, From c1dc7268a5b0d1d5227e703f3309aa78c0f2e07c Mon Sep 17 00:00:00 2001 From: magnificentlycursed <190320250+magnificentlycursed@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:44:17 -0700 Subject: [PATCH 15/15] ci(release): re-runs reuse a pushed version, both binaries are checked before the push, rehearsals leave :nightly alone From the third review round of this PR: - A re-run of the publish job (Re-run failed jobs, the runbook's recovery) no longer rebuilds and overwrites :: when the version is already published on a re-run attempt, the job reuses its digest for the smoke test, promotion and attestation; on a first attempt an existing version stops the job. - Before anything is pushed, both the amd64 and the arm64 binaries are checked for the gate's version string and for defaulting to this image, so an arm64 mismatch can no longer burn a release version after the push. - A rehearsal dispatch pushes only :nightly- and writes no cache, so it cannot race a develop push or move :nightly backwards. - Release runs skip the cargo cache as well as the image cache. - The tool check runs under pipefail, so a missing gh fails it. - release-builds passes its inputs to the check through env. - RELEASING.md says plainly that, until rulesets exist, the release checks guard against accidents only (they run from the tagged commit's own workflow), and describes the re-run behaviour and artifact retention. --- .github/workflows/container-image.yml | 63 ++++++++++++++++++++++++--- .github/workflows/release-builds.yml | 10 +++-- RELEASING.md | 15 ++++--- 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml index a763ec2f8..dd3bebf41 100644 --- a/.github/workflows/container-image.yml +++ b/.github/workflows/container-image.yml @@ -63,6 +63,7 @@ jobs: agent_image_tag: ${{ steps.plan.outputs.agent_image_tag }} crosslink_version: ${{ steps.plan.outputs.crosslink_version }} cache_ref: ${{ steps.plan.outputs.cache_ref }} + write_cache: ${{ steps.plan.outputs.write_cache }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: @@ -79,7 +80,7 @@ jobs: REPO_PATH="${OWNER}/${IMAGE_NAME}" SHORT_SHA="${GITHUB_SHA::7}" CARGO_VERSION="$(python3 -c 'import tomllib; print(tomllib.load(open("crosslink/Cargo.toml", "rb"))["package"]["version"])')" - PUSH=false RELEASE=false PRERELEASE=false REHEARSAL=false + PUSH=false RELEASE=false PRERELEASE=false REHEARSAL=false WRITE_CACHE=false AGENT_IMAGE_TAG=nightly case "${GITHUB_EVENT_NAME}:${GITHUB_REF}" in pull_request:*) @@ -127,10 +128,15 @@ jobs: ;; push:refs/heads/develop|workflow_dispatch:refs/heads/develop) VERSION="nightly-${SHORT_SHA}" - TAGS=("${BASE}:nightly" "${BASE}:nightly-${SHORT_SHA}") PUSH=true if [ "${GITHUB_EVENT_NAME}" = workflow_dispatch ] && [ "${REHEARSE:-false}" = true ]; then + # A rehearsal never moves :nightly or writes the shared cache, + # so it cannot race a develop push. REHEARSAL=true + TAGS=("${BASE}:nightly-${SHORT_SHA}") + else + WRITE_CACHE=true + TAGS=("${BASE}:nightly" "${BASE}:nightly-${SHORT_SHA}") fi ;; workflow_dispatch:*) @@ -156,6 +162,7 @@ jobs: echo "agent_image_tag=${AGENT_IMAGE_TAG}" echo "crosslink_version=${CARGO_VERSION}+${SHORT_SHA}" echo "cache_ref=${BASE}:buildcache" + echo "write_cache=${WRITE_CACHE}" } >> "$GITHUB_OUTPUT" echo "Plan: push=${PUSH} release=${RELEASE} prerelease=${PRERELEASE} rehearsal=${REHEARSAL} tags=${TAGS[*]}" @@ -204,6 +211,7 @@ jobs: tool: cross - name: Cache cargo + if: needs.gate.outputs.release != 'true' uses: actions/cache@v4 with: path: | @@ -240,7 +248,7 @@ jobs: id-token: write attestations: write outputs: - digest: ${{ steps.build.outputs.digest }} + digest: ${{ steps.build.outputs.digest || steps.existing.outputs.digest }} crosslink_version: ${{ steps.label.outputs.crosslink_version }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -264,10 +272,11 @@ jobs: chmod +x crosslink/resources/container/crosslink-amd64 chmod +x crosslink/resources/container/crosslink-arm64 - - name: Check the binary reports the gate's version + - name: Check both binaries before anything is pushed id: label env: EXPECTED: ${{ needs.gate.outputs.crosslink_version }} + DEFAULT_IMAGE: ${{ needs.gate.outputs.base }}:${{ needs.gate.outputs.agent_image_tag }} run: | set -euo pipefail out="$(crosslink/resources/container/crosslink-amd64 --version)" @@ -275,8 +284,47 @@ jobs: echo "::error::amd64 binary reports '${out}', expected 'crosslink ${EXPECTED}'" exit 1 fi + for arch in amd64 arm64; do + bin="crosslink/resources/container/crosslink-${arch}" + grep -aqF "$EXPECTED" "$bin" || { echo "::error::${arch} binary does not carry version ${EXPECTED}"; exit 1; } + grep -aqF "$DEFAULT_IMAGE" "$bin" || { echo "::error::${arch} binary does not default to ${DEFAULT_IMAGE}"; exit 1; } + done echo "crosslink_version=${EXPECTED}" >> "$GITHUB_OUTPUT" + # A re-run of this job (Re-run failed jobs) after the push already + # happened must not rebuild and overwrite :: reuse the + # published digest instead. On a first attempt an existing version means + # something else published it since the gate, so stop. + - name: Reuse an already published version on a re-run + id: existing + if: needs.gate.outputs.release == 'true' + env: + BASE: ${{ needs.gate.outputs.base }} + VERSION: ${{ needs.gate.outputs.version }} + ATTEMPT: ${{ github.run_attempt }} + run: | + set -euo pipefail + repo="${BASE#"${REGISTRY}"/}" + token="$(curl -sS "https://${REGISTRY}/token?scope=repository:${repo}:pull" | jq -r '.token // empty' || true)" + test -n "$token" || { echo "::error::no anonymous registry token for ${BASE}"; exit 1; } + headers="$(curl -sS -o /dev/null -D - -I -H "Authorization: Bearer ${token}" \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \ + "https://${REGISTRY}/v2/${repo}/manifests/${VERSION}" || true)" + status="$(printf '%s' "$headers" | head -n 1 | awk '{print $2}')" + case "$status" in + 404) echo "reuse=false" >> "$GITHUB_OUTPUT" ;; + 200) + if [ "$ATTEMPT" = 1 ]; then + echo "::error::${BASE}:${VERSION} appeared after the gate checked it; not overwriting it" + exit 1 + fi + digest="$(printf '%s' "$headers" | tr -d '\r' | awk -F': ' 'tolower($1) == "docker-content-digest" {print $2}')" + echo "Re-run: ${BASE}:${VERSION} is already published as ${digest}; reusing it" + { echo "reuse=true"; echo "digest=${digest}"; } >> "$GITHUB_OUTPUT" + ;; + *) echo "::error::registry answered HTTP ${status:-none} for ${BASE}:${VERSION}"; exit 1 ;; + esac + - name: Set up QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 with: @@ -295,6 +343,7 @@ jobs: - name: Build and push id: build + if: steps.existing.outputs.reuse != 'true' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: crosslink/resources/container @@ -312,14 +361,14 @@ jobs: # write, so a release image follows only from its tagged source. no-cache: ${{ needs.gate.outputs.release == 'true' }} cache-from: ${{ needs.gate.outputs.release != 'true' && format('type=registry,ref={0}', needs.gate.outputs.cache_ref) || '' }} - cache-to: ${{ needs.gate.outputs.push == 'true' && needs.gate.outputs.release != 'true' && format('type=registry,ref={0},mode=max', needs.gate.outputs.cache_ref) || '' }} + cache-to: ${{ needs.gate.outputs.write_cache == 'true' && format('type=registry,ref={0},mode=max', needs.gate.outputs.cache_ref) || '' }} - name: Attest the image build if: needs.gate.outputs.push == 'true' uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 with: subject-name: ${{ needs.gate.outputs.base }} - subject-digest: ${{ steps.build.outputs.digest }} + subject-digest: ${{ steps.build.outputs.digest || steps.existing.outputs.digest }} push-to-registry: true container-pr-smoke: @@ -448,7 +497,7 @@ jobs: - name: The image's tools run on this platform run: | docker run --rm --platform ${{ matrix.platform }} "${BASE}@${DIGEST}" \ - bash -c 'set -e; claude --version; codex --version; gh --version | head -n 1; uv --version' + bash -c 'set -eo pipefail; claude --version; codex --version; gh --version | head -n 1; uv --version' - name: Manifest covers both architectures (once) if: matrix.platform == 'linux/amd64' diff --git a/.github/workflows/release-builds.yml b/.github/workflows/release-builds.yml index b42e17485..709336d6c 100644 --- a/.github/workflows/release-builds.yml +++ b/.github/workflows/release-builds.yml @@ -149,12 +149,14 @@ jobs: - name: Check the binary defaults to the release image (release runs) if: inputs.agent_image_tag != '' shell: bash + env: + BIN: target/${{ matrix.target }}/release/crosslink${{ matrix.os == 'windows-latest' && '.exe' || '' }} + EXPECTED_IMAGE: ${{ inputs.agent_image_repository }}:${{ inputs.agent_image_tag }} + EXPECTED_VERSION: ${{ inputs.crosslink_version }} run: | set -euo pipefail - bin="target/${{ matrix.target }}/release/crosslink${{ matrix.os == 'windows-latest' && '.exe' || '' }}" - expected="${{ inputs.agent_image_repository }}:${{ inputs.agent_image_tag }}" - grep -aqF "$expected" "$bin" || { echo "::error::${bin} does not default to ${expected}"; exit 1; } - grep -aqF "${{ inputs.crosslink_version }}" "$bin" || { echo "::error::${bin} does not carry version ${{ inputs.crosslink_version }}"; exit 1; } + grep -aqF "$EXPECTED_IMAGE" "$BIN" || { echo "::error::${BIN} does not default to ${EXPECTED_IMAGE}"; exit 1; } + grep -aqF "$EXPECTED_VERSION" "$BIN" || { echo "::error::${BIN} does not carry version ${EXPECTED_VERSION}"; exit 1; } - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/RELEASING.md b/RELEASING.md index ea25d1d49..77dc1fdbb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -60,8 +60,10 @@ Then merge `main` back into `develop` so the next release does not conflict promotion takes "highest stable" from git tags, so a stray higher tag would stop `:latest` from moving and turn the canary red. - **A later stage fails for a transient reason:** use **Re-run failed jobs**. - Re-running all jobs starts at the gate again, which refuses because - `:` now exists. + A re-run of the image job reuses the digest that was already pushed instead + of rebuilding it. Re-running all jobs starts at the gate again, which + refuses because `:` now exists. Build artifacts are kept for one + day, so re-run within a day or release the next patch version. - **Smoke test fails:** `:` exists but `:latest` did not move and no GitHub release was created. Find the cause, fix it, and release the next patch version. Do not republish the same version: the gate refuses, on @@ -81,7 +83,7 @@ Then merge `main` back into `develop` so the next release does not conflict | Tag | Moves? | Who uses it | |---|---|---| -| `:` | Not by the release flow (the gate refuses to republish); only someone with package write access acting outside it | Released builds of that version | +| `:` | Not by the release flow (the gate refuses to republish and re-runs reuse the pushed digest); any repository writer can still push one from another workflow until rulesets restrict that | Released builds of that version | | `:latest` | To each stable release, after its smoke test | Anyone who asks for it with `--image` | | `:nightly` | On every push to `develop` | Development builds; refreshed before each launch | | `:nightly-` | Not by design, but registry tags can be moved | Pinning a development build | @@ -126,8 +128,11 @@ with newer tools: ship a patch release instead. created only by maintainers and never moved (deletion by an admin only, for tags whose release failed at the gate); `main` and `develop` require pull requests and passing CI and refuse force pushes and deletion. Until they - exist, anyone with write access can push to `develop` (which every - development build's `:nightly` follows) and tag a commit on `main`. + exist, the release checks guard against accidents only: the gate runs from + the tagged commit's own workflow, so anyone with write access can tag any + commit, edit the checks out, and publish a release, and can push to + `develop`, which every development build's `:nightly` follows. The rulesets + are the only control against that. ## Moving the repository or owner