From 18888e66eec56c82ee6ea2eabad13145182312bd Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 1 Sep 2026 10:11:33 +0100 Subject: [PATCH 1/5] fix: recover stale release tags Signed-off-by: lucarlig --- .github/workflows/release.yml | 69 +++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e472257..6e877d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -167,6 +167,61 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@1.97.0 + - name: Replace stale unpublished release state + id: release-state + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + version=$(cargo metadata --no-deps --format-version 1 \ + | jq -r 'first(.packages[] | select(.name == "cf-integration") | .version) // ""') + if [[ -z "$version" ]]; then + echo "cf-integration is missing from cargo metadata" >&2 + exit 1 + fi + + tag="v$version" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + registry_status=$(curl --silent --show-error \ + --output /dev/null \ + --write-out '%{http_code}' \ + --user-agent 'contextforge-dev-tools-release' \ + "https://crates.io/api/v1/crates/cf-integration/$version") + case "$registry_status" in + 200) + exit 0 + ;; + 404) + ;; + *) + echo "crates.io returned HTTP $registry_status while checking cf-integration $version" >&2 + exit 1 + ;; + esac + + release_rows=$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases" \ + --jq ".[] | select(.tag_name == \"$tag\") | [.id, .draft] | @tsv") + while IFS=$'\t' read -r release_id draft; do + [[ -z "$release_id" ]] && continue + if [[ "$draft" != "true" ]]; then + echo "refusing to replace published GitHub release $tag" >&2 + exit 1 + fi + gh api --method DELETE "repos/$GITHUB_REPOSITORY/releases/$release_id" + done <<< "$release_rows" + + if tag_probe=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" 2>&1); then + gh api --method DELETE "repos/$GITHUB_REPOSITORY/git/refs/tags/$tag" + elif [[ "$tag_probe" != *"HTTP 404"* ]]; then + echo "$tag_probe" >&2 + exit 1 + fi + + if git show-ref --verify --quiet "refs/tags/$tag"; then + git tag --delete "$tag" + fi + - name: Publish root package id: release-plz uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 # v0.5.131 @@ -180,9 +235,23 @@ jobs: id: root-release shell: bash env: + CANDIDATE_TAG: ${{ steps.release-state.outputs.tag }} RELEASES: ${{ steps.release-plz.outputs.releases }} run: | tag=$(jq -r 'first(.[] | select(.package_name == "cf-integration") | .tag) // ""' <<<"$RELEASES") + if [[ -z "$tag" ]] \ + && git fetch --force origin "refs/tags/$CANDIDATE_TAG:refs/tags/$CANDIDATE_TAG" \ + && [[ "$(git rev-list -n 1 "$CANDIDATE_TAG")" == "$GITHUB_SHA" ]]; then + version=${CANDIDATE_TAG#v} + registry_status=$(curl --silent --show-error \ + --output /dev/null \ + --write-out '%{http_code}' \ + --user-agent 'contextforge-dev-tools-release' \ + "https://crates.io/api/v1/crates/cf-integration/$version") + if [[ "$registry_status" == "200" ]]; then + tag=$CANDIDATE_TAG + fi + fi echo "tag=$tag" >> "$GITHUB_OUTPUT" publish-binaries: From 9049e703437436d48ba99e70a0583661a4ad4873 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 1 Sep 2026 10:13:37 +0100 Subject: [PATCH 2/5] fix: target repository for binary uploads Signed-off-by: lucarlig --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e877d1..03a9d4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -278,6 +278,7 @@ jobs: - name: Upload assets and publish release env: + GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.publish.outputs.tag }} run: | From 5f8f1fcf3212a4a80c965e08041ceb6f2518c5ea Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 1 Sep 2026 10:39:40 +0100 Subject: [PATCH 3/5] feat: support preloaded stack images Signed-off-by: lucarlig --- .env.example | 5 + README.md | 5 + ...-compose.cf-controlplane-build-labels.yaml | 3 + docker/docker-compose.cf-dataplane.yaml | 1 + .../compose_integration_tests.rs | 22 +++++ src/infrastructure/config.rs | 99 ++++++++++++++++++- src/runtime/mod.rs | 2 +- src/runtime/stack/mod.rs | 59 +++++++++-- 8 files changed, 183 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 0b92bc3..a210c76 100644 --- a/.env.example +++ b/.env.example @@ -74,6 +74,11 @@ CF_DATAPLANE_LOCAL_IMAGE=contextforge-org/contextforge-data-plane:local # CF_DATAPLANE_IMAGE=ghcr.io/contextforge-org/contextforge-data-plane: CF_DATAPLANE_VERSION=latest +# Registry refresh policy for prebuilt images. Default: always. +# Set to never only after loading the exact image into the local Docker daemon. +# CF_CONTROLPLANE_PULL_POLICY=never +# CF_DATAPLANE_PULL_POLICY=never + # cf-dataplane image platform. Default: auto. # auto resolves to linux/amd64 in published image mode, or the Docker server platform # when CF_DATAPLANE_REF is set for a local source build. diff --git a/README.md b/README.md index 3e5db2b..961676c 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,11 @@ upstream reserves `:latest` for releases. Stack startup pulls changes; incompatible main images make the workflow fail instead of selecting an older pair. +CI jobs that package the code under test as a local image can opt out of +registry access with `CF_CONTROLPLANE_PULL_POLICY=never` or +`CF_DATAPLANE_PULL_POLICY=never`. This is never the default: the selected image +must already be loaded in Docker, and startup fails if it is absent. + Compose requires `JWT_SECRET_KEY` and `AUTH_ENCRYPTION_SECRET`. If either is unset, a runtime-backed action generates stable values under `CF_INTEGRATION_DIR`. Canonical configuration is exported internally as the diff --git a/docker/docker-compose.cf-controlplane-build-labels.yaml b/docker/docker-compose.cf-controlplane-build-labels.yaml index b7e889b..fadc85d 100644 --- a/docker/docker-compose.cf-controlplane-build-labels.yaml +++ b/docker/docker-compose.cf-controlplane-build-labels.yaml @@ -2,6 +2,7 @@ services: gateway: + pull_policy: ${CF_CONTROLPLANE_PULL_POLICY:-always} labels: name: cf-controlplane environment: @@ -19,6 +20,7 @@ services: org.opencontainers.image.ref.name: ${CF_CONTROLPLANE_CHECKOUT_REF:-unknown} migration: + pull_policy: ${CF_CONTROLPLANE_PULL_POLICY:-always} labels: name: cf-migration build: @@ -27,6 +29,7 @@ services: org.opencontainers.image.ref.name: ${CF_CONTROLPLANE_CHECKOUT_REF:-unknown} register_fast_time: + pull_policy: ${CF_CONTROLPLANE_PULL_POLICY:-always} labels: name: cf-register-fast-time diff --git a/docker/docker-compose.cf-dataplane.yaml b/docker/docker-compose.cf-dataplane.yaml index bdfc25c..43e37f8 100644 --- a/docker/docker-compose.cf-dataplane.yaml +++ b/docker/docker-compose.cf-dataplane.yaml @@ -30,6 +30,7 @@ services: dataplane: image: ${CF_DATAPLANE_IMAGE:?Set CF_DATAPLANE_IMAGE to the cf-dataplane image tag} + pull_policy: ${CF_DATAPLANE_PULL_POLICY:-always} platform: ${CF_DATAPLANE_PLATFORM:?Set CF_DATAPLANE_PLATFORM to the cf-dataplane image platform} labels: name: cf-dataplane diff --git a/src/infrastructure/compose_integration_tests.rs b/src/infrastructure/compose_integration_tests.rs index 100ec2f..cb39d4c 100644 --- a/src/infrastructure/compose_integration_tests.rs +++ b/src/infrastructure/compose_integration_tests.rs @@ -276,6 +276,10 @@ fn dataplane_overlays_track_the_current_image_build_and_environment_contract() { Some("host.docker.internal:host-gateway"), "client conformance must let the dataplane reach the official scenario server" ); + assert_eq!( + compose["services"]["dataplane"]["pull_policy"].as_str(), + Some("${CF_DATAPLANE_PULL_POLICY:-always}") + ); for obsolete in [ "CONTEXTFORGE_GATEWAY_RS_ADDRESS", "CONTEXTFORGE_GATEWAY_RS_REDIS_HOSTNAME", @@ -299,6 +303,24 @@ fn dataplane_overlays_track_the_current_image_build_and_environment_contract() { ); } +#[test] +fn controlplane_image_consumers_share_the_explicit_pull_policy() { + let root = workspace_root(); + let compose = + fs::read_to_string(root.join("docker/docker-compose.cf-controlplane-build-labels.yaml")) + .expect("read controlplane metadata overlay"); + let compose: yaml_serde::Value = + yaml_serde::from_str(&compose).expect("parse controlplane metadata overlay"); + + for service in ["gateway", "migration", "register_fast_time"] { + assert_eq!( + compose["services"][service]["pull_policy"].as_str(), + Some("${CF_CONTROLPLANE_PULL_POLICY:-always}"), + "{service} must honor the controlplane image pull policy" + ); + } +} + #[test] fn source_dataplane_adds_build_overlay_last() { let project = ComposeProject::dataplane( diff --git a/src/infrastructure/config.rs b/src/infrastructure/config.rs index 3a9d22c..4afb3df 100644 --- a/src/infrastructure/config.rs +++ b/src/infrastructure/config.rs @@ -53,6 +53,27 @@ pub(crate) struct ImageSetting { resolved: OsString, prebuilt: bool, tracks_main_revision: bool, + pull_policy: ImagePullPolicy, +} + +/// Whether a prebuilt image may be refreshed from its registry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ImagePullPolicy { + /// Resolve registry freshness and pull changed images. + Always, + /// Require an image that is already loaded in the local Docker daemon. + Never, +} + +impl ImagePullPolicy { + /// Returns the Docker Compose spelling for this policy. + #[must_use] + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Always => "always", + Self::Never => "never", + } + } } impl ImageSetting { @@ -73,6 +94,12 @@ impl ImageSetting { pub(crate) fn tracks_main_revision(&self) -> bool { self.tracks_main_revision } + + /// Returns whether this image may be refreshed from its registry. + #[must_use] + pub(crate) const fn pull_policy(&self) -> ImagePullPolicy { + self.pull_policy + } } /// Derived configuration used by integration commands. @@ -155,6 +182,7 @@ impl fmt::Debug for ImageSetting { .field("resolved", &REDACTED) .field("prebuilt", &self.prebuilt) .field("tracks_main_revision", &self.tracks_main_revision) + .field("pull_policy", &self.pull_policy) .finish() } } @@ -327,8 +355,15 @@ impl AppConfig { .auth_encryption_secret, ), }; - let controlplane_image = controlplane_image(&environment); - let dataplane_image = dataplane_image(&environment, &dataplane_ref); + let controlplane_image = controlplane_image( + &environment, + image_pull_policy(&environment, "CF_CONTROLPLANE_PULL_POLICY")?, + ); + let dataplane_image = dataplane_image( + &environment, + &dataplane_ref, + image_pull_policy(&environment, "CF_DATAPLANE_PULL_POLICY")?, + ); let dataplane_platform = shell_value( &environment, "CF_DATAPLANE_PLATFORM", @@ -598,7 +633,10 @@ fn prefixed_value(prefix: &str, suffix: &OsStr) -> OsString { value } -fn controlplane_image(environment: &LoadedEnvironment) -> ImageSetting { +fn controlplane_image( + environment: &LoadedEnvironment, + pull_policy: ImagePullPolicy, +) -> ImageSetting { let (resolved, tracks_main_revision) = if let Some(image) = first_nonempty(environment, "CF_CONTROLPLANE_IMAGE") { (image.value.clone(), false) @@ -619,10 +657,15 @@ fn controlplane_image(environment: &LoadedEnvironment) -> ImageSetting { resolved, prebuilt: true, tracks_main_revision, + pull_policy, } } -fn dataplane_image(environment: &LoadedEnvironment, dataplane_ref: &SourcedValue) -> ImageSetting { +fn dataplane_image( + environment: &LoadedEnvironment, + dataplane_ref: &SourcedValue, + pull_policy: ImagePullPolicy, +) -> ImageSetting { let explicitly_set = is_configured_value(environment, "CF_DATAPLANE_IMAGE"); let resolved = if let Some(image) = first_nonempty(environment, "CF_DATAPLANE_IMAGE") { image.value.clone() @@ -649,6 +692,16 @@ fn dataplane_image(environment: &LoadedEnvironment, dataplane_ref: &SourcedValue resolved, prebuilt: explicitly_set || dataplane_ref.value.is_empty(), tracks_main_revision: false, + pull_policy, + } +} + +fn image_pull_policy(environment: &LoadedEnvironment, key: &str) -> Result { + let value = shell_value(environment, key, OsString::from("always")); + match value.value.to_str() { + Some("always") => Ok(ImagePullPolicy::Always), + Some("never") => Ok(ImagePullPolicy::Never), + _ => bail!("{key} must be always or never"), } } @@ -959,10 +1012,15 @@ mod tests { ); assert!(config.controlplane_image.prebuilt); assert!(config.controlplane_image.tracks_main_revision); + assert_eq!( + config.controlplane_image.pull_policy, + ImagePullPolicy::Always + ); assert_eq!( config.dataplane_image.resolved, OsStr::new("ghcr.io/contextforge-org/contextforge-data-plane:latest") ); + assert_eq!(config.dataplane_image.pull_policy, ImagePullPolicy::Always); assert_sourced( &config.dataplane_platform, OsStr::new("auto"), @@ -1181,6 +1239,39 @@ mod tests { ); } + #[test] + fn image_pull_policies_are_independent_explicit_opt_ins() { + let root = repository_root(); + let process = environment(&[ + ("CF_CONTROLPLANE_PULL_POLICY", "never"), + ("CF_DATAPLANE_PULL_POLICY", "never"), + ]); + + let config = load_app_config(root.path(), &process); + + assert_eq!( + config.controlplane_image.pull_policy(), + ImagePullPolicy::Never + ); + assert_eq!(config.dataplane_image.pull_policy(), ImagePullPolicy::Never); + } + + #[test] + fn invalid_image_pull_policy_is_rejected() { + let root = repository_root(); + let process = environment(&[("CF_DATAPLANE_PULL_POLICY", "sometimes")]); + let bootstrap = + ConfigBootstrap::load(&process, root.path()).expect("bootstrap should load"); + + let error = AppConfig::load(bootstrap, ConfigRequirements::RUNTIME) + .expect_err("an unknown pull policy must fail"); + + assert_eq!( + error.to_string(), + "CF_DATAPLANE_PULL_POLICY must be always or never" + ); + } + #[test] fn fast_time_image_uses_canonical_override_and_ignores_legacy_input() { let root = repository_root(); diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 0ebd86b..38ebe71 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -33,7 +33,7 @@ use crate::conformance::results::{ }; use crate::infrastructure::checkout::{CheckoutManager, CheckoutRequest}; use crate::infrastructure::compose::{ComposeProject, validate_integration_contract}; -use crate::infrastructure::config::AppConfig; +use crate::infrastructure::config::{AppConfig, ImagePullPolicy}; use crate::infrastructure::process::{CommandSpec, LoggingProcessRunner, ProcessRunner}; use crate::infrastructure::stack::{ BuildInputs, BuildMode, CleanupKind, FreshnessSnapshot, ServiceSnapshot, StackCommandPlan, diff --git a/src/runtime/stack/mod.rs b/src/runtime/stack/mod.rs index fbf5bf2..5e0361c 100644 --- a/src/runtime/stack/mod.rs +++ b/src/runtime/stack/mod.rs @@ -263,6 +263,10 @@ impl RuntimeContext { ) .env("CF_DATAPLANE_DIR", self.config.dataplane_dir().as_os_str()) .env("CF_CONTROLPLANE_IMAGE", controlplane_image.clone()) + .env( + "CF_CONTROLPLANE_PULL_POLICY", + self.config.controlplane_image().pull_policy().as_str(), + ) .env("IMAGE_LOCAL", controlplane_image) .env( "FAST_TIME_IMAGE", @@ -272,6 +276,10 @@ impl RuntimeContext { "CF_DATAPLANE_IMAGE", self.config.dataplane_image().resolved().to_owned(), ) + .env( + "CF_DATAPLANE_PULL_POLICY", + self.config.dataplane_image().pull_policy().as_str(), + ) .env("CF_DATAPLANE_PLATFORM", self.dataplane_platform()?) .env("JWT_SECRET_KEY", self.config.jwt_secret_key().value.clone()) .env( @@ -522,6 +530,7 @@ impl RuntimeContext { "cf-controlplane", &controlplane_image, None, + self.config.controlplane_image().pull_policy(), report_progress, )?; } @@ -531,6 +540,7 @@ impl RuntimeContext { "cf-dataplane", self.config.dataplane_image().resolved(), Some(platform.as_os_str()), + self.config.dataplane_image().pull_policy(), report_progress, )?; } @@ -542,16 +552,9 @@ impl RuntimeContext { label: &str, image: &OsStr, platform: Option<&OsStr>, + pull_policy: ImagePullPolicy, report_progress: bool, ) -> AppResult<()> { - let inspect = CommandSpec::new("docker").args([ - OsString::from("buildx"), - OsString::from("imagetools"), - OsString::from("inspect"), - image.to_owned(), - OsString::from("--format"), - OsString::from("{{.Manifest.Digest}}"), - ]); let local_ids = self.capture_text(&CommandSpec::new("docker").args([ OsString::from("image"), OsString::from("ls"), @@ -560,6 +563,18 @@ impl RuntimeContext { image.to_owned(), ]))?; let local_exists = !local_ids.is_empty(); + if pull_policy == ImagePullPolicy::Never { + return require_preloaded_image(label, image, local_exists); + } + + let inspect = CommandSpec::new("docker").args([ + OsString::from("buildx"), + OsString::from("imagetools"), + OsString::from("inspect"), + image.to_owned(), + OsString::from("--format"), + OsString::from("{{.Manifest.Digest}}"), + ]); let remote_digest = match self.capture_text(&inspect) { Ok(digest) => (!digest.is_empty()).then_some(digest), Err(error) if local_exists => { @@ -994,6 +1009,16 @@ impl RuntimeContext { } } +fn require_preloaded_image(label: &str, image: &OsStr, local_exists: bool) -> AppResult<()> { + if local_exists { + return Ok(()); + } + Err(AppFailure::from(anyhow!( + "{label} image {} is not loaded locally; preload it or set its pull policy to always", + image.to_string_lossy() + ))) +} + fn latest_published_controlplane_image( revisions: &str, mut is_published: impl FnMut(&OsStr) -> bool, @@ -1095,6 +1120,24 @@ mod tests { assert!(error.to_string().contains("newest 1 commits on main")); } + #[test] + fn never_pull_policy_accepts_a_preloaded_image() { + require_preloaded_image("cf-dataplane", OsStr::new("local/dataplane:test"), true) + .expect("a preloaded image should satisfy the never pull policy"); + } + + #[test] + fn never_pull_policy_rejects_a_missing_local_image() { + let error = + require_preloaded_image("cf-dataplane", OsStr::new("local/dataplane:test"), false) + .expect_err("a missing preloaded image must fail"); + + assert_eq!( + error.to_string(), + "cf-dataplane image local/dataplane:test is not loaded locally; preload it or set its pull policy to always" + ); + } + #[test] fn compose_commands_default_to_the_latest_modern_conformance_era() { let command = with_default_conformance_server_era(CommandSpec::new("docker")); From f493e918e0ec3d81ec170bd443e2fc18b8aefd42 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 1 Sep 2026 10:53:40 +0100 Subject: [PATCH 4/5] fix: preserve source image builds Signed-off-by: lucarlig --- src/runtime/stack/mod.rs | 98 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 88 insertions(+), 10 deletions(-) diff --git a/src/runtime/stack/mod.rs b/src/runtime/stack/mod.rs index 5e0361c..514e7ac 100644 --- a/src/runtime/stack/mod.rs +++ b/src/runtime/stack/mod.rs @@ -147,8 +147,19 @@ impl RuntimeContext { AppFailure::from(anyhow!("CONTROLPLANE_LOCUST_WORKERS must be an integer")) })?; let command = StackCommandPlan::up(project, mode, build, start_locust, locust_workers); - self.runner - .run(&self.compose_environment(command.command().clone(), mode, true)?)?; + let command = self.compose_environment(command.command().clone(), mode, true)?; + let (controlplane_pull_policy, dataplane_pull_policy) = compose_pull_policies( + mode, + build, + !self.config.dataplane_ref().value.is_empty(), + self.config.controlplane_image().pull_policy(), + self.config.dataplane_image().pull_policy(), + ); + self.runner.run( + &command + .env("CF_CONTROLPLANE_PULL_POLICY", controlplane_pull_policy) + .env("CF_DATAPLANE_PULL_POLICY", dataplane_pull_policy), + )?; self.wait_for_public_endpoint(mode, report_progress).await?; if report_progress { println!( @@ -251,6 +262,13 @@ impl RuntimeContext { command = command.env(key.clone(), value.value.clone()); } } + let (controlplane_pull_policy, dataplane_pull_policy) = compose_pull_policies( + mode, + false, + !self.config.dataplane_ref().value.is_empty(), + self.config.controlplane_image().pull_policy(), + self.config.dataplane_image().pull_policy(), + ); command = command .env("CF_INTEGRATION_ROOT", self.config.asset_root().as_os_str()) .env( @@ -263,10 +281,7 @@ impl RuntimeContext { ) .env("CF_DATAPLANE_DIR", self.config.dataplane_dir().as_os_str()) .env("CF_CONTROLPLANE_IMAGE", controlplane_image.clone()) - .env( - "CF_CONTROLPLANE_PULL_POLICY", - self.config.controlplane_image().pull_policy().as_str(), - ) + .env("CF_CONTROLPLANE_PULL_POLICY", controlplane_pull_policy) .env("IMAGE_LOCAL", controlplane_image) .env( "FAST_TIME_IMAGE", @@ -276,10 +291,7 @@ impl RuntimeContext { "CF_DATAPLANE_IMAGE", self.config.dataplane_image().resolved().to_owned(), ) - .env( - "CF_DATAPLANE_PULL_POLICY", - self.config.dataplane_image().pull_policy().as_str(), - ) + .env("CF_DATAPLANE_PULL_POLICY", dataplane_pull_policy) .env("CF_DATAPLANE_PLATFORM", self.dataplane_platform()?) .env("JWT_SECRET_KEY", self.config.jwt_secret_key().value.clone()) .env( @@ -1019,6 +1031,30 @@ fn require_preloaded_image(label: &str, image: &OsStr, local_exists: bool) -> Ap ))) } +fn compose_pull_policies( + mode: StackMode, + build: bool, + dataplane_source: bool, + controlplane: ImagePullPolicy, + dataplane: ImagePullPolicy, +) -> (&'static str, &'static str) { + let controlplane = if build && (mode == StackMode::Controlplane || !dataplane_source) { + "build" + } else { + controlplane.as_str() + }; + let dataplane = if dataplane_source { + if build && mode == StackMode::Dataplane { + "build" + } else { + "never" + } + } else { + dataplane.as_str() + }; + (controlplane, dataplane) +} + fn latest_published_controlplane_image( revisions: &str, mut is_published: impl FnMut(&OsStr) -> bool, @@ -1138,6 +1174,48 @@ mod tests { ); } + #[test] + fn source_dataplane_build_does_not_change_the_controlplane_policy() { + assert_eq!( + compose_pull_policies( + StackMode::Dataplane, + true, + true, + ImagePullPolicy::Always, + ImagePullPolicy::Always, + ), + ("always", "build") + ); + } + + #[test] + fn current_source_dataplane_uses_its_cached_image_without_rebuilding() { + assert_eq!( + compose_pull_policies( + StackMode::Dataplane, + false, + true, + ImagePullPolicy::Always, + ImagePullPolicy::Always, + ), + ("always", "never") + ); + } + + #[test] + fn controlplane_build_uses_compose_build_policy() { + assert_eq!( + compose_pull_policies( + StackMode::Controlplane, + true, + false, + ImagePullPolicy::Always, + ImagePullPolicy::Always, + ), + ("build", "always") + ); + } + #[test] fn compose_commands_default_to_the_latest_modern_conformance_era() { let command = with_default_conformance_server_era(CommandSpec::new("docker")); From 0e59eef43a0fcbb437dca8b8351385a43a9075c3 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 1 Sep 2026 11:17:57 +0100 Subject: [PATCH 5/5] feat: move CI orchestration into the CLI Signed-off-by: lucarlig --- .github/workflows/release.yml | 87 ++----- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app.rs | 67 ++++- src/app_tests.rs | 53 +++- src/cli.rs | 58 +++++ src/cli_public_tests.rs | 22 +- src/runtime/ci.rs | 449 ++++++++++++++++++++++++++++++++++ src/runtime/mod.rs | 13 +- 9 files changed, 678 insertions(+), 75 deletions(-) create mode 100644 src/runtime/ci.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03a9d4e..e256e0a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -149,6 +149,15 @@ jobs: if-no-files-found: error retention-days: 1 + - name: Upload release orchestration CLI + if: matrix.target == 'x86_64-unknown-linux-gnu' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-orchestration-cli + path: dist/package/cf-integration + if-no-files-found: error + retention-days: 1 + publish: name: Publish crate and tag needs: [quality, build-binaries] @@ -167,60 +176,20 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@1.97.0 + - name: Download release orchestration CLI + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-orchestration-cli + path: .integration/release-cli + + - name: Make release orchestration CLI executable + run: chmod +x .integration/release-cli/cf-integration + - name: Replace stale unpublished release state id: release-state - shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - version=$(cargo metadata --no-deps --format-version 1 \ - | jq -r 'first(.packages[] | select(.name == "cf-integration") | .version) // ""') - if [[ -z "$version" ]]; then - echo "cf-integration is missing from cargo metadata" >&2 - exit 1 - fi - - tag="v$version" - echo "tag=$tag" >> "$GITHUB_OUTPUT" - - registry_status=$(curl --silent --show-error \ - --output /dev/null \ - --write-out '%{http_code}' \ - --user-agent 'contextforge-dev-tools-release' \ - "https://crates.io/api/v1/crates/cf-integration/$version") - case "$registry_status" in - 200) - exit 0 - ;; - 404) - ;; - *) - echo "crates.io returned HTTP $registry_status while checking cf-integration $version" >&2 - exit 1 - ;; - esac - - release_rows=$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases" \ - --jq ".[] | select(.tag_name == \"$tag\") | [.id, .draft] | @tsv") - while IFS=$'\t' read -r release_id draft; do - [[ -z "$release_id" ]] && continue - if [[ "$draft" != "true" ]]; then - echo "refusing to replace published GitHub release $tag" >&2 - exit 1 - fi - gh api --method DELETE "repos/$GITHUB_REPOSITORY/releases/$release_id" - done <<< "$release_rows" - - if tag_probe=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" 2>&1); then - gh api --method DELETE "repos/$GITHUB_REPOSITORY/git/refs/tags/$tag" - elif [[ "$tag_probe" != *"HTTP 404"* ]]; then - echo "$tag_probe" >&2 - exit 1 - fi - - if git show-ref --verify --quiet "refs/tags/$tag"; then - git tag --delete "$tag" - fi + run: .integration/release-cli/cf-integration ci prepare-release - name: Publish root package id: release-plz @@ -233,26 +202,10 @@ jobs: - name: Select CLI release id: root-release - shell: bash env: CANDIDATE_TAG: ${{ steps.release-state.outputs.tag }} RELEASES: ${{ steps.release-plz.outputs.releases }} - run: | - tag=$(jq -r 'first(.[] | select(.package_name == "cf-integration") | .tag) // ""' <<<"$RELEASES") - if [[ -z "$tag" ]] \ - && git fetch --force origin "refs/tags/$CANDIDATE_TAG:refs/tags/$CANDIDATE_TAG" \ - && [[ "$(git rev-list -n 1 "$CANDIDATE_TAG")" == "$GITHUB_SHA" ]]; then - version=${CANDIDATE_TAG#v} - registry_status=$(curl --silent --show-error \ - --output /dev/null \ - --write-out '%{http_code}' \ - --user-agent 'contextforge-dev-tools-release' \ - "https://crates.io/api/v1/crates/cf-integration/$version") - if [[ "$registry_status" == "200" ]]; then - tag=$CANDIDATE_TAG - fi - fi - echo "tag=$tag" >> "$GITHUB_OUTPUT" + run: .integration/release-cli/cf-integration ci select-release publish-binaries: name: Publish release binaries diff --git a/Cargo.lock b/Cargo.lock index 701bc23..68d7476 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,7 +177,7 @@ dependencies = [ [[package]] name = "cf-integration" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 071e8f0..475f48d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cf-integration" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.97" license = "Apache-2.0" diff --git a/src/app.rs b/src/app.rs index ce27bda..a9907aa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use std::ffi::{OsStr, OsString}; -use std::path::PathBuf; +use std::path::{Component, PathBuf}; use std::str::FromStr; use crate::conformance::DEFAULT_MCP_SPEC_VERSION; @@ -16,7 +16,7 @@ use crate::performance::LoadRequest; use anyhow::{Result, bail}; use crate::cli::{ - Cli, CliLane, CliTopology, Command, ConformanceCommand, DebugCommand, LiveGroup, + CiCommand, Cli, CliLane, CliTopology, Command, ConformanceCommand, DebugCommand, LiveGroup, ProtocolVersion, StackCommand, TokenKind, TopologySelection, }; const STACK_MODE_ENV: &str = "CF_MCP_STACK_MODE"; @@ -38,6 +38,7 @@ pub(crate) enum Action { }, Conformance(ConformanceAction), Debug(DebugAction), + Ci(CiAction), } impl Action { @@ -57,6 +58,9 @@ impl Action { Self::Conformance(ConformanceAction::Report { .. }) => "conformance report", Self::Debug(DebugAction::Inspect { .. }) => "debug inspect", Self::Debug(DebugAction::Token { .. }) => "debug token", + Self::Ci(CiAction::PrepareImage { .. }) => "prepare prebuilt CI image", + Self::Ci(CiAction::PrepareRelease) => "prepare release state", + Self::Ci(CiAction::SelectRelease) => "select release tag", } } @@ -100,6 +104,13 @@ impl Action { Self::Debug(DebugAction::Token { .. }) => { String::from("Topology: not applicable (token only)") } + Self::Ci(CiAction::PrepareImage { .. }) => { + String::from("CI operation: prepare prebuilt image") + } + Self::Ci(CiAction::PrepareRelease) => { + String::from("CI operation: prepare release state") + } + Self::Ci(CiAction::SelectRelease) => String::from("CI operation: select release tag"), } } @@ -121,6 +132,7 @@ impl Action { self, Self::Conformance(ConformanceAction::Report { .. }) | Self::Debug(DebugAction::Token { .. }) + | Self::Ci(_) ) } } @@ -251,6 +263,23 @@ pub(crate) enum DebugAction { }, } +/// Repository CI operation executed by the published CLI. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CiAction { + PrepareImage { + artifact: String, + binary: PathBuf, + image: String, + repository: String, + revision: Option, + dockerfile: PathBuf, + target: String, + download_dir: PathBuf, + }, + PrepareRelease, + SelectRelease, +} + /// Resolves a parsed CLI without starting child processes or mutating global state. /// /// # Errors @@ -346,9 +375,43 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result Ok(Action::Ci(match args.command { + CiCommand::PrepareImage(args) => { + let mut components = args.binary.components(); + if !matches!(components.next(), Some(Component::Normal(_))) + || components.next().is_some() + { + bail!("--binary must be one filename at the artifact root"); + } + let repository = args + .repository + .or_else(|| environment_utf8(environment, "GITHUB_REPOSITORY")) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow::anyhow!("set --repository or GITHUB_REPOSITORY"))?; + CiAction::PrepareImage { + artifact: args.artifact, + binary: args.binary, + image: args.image, + repository, + revision: args.revision, + dockerfile: args.dockerfile, + target: args.target, + download_dir: args.download_dir, + } + } + CiCommand::PrepareRelease => CiAction::PrepareRelease, + CiCommand::SelectRelease => CiAction::SelectRelease, + })), } } +fn environment_utf8(environment: &Environment, key: &str) -> Option { + environment + .get(std::ffi::OsStr::new(key)) + .and_then(|value| value.to_str()) + .map(str::to_owned) +} + fn resolve_live_lane(lane: Option, environment: &Environment) -> Result { Ok(match lane { Some(CliLane::FixtureDirect) => SemanticLane::FixtureDirect, diff --git a/src/app_tests.rs b/src/app_tests.rs index 6a8bef7..e391fdc 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -2,7 +2,7 @@ use std::ffi::OsString; use std::path::PathBuf; use cf_integration::app::{ - Action, ConformanceAction, DebugAction, ResolvedLoadArgs, StackAction, resolve_action, + Action, CiAction, ConformanceAction, DebugAction, ResolvedLoadArgs, StackAction, resolve_action, }; use cf_integration::cli::{Cli, LiveGroup, ProtocolVersion, TokenKind, TopologySelection}; use cf_integration::conformance::results::{ConformanceServerEra, SemanticLane}; @@ -51,6 +51,57 @@ fn every_subcommand_has_a_stable_progress_description() { } } +#[test] +fn ci_image_preparation_is_read_only_until_execution() { + let action = action( + &[ + "cf-integration", + "ci", + "prepare-image", + "--artifact", + "contextforge-data-plane-conformance", + "--binary", + "contextforge-data-plane", + "--image", + "contextforge-data-plane:conformance", + ], + &[( + "GITHUB_REPOSITORY", + "contextforge-org/contextforge-data-plane", + )], + ); + + assert!(matches!(&action, Action::Ci(CiAction::PrepareImage { .. }))); + assert_eq!(action.description(), "prepare prebuilt CI image"); + assert!(!action.requires_runtime_assets()); +} + +#[test] +fn ci_image_preparation_rejects_nested_artifact_paths() { + let cli = Cli::try_parse_from([ + "cf-integration", + "ci", + "prepare-image", + "--artifact", + "artifact", + "--binary", + "nested/binary", + "--image", + "service:test", + "--repository", + "owner/repository", + ]) + .expect("CLI syntax should parse before path validation"); + + let error = resolve_action(cli, &Environment::new()) + .expect_err("artifact binary must remain inside its download root"); + + assert_eq!( + error.to_string(), + "--binary must be one filename at the artifact root" + ); +} + #[test] fn every_subcommand_reports_its_resolved_topology_at_startup() { let cases: &[(&[&str], &str)] = &[ diff --git a/src/cli.rs b/src/cli.rs index c527c30..9566556 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -99,6 +99,64 @@ pub(crate) enum Command { Conformance(ConformanceArgs), /// Run manual debugging utilities. Debug(DebugArgs), + /// Repository CI orchestration used by ContextForge workflows. + #[command(hide = true)] + Ci(CiArgs), +} + +/// Internal CI command selection. +#[derive(Debug, Clone, PartialEq, Eq, Args)] +pub(crate) struct CiArgs { + /// CI operation to run. + #[command(subcommand)] + pub(crate) command: CiCommand, +} + +/// Internal CI operations kept in the published binary instead of workflow scripts. +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub(crate) enum CiCommand { + /// Download an exact CI artifact and package it as a local Docker image. + PrepareImage(CiPrepareImageArgs), + /// Remove stale unpublished release state before release-plz runs. + PrepareRelease, + /// Select the release tag produced by or recoverable after release-plz. + SelectRelease, +} + +/// Options for packaging a prebuilt service binary from GitHub Actions. +#[derive(Debug, Clone, PartialEq, Eq, Args)] +pub(crate) struct CiPrepareImageArgs { + /// GitHub Actions artifact prefix; the exact checkout revision is appended. + #[arg(long)] + pub(crate) artifact: String, + + /// Binary filename at the root of the downloaded artifact. + #[arg(long)] + pub(crate) binary: PathBuf, + + /// Local Docker image tag to create. + #[arg(long)] + pub(crate) image: String, + + /// GitHub owner/repository; defaults to GITHUB_REPOSITORY. + #[arg(long)] + pub(crate) repository: Option, + + /// Exact artifact revision; defaults to the current Git checkout. + #[arg(long)] + pub(crate) revision: Option, + + /// Dockerfile containing the prebuilt image target. + #[arg(long, default_value = "docker/Dockerfile")] + pub(crate) dockerfile: PathBuf, + + /// Dockerfile target that copies from the prebuilt build context. + #[arg(long, default_value = "conformance-prebuilt")] + pub(crate) target: String, + + /// Generated artifact download directory. + #[arg(long, default_value = ".integration/ci/prebuilt")] + pub(crate) download_dir: PathBuf, } /// Stack command selection. diff --git a/src/cli_public_tests.rs b/src/cli_public_tests.rs index a02dbd7..8ec14c4 100644 --- a/src/cli_public_tests.rs +++ b/src/cli_public_tests.rs @@ -34,11 +34,31 @@ fn command_at(path: &[&str]) -> clap::Command { fn subcommands(path: &[&str]) -> Vec { command_at(path) .get_subcommands() - .filter(|command| command.get_name() != "help") + .filter(|command| command.get_name() != "help" && !command.is_hide_set()) .map(|command| command.get_name().to_owned()) .collect() } +#[test] +fn hidden_ci_commands_parse_without_expanding_the_public_command_tree() { + let cli = parse(&[ + "cf-integration", + "ci", + "prepare-image", + "--artifact", + "contextforge-data-plane-conformance", + "--binary", + "contextforge-data-plane", + "--image", + "contextforge-data-plane:conformance", + "--repository", + "contextforge-org/contextforge-data-plane", + ]); + + assert!(matches!(cli.command, Command::Ci(_))); + assert!(!subcommands(&[]).contains(&String::from("ci"))); +} + #[test] fn command_tree_contains_only_distinct_public_workflows() { assert_eq!( diff --git a/src/runtime/ci.rs b/src/runtime/ci.rs new file mode 100644 index 0000000..5c0d061 --- /dev/null +++ b/src/runtime/ci.rs @@ -0,0 +1,449 @@ +//! Repository CI orchestration kept out of GitHub Actions shell blocks. + +use std::ffi::OsString; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::Path; +use std::time::Duration; + +use anyhow::{Context, anyhow, bail}; +use serde::Deserialize; +use serde::de::DeserializeOwned; + +use super::{AppFailure, AppResult, RuntimeContext}; +use crate::app::CiAction; +use crate::infrastructure::process::{CommandSpec, ProcessRunner}; + +const ARTIFACT_WAIT_ATTEMPTS: usize = 60; +const ARTIFACT_WAIT_INTERVAL: Duration = Duration::from_secs(10); +const CRATES_IO_USER_AGENT: &str = "contextforge-dev-tools-release"; + +#[derive(Debug, Deserialize)] +struct ArtifactList { + artifacts: Vec, +} + +#[derive(Debug, Deserialize)] +struct WorkflowArtifact { + expired: bool, + workflow_run: Option, +} + +#[derive(Debug, Deserialize)] +struct WorkflowRun { + id: u64, +} + +#[derive(Debug, Deserialize)] +struct GitHubRelease { + id: u64, + tag_name: String, + draft: bool, +} + +#[derive(Debug, Deserialize)] +struct GitReference { + #[serde(rename = "ref")] + name: String, +} + +#[derive(Debug, Deserialize)] +struct ReleasePlzRelease { + package_name: String, + #[serde(default)] + tag: String, +} + +impl RuntimeContext { + pub(super) async fn execute_ci(&self, action: CiAction) -> AppResult<()> { + match action { + action @ CiAction::PrepareImage { .. } => self.prepare_ci_image(&action).await, + CiAction::PrepareRelease => self.prepare_release_state(), + CiAction::SelectRelease => self.select_release_tag(), + } + } + + async fn prepare_ci_image(&self, action: &CiAction) -> AppResult<()> { + let CiAction::PrepareImage { + artifact: artifact_prefix, + binary, + image, + repository, + revision, + dockerfile, + target, + download_dir, + } = action + else { + return Err(AppFailure::from(anyhow!( + "internal CI action was not an image preparation request" + ))); + }; + let revision = match revision { + Some(revision) => revision.clone(), + None => self.capture_text( + &CommandSpec::new("git") + .args(["rev-parse", "HEAD"]) + .cwd(self.config.root()), + )?, + }; + validate_revision(&revision)?; + let artifact_name = format!("{artifact_prefix}-{revision}"); + let run_id = self.wait_for_artifact(repository, &artifact_name).await?; + + if download_dir.exists() { + return Err(AppFailure::from(anyhow!( + "CI artifact directory {} already exists; remove it before retrying", + download_dir.display() + ))); + } + self.runner + .run_async( + &CommandSpec::new("gh") + .args([ + OsString::from("run"), + OsString::from("download"), + OsString::from(run_id.to_string()), + OsString::from("--repo"), + OsString::from(repository), + OsString::from("--name"), + OsString::from(&artifact_name), + OsString::from("--dir"), + download_dir.as_os_str().to_owned(), + ]) + .cwd(self.config.root()), + ) + .await?; + + let downloaded_binary = download_dir.join(binary); + let metadata = fs::metadata(&downloaded_binary).with_context(|| { + format!( + "artifact {artifact_name} did not contain {}", + binary.display() + ) + })?; + if !metadata.is_file() { + return Err(AppFailure::from(anyhow!( + "artifact binary {} is not a regular file", + downloaded_binary.display() + ))); + } + make_executable(&downloaded_binary, metadata.permissions())?; + + let mut prebuilt_context = OsString::from("prebuilt="); + prebuilt_context.push(download_dir); + self.runner + .run_async( + &CommandSpec::new("docker") + .args([ + OsString::from("buildx"), + OsString::from("build"), + OsString::from("--load"), + OsString::from("--target"), + OsString::from(target), + OsString::from("--build-context"), + prebuilt_context, + OsString::from("--tag"), + OsString::from(image), + OsString::from("--file"), + dockerfile.as_os_str().to_owned(), + OsString::from("."), + ]) + .cwd(self.config.root()), + ) + .await?; + Ok(()) + } + + async fn wait_for_artifact(&self, repository: &str, artifact: &str) -> AppResult { + let endpoint = format!("repos/{repository}/actions/artifacts"); + for attempt in 1..=ARTIFACT_WAIT_ATTEMPTS { + let response: ArtifactList = self.capture_ci_json( + &CommandSpec::new("gh").args([ + "api", + "--method", + "GET", + &endpoint, + "-f", + &format!("name={artifact}"), + ]), + "GitHub Actions artifact response", + )?; + if let Some(run_id) = artifact_run_id(&response) { + return Ok(run_id); + } + eprintln!("Waiting for CI artifact {artifact} ({attempt}/{ARTIFACT_WAIT_ATTEMPTS})"); + tokio::time::sleep(ARTIFACT_WAIT_INTERVAL).await; + } + Err(AppFailure::from(anyhow!( + "CI artifact {artifact} was not produced within {} seconds", + ARTIFACT_WAIT_INTERVAL.as_secs() * ARTIFACT_WAIT_ATTEMPTS as u64 + ))) + } + + fn prepare_release_state(&self) -> AppResult<()> { + let repository = self.required_ci_environment("GITHUB_REPOSITORY")?; + let tag = format!("v{}", env!("CARGO_PKG_VERSION")); + if self.crate_is_published(env!("CARGO_PKG_VERSION"))? { + return self.write_github_output("tag", &tag); + } + + let pages: Vec> = self.capture_ci_json( + &CommandSpec::new("gh").args([ + "api", + "--paginate", + "--slurp", + &format!("repos/{repository}/releases"), + ]), + "GitHub release list", + )?; + let matching = pages + .into_iter() + .flatten() + .filter(|release| release.tag_name == tag) + .collect::>(); + if matching.iter().any(|release| !release.draft) { + return Err(AppFailure::from(anyhow!( + "refusing to replace published GitHub release {tag}" + ))); + } + for release in matching { + self.runner.run(&CommandSpec::new("gh").args([ + "api", + "--method", + "DELETE", + &format!("repos/{repository}/releases/{}", release.id), + ]))?; + } + + let references: Vec = self.capture_ci_json( + &CommandSpec::new("gh").args([ + "api", + &format!("repos/{repository}/git/matching-refs/tags/{tag}"), + ]), + "GitHub tag references", + )?; + if references + .iter() + .any(|reference| reference.name == format!("refs/tags/{tag}")) + { + self.runner.run(&CommandSpec::new("gh").args([ + "api", + "--method", + "DELETE", + &format!("repos/{repository}/git/refs/tags/{tag}"), + ]))?; + } + + let local_tag = self.capture_text( + &CommandSpec::new("git") + .args(["tag", "--list", &tag]) + .cwd(self.config.root()), + )?; + if local_tag.lines().any(|candidate| candidate == tag) { + self.runner.run( + &CommandSpec::new("git") + .args(["tag", "--delete", &tag]) + .cwd(self.config.root()), + )?; + } + self.write_github_output("tag", &tag) + } + + fn select_release_tag(&self) -> AppResult<()> { + let releases = self + .environment_text("RELEASES") + .filter(|releases| !releases.is_empty()) + .unwrap_or("[]"); + let releases: Vec = serde_json::from_str(releases) + .context("failed to parse release-plz output") + .map_err(AppFailure::from)?; + if let Some(release) = releases + .into_iter() + .find(|release| release.package_name == "cf-integration" && !release.tag.is_empty()) + { + return self.write_github_output("tag", &release.tag); + } + + let candidate = self.required_ci_environment("CANDIDATE_TAG")?; + let remote = self.capture_text( + &CommandSpec::new("git") + .args([ + "ls-remote", + "--tags", + "origin", + &format!("refs/tags/{candidate}"), + ]) + .cwd(self.config.root()), + )?; + if remote.is_empty() { + return self.write_github_output("tag", ""); + } + self.runner.run( + &CommandSpec::new("git") + .args([ + "fetch", + "--force", + "origin", + &format!("refs/tags/{candidate}:refs/tags/{candidate}"), + ]) + .cwd(self.config.root()), + )?; + let tagged_revision = self.capture_text( + &CommandSpec::new("git") + .args(["rev-list", "-n", "1", candidate]) + .cwd(self.config.root()), + )?; + if tagged_revision != self.required_ci_environment("GITHUB_SHA")? { + return self.write_github_output("tag", ""); + } + let version = candidate + .strip_prefix('v') + .ok_or_else(|| AppFailure::from(anyhow!("candidate release tag must start with v")))?; + let selected = if self.crate_is_published(version)? { + candidate + } else { + "" + }; + self.write_github_output("tag", selected) + } + + fn crate_is_published(&self, version: &str) -> AppResult { + let status = self.capture_text(&CommandSpec::new("curl").args([ + "--silent", + "--show-error", + "--output", + "/dev/null", + "--write-out", + "%{http_code}", + "--user-agent", + CRATES_IO_USER_AGENT, + &format!("https://crates.io/api/v1/crates/cf-integration/{version}"), + ]))?; + match status.as_str() { + "200" => Ok(true), + "404" => Ok(false), + _ => Err(AppFailure::from(anyhow!( + "crates.io returned HTTP {status} while checking cf-integration {version}" + ))), + } + } + + fn capture_ci_json( + &self, + command: &CommandSpec, + description: &str, + ) -> AppResult { + let output = self.capture_text(command)?; + serde_json::from_str(&output) + .with_context(|| format!("failed to parse {description}")) + .map_err(AppFailure::from) + } + + fn required_ci_environment(&self, key: &str) -> AppResult<&str> { + self.environment_text(key) + .filter(|value| !value.is_empty()) + .ok_or_else(|| AppFailure::from(anyhow!("{key} is required for this CI operation"))) + } + + fn write_github_output(&self, key: &str, value: &str) -> AppResult<()> { + let output = self.required_ci_environment("GITHUB_OUTPUT")?; + write_github_output(Path::new(output), key, value).map_err(AppFailure::from) + } +} + +fn artifact_run_id(response: &ArtifactList) -> Option { + response + .artifacts + .iter() + .find(|artifact| !artifact.expired) + .and_then(|artifact| artifact.workflow_run.as_ref()) + .map(|run| run.id) +} + +fn validate_revision(revision: &str) -> AppResult<()> { + if matches!(revision.len(), 40 | 64) && revision.bytes().all(|byte| byte.is_ascii_hexdigit()) { + Ok(()) + } else { + Err(AppFailure::from(anyhow!( + "CI artifact revision must be a 40- or 64-character Git object ID" + ))) + } +} + +fn write_github_output(path: &Path, key: &str, value: &str) -> anyhow::Result<()> { + if key.contains(['\r', '\n']) || value.contains(['\r', '\n']) { + bail!("GitHub Actions output values cannot contain newlines"); + } + let mut output = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .with_context(|| format!("failed to open GitHub Actions output {}", path.display()))?; + writeln!(output, "{key}={value}") + .with_context(|| format!("failed to write GitHub Actions output {}", path.display())) +} + +#[cfg(unix)] +fn make_executable(path: &Path, mut permissions: fs::Permissions) -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + + permissions.set_mode(permissions.mode() | 0o111); + fs::set_permissions(path, permissions) + .with_context(|| format!("failed to make {} executable", path.display())) +} + +#[cfg(not(unix))] +fn make_executable(_path: &Path, _permissions: fs::Permissions) -> anyhow::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn artifact_selection_skips_expired_entries() { + let response = ArtifactList { + artifacts: vec![ + WorkflowArtifact { + expired: true, + workflow_run: Some(WorkflowRun { id: 1 }), + }, + WorkflowArtifact { + expired: false, + workflow_run: Some(WorkflowRun { id: 2 }), + }, + ], + }; + + let run_id = artifact_run_id(&response); + + assert_eq!(run_id, Some(2)); + } + + #[test] + fn github_output_rejects_newline_injection() { + let directory = tempdir().expect("create temporary output directory"); + let output = directory.path().join("github-output"); + + let error = write_github_output(&output, "tag", "v1.0.0\nunsafe=true") + .expect_err("newlines must not be written to GitHub Actions outputs"); + + assert_eq!( + error.to_string(), + "GitHub Actions output values cannot contain newlines" + ); + assert!(!output.exists()); + } + + #[test] + fn revision_validation_accepts_git_sha1_and_sha256_ids_only() { + assert!(validate_revision(&"a".repeat(40)).is_ok()); + assert!(validate_revision(&"b".repeat(64)).is_ok()); + assert!(validate_revision("main").is_err()); + assert!(validate_revision(&"z".repeat(40)).is_err()); + } +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 38ebe71..2d523d8 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -49,8 +49,8 @@ use crate::performance::{LoadSettings, LocustCommand, audit_locust_reports}; use anyhow::{Context, anyhow}; use crate::app::{ - Action, ConformanceAction, DebugAction, ResolvedLoadArgs, StackAction, selected_topologies, - topology_selection, + Action, CiAction, ConformanceAction, DebugAction, ResolvedLoadArgs, StackAction, + selected_topologies, topology_selection, }; use crate::cli::{LiveGroup, ProtocolVersion, TokenKind as CliTokenKind, TopologySelection}; use crate::error::AppFailure; @@ -63,6 +63,7 @@ const STACK_READY_POLL_INTERVAL: Duration = Duration::from_millis(250); const STACK_READY_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); const CONFORMANCE_SERVER_ERA_ENV: &str = "CF_CONFORMANCE_SERVER_ERA"; const DEFAULT_CONFORMANCE_SERVER_ERA: ConformanceServerEra = ConformanceServerEra::Modern; +mod ci; mod conformance; mod control_plane; mod inspect; @@ -116,6 +117,7 @@ struct ProbeWorkflow<'a, R>(&'a RuntimeContext); struct PerformanceWorkflow<'a, R>(&'a RuntimeContext); struct LiveWorkflow<'a, R>(&'a RuntimeContext); struct ConformanceWorkflow<'a, R>(&'a RuntimeContext); +struct CiWorkflow<'a, R>(&'a RuntimeContext); impl RuntimeDispatcher { /// Dispatches one fully resolved operation through its workflow owner. @@ -141,6 +143,7 @@ impl RuntimeDispatcher { .await } Action::Conformance(action) => ConformanceWorkflow(&self.context).execute(action).await, + Action::Ci(action) => CiWorkflow(&self.context).execute(action).await, Action::Debug(DebugAction::Token { kind, server_id }) => { self.context.print_token(kind, server_id).await } @@ -197,6 +200,12 @@ impl<'a, R: ProcessRunner> ConformanceWorkflow<'a, R> { } } +impl<'a, R: ProcessRunner> CiWorkflow<'a, R> { + async fn execute(&self, action: CiAction) -> AppResult<()> { + self.0.execute_ci(action).await + } +} + impl RuntimeContext { async fn print_token(&self, kind: CliTokenKind, server_id: Option) -> AppResult<()> { let token = match kind {