From 7bd9bc76b1495c62286b11e093904bb13d273b36 Mon Sep 17 00:00:00 2001 From: JZKK720 Date: Fri, 22 May 2026 15:44:32 +0800 Subject: [PATCH 01/14] fix: make sandbox project dirs worker-writable --- src/tools/builtin/job.rs | 87 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/src/tools/builtin/job.rs b/src/tools/builtin/job.rs index cb1255d5f22..571f9cab57d 100644 --- a/src/tools/builtin/job.rs +++ b/src/tools/builtin/job.rs @@ -6,10 +6,13 @@ //! - Check job status //! - Cancel running jobs -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + use async_trait::async_trait; use chrono::Utc; use tokio::sync::RwLock; @@ -758,6 +761,36 @@ fn projects_base() -> PathBuf { ironclaw_base_dir().join("projects") } +#[cfg(unix)] +fn make_project_dir_worker_writable(dir: &Path) -> Result<(), ToolError> { + let mut permissions = std::fs::metadata(dir) + .map_err(|e| { + ToolError::ExecutionFailed(format!( + "failed to read project dir metadata {}: {}", + dir.display(), + e + )) + })? + .permissions(); + + // The app container creates the bind mount as root, but the sandbox worker + // runs as uid/gid 1000. Mark the workspace directory sticky + writable so + // the worker can create files without running the whole container as root. + permissions.set_mode(0o1777); + std::fs::set_permissions(dir, permissions).map_err(|e| { + ToolError::ExecutionFailed(format!( + "failed to update project dir permissions {}: {}", + dir.display(), + e + )) + }) +} + +#[cfg(not(unix))] +fn make_project_dir_worker_writable(_dir: &Path) -> Result<(), ToolError> { + Ok(()) +} + /// Resolve the project directory, creating it if it doesn't exist. /// /// Auto-creates `~/.ironclaw/projects/{project_id}/` so every sandbox job has a @@ -820,6 +853,8 @@ fn resolve_project_dir( } }; + make_project_dir_worker_writable(&canonical_dir)?; + let browse_id = canonical_dir .file_name() .map(|n| n.to_string_lossy().to_string()) @@ -2049,6 +2084,12 @@ mod tests { assert!(dir.ends_with(project_id.to_string())); // safety: test assert_eq!(browse_id, project_id.to_string()); // safety: test + #[cfg(unix)] + { + let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o7777; // safety: test + assert_eq!(mode, 0o1777); // safety: test + } + // Must be under the projects base let base = projects_base().canonicalize().unwrap(); // safety: test assert!(dir.starts_with(&base)); // safety: test @@ -2069,6 +2110,12 @@ mod tests { assert!(dir.exists()); // safety: test assert_eq!(browse_id, "test_explicit_project"); // safety: test + #[cfg(unix)] + { + let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o7777; // safety: test + assert_eq!(mode, 0o1777); // safety: test + } + let canonical_base = base.canonicalize().unwrap(); // safety: test assert!(dir.starts_with(&canonical_base)); // safety: test @@ -2587,6 +2634,44 @@ mod tests { ); } + #[cfg(unix)] + #[tokio::test] + async fn test_execute_sandbox_makes_explicit_project_dir_worker_writable() { + let manager = Arc::new(ContextManager::new(5)); + let jm = Arc::new(ContainerJobManager::new( + crate::orchestrator::job_manager::ContainerJobConfig::default(), + crate::orchestrator::TokenStore::new(), + )); + let tool = CreateJobTool::new(manager).with_sandbox(jm, None); + + let explicit = projects_base().join(format!("test_execute_sandbox_{}", Uuid::new_v4())); + std::fs::create_dir_all(&explicit).unwrap(); // safety: test + std::fs::set_permissions(&explicit, std::fs::Permissions::from_mode(0o0755)).unwrap(); // safety: test + + let result = tool + .execute_sandbox( + "test task", + Some(explicit.clone()), + false, + JobMode::ClaudeCode, + JobCreationParams::default(), + &JobContext::default(), + ) + .await; + + assert!(result.is_err()); // safety: test + let err = result.unwrap_err().to_string(); // safety: test + assert!( + err.contains("claude_code mode is not enabled"), + "expected mode-disabled error, got: {err}" + ); + + let mode = std::fs::metadata(&explicit).unwrap().permissions().mode() & 0o7777; // safety: test + assert_eq!(mode, 0o1777); // safety: test + + let _ = std::fs::remove_dir_all(&explicit); + } + #[tokio::test] async fn test_execute_rejects_acp_when_disabled() { let tool = sandbox_tool(false, false); From 27d75df1b0bea11e0d2a0d356a848d29ccda94c8 Mon Sep 17 00:00:00 2001 From: JZKK720 Date: Fri, 22 May 2026 15:53:03 +0800 Subject: [PATCH 02/14] ci: publish fork images to ghcr --- .env.example | 4 +- .github/workflows/docker.yml | 17 +-- .github/workflows/rebuild-release-image.yml | 12 +- .github/workflows/release-plz.yml | 59 ++++++---- .github/workflows/sync-upstream.yml | 118 ++++++++++++++++++++ release-plz.toml | 15 +++ 6 files changed, 189 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/sync-upstream.yml diff --git a/.env.example b/.env.example index d2afe9768fd..c4eb43eddeb 100644 --- a/.env.example +++ b/.env.example @@ -209,7 +209,9 @@ HEARTBEAT_NOTIFY_USER=default # # commands directly on the host. Without this # # set to "true", full_access is downgraded to # # workspace_write. -# SANDBOX_IMAGE=ironclaw-worker:latest +# IRONCLAW_APP_IMAGE=ghcr.io/jzkk720/ironclaw:latest +# SANDBOX_IMAGE=ghcr.io/jzkk720/ironclaw-worker:latest +# Keep the app and worker image on the same channel/tag or digest. # SANDBOX_TIMEOUT_SECS=120 # SANDBOX_MEMORY_LIMIT_MB=2048 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1f750a9e5f7..da71c1a98a6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,4 +1,4 @@ -name: Docker Image +name: Build and Publish Docker Images on: # Called by release.yml or other workflows @@ -32,8 +32,10 @@ on: - cron: '0 * * * *' env: - IMAGE_NAME: nearaidev/ironclaw - WORKER_IMAGE_NAME: nearaidev/ironclaw-worker + REGISTRY: ghcr.io + IMAGE_OWNER: jzkk720 + IMAGE_NAME: ghcr.io/jzkk720/ironclaw + WORKER_IMAGE_NAME: ghcr.io/jzkk720/ironclaw-worker jobs: build: @@ -41,7 +43,7 @@ jobs: runs-on: ubuntu-24.04 permissions: contents: read - packages: read + packages: write actions: write steps: - name: Checkout @@ -120,11 +122,12 @@ jobs: echo "target=runtime" >> "$GITHUB_OUTPUT" fi - - name: Log in to Docker Hub + - name: Log in to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: - username: ${{ vars.DOCKER_REGISTRY_USER }} - password: ${{ secrets.DOCKER_REGISTRY_TOKEN }} + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Check if current git commit is already built id: check diff --git a/.github/workflows/rebuild-release-image.yml b/.github/workflows/rebuild-release-image.yml index 8d362f33621..ecebb46a30a 100644 --- a/.github/workflows/rebuild-release-image.yml +++ b/.github/workflows/rebuild-release-image.yml @@ -13,7 +13,8 @@ on: type: string env: - IMAGE_NAME: nearaidev/ironclaw + REGISTRY: ghcr.io + IMAGE_NAME: ghcr.io/jzkk720/ironclaw jobs: build: @@ -22,7 +23,7 @@ jobs: permissions: actions: write contents: read - packages: read + packages: write steps: - name: Checkout requested source uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -73,11 +74,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - - name: Log in to Docker Hub + - name: Log in to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: - username: ${{ vars.DOCKER_REGISTRY_USER }} - password: ${{ secrets.DOCKER_REGISTRY_TOKEN }} + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Detect runtime stage id: target diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index cfff0e59720..4460402ab27 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -9,11 +9,11 @@ jobs: # Release unpublished packages. release-plz-release: - if: ${{ github.repository_owner == 'nearai' }} name: Release-plz release runs-on: ubuntu-latest permissions: contents: write + actions: write steps: - &checkout name: Checkout repository @@ -21,31 +21,51 @@ jobs: with: fetch-depth: 0 persist-credentials: false + - name: Record release tags before run + shell: bash + run: | + git fetch origin --tags + git tag -l 'ironclaw-v*' | sort > "$RUNNER_TEMP/release-tags-before.txt" - &install-rust name: Install Rust toolchain uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - # Generating a GitHub token, so that PRs and tags created by - # the release-plz-action can trigger actions workflows. - - name: Generate GitHub token - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 - id: generate-token - with: - # GitHub App ID secret name - app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }} - # GitHub App private key secret name - private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }} - name: Run release-plz uses: release-plz/action@1528104d2ca23787631a1c1f022abb64b34c1e11 # v0.5 with: command: release env: - GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Detect newly created release tags + id: new-tags + shell: bash + run: | + git fetch origin --tags + git tag -l 'ironclaw-v*' | sort > "$RUNNER_TEMP/release-tags-after.txt" + comm -13 "$RUNNER_TEMP/release-tags-before.txt" "$RUNNER_TEMP/release-tags-after.txt" > "$RUNNER_TEMP/release-tags-new.txt" + + if [ -s "$RUNNER_TEMP/release-tags-new.txt" ]; then + echo "created=true" >> "$GITHUB_OUTPUT" + echo "tags<> "$GITHUB_OUTPUT" + cat "$RUNNER_TEMP/release-tags-new.txt" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + else + echo "created=false" >> "$GITHUB_OUTPUT" + fi + - name: Dispatch release workflow for new tags + if: steps.new-tags.outputs.created == 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CREATED_TAGS: ${{ steps.new-tags.outputs.tags }} + run: | + while IFS= read -r tag; do + [ -n "$tag" ] || continue + gh workflow run release.yml --ref main -f release_tag="$tag" + done <<< "$CREATED_TAGS" # Create a PR with the new versions and changelog, preparing the next release. release-plz-pr: - if: ${{ github.repository_owner == 'nearai' }} name: Release-plz PR runs-on: ubuntu-latest permissions: @@ -58,16 +78,9 @@ jobs: - *checkout - *install-rust - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - name: Generate GitHub token - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 - id: generate-token - with: - app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }} - private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }} - - name: Run release-plz + - name: Run release-plz PR uses: release-plz/action@1528104d2ca23787631a1c1f022abb64b34c1e11 # v0.5 with: command: release-pr env: - GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 00000000000..03c818ca8f1 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,118 @@ +name: Sync Upstream Main and Rebuild + +on: + schedule: + - cron: '0 2 * * 1' + workflow_dispatch: + +concurrency: + group: sync-upstream + cancel-in-progress: false + +permissions: + contents: write + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_OWNER: jzkk720 + +jobs: + sync-and-build: + name: Sync upstream/main -> main -> GHCR + runs-on: ubuntu-latest + + steps: + - name: Checkout fork/main (full history) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Add upstream remote + run: git remote add upstream https://github.com/nearai/ironclaw.git + + - name: Fetch upstream/main + run: git fetch upstream main + + - name: Count commits behind upstream/main + id: diff + run: | + BEHIND=$(git rev-list HEAD..upstream/main --count) + echo "behind=$BEHIND" >> "$GITHUB_OUTPUT" + echo "This fork is $BEHIND commit(s) behind upstream/main." + + - name: Nothing to sync — exit early + if: steps.diff.outputs.behind == '0' + run: echo "Already up to date with upstream/main. Skipping build." + + - name: Attempt auto-merge + if: steps.diff.outputs.behind != '0' + id: merge + run: | + DATE=$(date -u +%Y-%m-%d) + git merge upstream/main --no-edit \ + -m "chore: sync upstream/main ${DATE} (${{ steps.diff.outputs.behind }} new commits)" \ + || { + git merge --abort + echo "::error::Auto-merge failed — upstream/main has conflicts with fork/main." + echo "::error::To resolve: git fetch upstream && git merge upstream/main, fix conflicts, push, then re-run this workflow." + exit 1 + } + + - name: Push merged main + if: steps.diff.outputs.behind != '0' + run: git push origin main + + - name: Set up Docker Buildx + if: steps.diff.outputs.behind != '0' + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: steps.diff.outputs.behind != '0' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push ironclaw + if: steps.diff.outputs.behind != '0' + uses: docker/build-push-action@v6 + with: + context: . + target: runtime-staging + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ironclaw:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ironclaw:${{ github.sha }} + cache-from: type=gha,scope=ironclaw-main + cache-to: type=gha,mode=max,scope=ironclaw-main + + - name: Build and push ironclaw-worker + if: steps.diff.outputs.behind != '0' + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.worker + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ironclaw-worker:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/ironclaw-worker:${{ github.sha }} + cache-from: type=gha,scope=ironclaw-worker + cache-to: type=gha,mode=max,scope=ironclaw-worker + + - name: Summary + if: steps.diff.outputs.behind != '0' + run: | + echo "### Sync complete" >> "$GITHUB_STEP_SUMMARY" + echo "Merged ${{ steps.diff.outputs.behind }} upstream commit(s)." >> "$GITHUB_STEP_SUMMARY" + echo "Images pushed to GHCR:" >> "$GITHUB_STEP_SUMMARY" + echo "- \`ghcr.io/${{ env.IMAGE_OWNER }}/ironclaw:latest\`" >> "$GITHUB_STEP_SUMMARY" + echo "- \`ghcr.io/${{ env.IMAGE_OWNER }}/ironclaw-worker:latest\`" >> "$GITHUB_STEP_SUMMARY" + echo "Watchtower will pick up the new image within the next poll interval." >> "$GITHUB_STEP_SUMMARY" \ No newline at end of file diff --git a/release-plz.toml b/release-plz.toml index b140099382f..ff4bfb94132 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -26,3 +26,18 @@ release = false name = "ironclaw_gateway" publish = false release = false + +[[package]] +name = "ironclaw_common" +publish = false +release = false + +[[package]] +name = "ironclaw_safety" +publish = false +release = false + +[[package]] +name = "ironclaw_skills" +publish = false +release = false From 230f84ab065b50a9dd90ed26cb0f157263c71293 Mon Sep 17 00:00:00 2001 From: JZKK720 Date: Fri, 22 May 2026 15:55:30 +0800 Subject: [PATCH 03/14] ci: publish latest images on main push --- .github/workflows/docker.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index da71c1a98a6..5fab7fe0d67 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,6 +1,9 @@ name: Build and Publish Docker Images on: + push: + branches: + - main # Called by release.yml or other workflows workflow_call: inputs: @@ -101,6 +104,12 @@ jobs: TAGS="${TAGS},${IMAGE_NAME}:${SHA}" WORKER_TAGS="${WORKER_IMAGE_NAME}:staging" WORKER_TAGS="${WORKER_TAGS},${WORKER_IMAGE_NAME}:${SHA}" + elif [[ "${EVENT_NAME}" == "push" ]]; then + # Push to main: :latest + :sha-xxx + TAGS="${IMAGE_NAME}:latest" + TAGS="${TAGS},${IMAGE_NAME}:${SHA}" + WORKER_TAGS="${WORKER_IMAGE_NAME}:latest" + WORKER_TAGS="${WORKER_TAGS},${WORKER_IMAGE_NAME}:${SHA}" else # Manual dispatch: :sha-xxx only TAGS="${IMAGE_NAME}:${SHA}" @@ -116,7 +125,7 @@ jobs: echo "worker_tags=${WORKER_TAGS}" >> "$GITHUB_OUTPUT" # Staging builds get pre-bundled WASM extensions - if [[ "${EVENT_NAME}" == "schedule" || "${INPUT_TAG}" == "staging" ]]; then + if [[ "${EVENT_NAME}" == "schedule" || "${EVENT_NAME}" == "push" || "${INPUT_TAG}" == "staging" ]]; then echo "target=runtime-staging" >> "$GITHUB_OUTPUT" else echo "target=runtime" >> "$GITHUB_OUTPUT" @@ -131,7 +140,7 @@ jobs: - name: Check if current git commit is already built id: check - if: steps.tags.outputs.target == 'runtime-staging' + if: github.event_name == 'schedule' || inputs.tag == 'staging' env: SOURCE_SHA: ${{ steps.source_sha.outputs.sha }} run: | @@ -191,7 +200,7 @@ jobs: - name: Create releases-manager app token id: app-token - if: steps.check.outputs.skip != 'true' + if: github.repository_owner == 'nearai' && steps.check.outputs.skip != 'true' continue-on-error: true uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 with: @@ -201,7 +210,7 @@ jobs: repositories: ironclaw-dind - name: Trigger ironclaw-dind Build & Push - if: steps.app-token.outcome == 'success' && steps.check.outputs.skip != 'true' + if: github.repository_owner == 'nearai' && steps.app-token.outcome == 'success' && steps.check.outputs.skip != 'true' continue-on-error: true env: GH_TOKEN: ${{ steps.app-token.outputs.token }} From 6326d79ad7fd78789ddaa53287533e9f32593683 Mon Sep 17 00:00:00 2001 From: JZKK720 Date: Fri, 22 May 2026 16:06:06 +0800 Subject: [PATCH 04/14] ci: retrigger docker publish after workflow re-enable From fad6db6e0760e4620b9f94e9368a14f815091472 Mon Sep 17 00:00:00 2001 From: JZKK720 Date: Fri, 22 May 2026 17:32:17 +0800 Subject: [PATCH 05/14] Fix sandbox restart project dir permissions --- src/channels/web/features/jobs/mod.rs | 128 ++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/src/channels/web/features/jobs/mod.rs b/src/channels/web/features/jobs/mod.rs index d6feb3486f5..931fe9792eb 100644 --- a/src/channels/web/features/jobs/mod.rs +++ b/src/channels/web/features/jobs/mod.rs @@ -25,6 +25,38 @@ fn db_error(context: &str, e: impl std::fmt::Display) -> (StatusCode, String) { ) } +#[cfg(unix)] +fn make_project_dir_worker_writable(dir: &std::path::Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = std::fs::metadata(dir) + .map_err(|e| { + format!( + "failed to read project dir metadata {}: {}", + dir.display(), + e + ) + })? + .permissions(); + + // Restarts reuse an existing bind mount created by the app container as + // root, but the worker still runs as uid/gid 1000. Keep the sticky + + // writable directory contract consistent with initial sandbox creation. + permissions.set_mode(0o1777); + std::fs::set_permissions(dir, permissions).map_err(|e| { + format!( + "failed to update project dir permissions {}: {}", + dir.display(), + e + ) + }) +} + +#[cfg(not(unix))] +fn make_project_dir_worker_writable(_dir: &std::path::Path) -> Result<(), String> { + Ok(()) +} + async fn resolve_sandbox_restart_mode( store: &dyn crate::db::Database, stored_mode: &str, @@ -542,6 +574,22 @@ pub async fn jobs_restart_handler( .await; let project_dir = std::path::PathBuf::from(&old_job.project_dir); + if let Err(error_text) = make_project_dir_worker_writable(&project_dir) { + let _ = store + .update_sandbox_job_status( + new_job_id, + "failed", + Some(false), + Some(error_text.as_str()), + None, + Some(chrono::Utc::now()), + ) + .await; + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to prepare project dir: {}", error_text), + )); + } let create_result = jm .create_job( new_job_id, @@ -942,6 +990,8 @@ pub async fn job_files_read_handler( mod tests { use super::*; + use std::sync::Arc; + use crate::orchestrator::TokenStore; use crate::orchestrator::job_manager::ContainerJobConfig; @@ -1049,4 +1099,82 @@ mod tests { let result = check_mode_enabled(JobMode::Worker, &jm); assert!(result.is_ok(), "worker mode should always be allowed"); } + + #[cfg(feature = "libsql")] + #[tokio::test] + async fn jobs_restart_handler_makes_reused_project_dir_worker_writable() { + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + let (db, _tmp) = crate::testing::test_db().await; + let temp = tempfile::tempdir().unwrap(); + let project_dir = temp.path().join("reused-project-dir"); + std::fs::create_dir(&project_dir).unwrap(); + + #[cfg(unix)] + std::fs::set_permissions(&project_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let old_job = crate::history::SandboxJobRecord { + id: Uuid::new_v4(), + task: "restart me".to_string(), + status: "failed".to_string(), + user_id: "alice".to_string(), + project_dir: project_dir.display().to_string(), + success: Some(false), + failure_reason: Some("permission denied".to_string()), + created_at: chrono::Utc::now(), + started_at: Some(chrono::Utc::now()), + completed_at: Some(chrono::Utc::now()), + credential_grants_json: "[]".to_string(), + mcp_servers: None, + max_iterations: None, + }; + db.save_sandbox_job(&old_job).await.unwrap(); + + let mut state = crate::channels::web::test_helpers::test_gateway_state_with_dependencies( + None, + Some(db.clone()), + None, + None, + ); + Arc::get_mut(&mut state).unwrap().job_manager = + Some(Arc::new(make_job_manager(false, false))); + + let result = jobs_restart_handler( + State(state), + AuthenticatedUser(crate::channels::web::auth::UserIdentity { + user_id: "alice".to_string(), + role: "admin".to_string(), + workspace_read_scopes: Vec::new(), + }), + Path(old_job.id.to_string()), + ) + .await; + + assert!( + result.is_err(), + "tempdir outside projects base should fail before Docker" + ); + + #[cfg(unix)] + { + let mode = std::fs::metadata(&project_dir) + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!( + mode, 0o1777, + "restart handler should chmod reused project dirs" + ); + } + + #[cfg(not(unix))] + { + assert!( + project_dir.exists(), + "restart handler should still reuse the explicit project dir path" + ); + } + } } From 06036264e294ed2cabae7d2286ea560446a832d8 Mon Sep 17 00:00:00 2001 From: JZKK720 Date: Fri, 22 May 2026 23:20:29 +0800 Subject: [PATCH 06/14] Fix sandbox worker bind source in Docker --- src/orchestrator/job_manager.rs | 142 +++++++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 3 deletions(-) diff --git a/src/orchestrator/job_manager.rs b/src/orchestrator/job_manager.rs index d3c35d84b4d..2c7befc0b13 100644 --- a/src/orchestrator/job_manager.rs +++ b/src/orchestrator/job_manager.rs @@ -4,7 +4,7 @@ //! containers with their own agent loops (as opposed to ephemeral per-command containers). use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use chrono::{DateTime, Utc}; @@ -18,6 +18,12 @@ use crate::sandbox::connect_docker; use ironclaw_common::MAX_WORKER_ITERATIONS; +const DOCKER_HOST_BASE_DIR_ENV: &str = "IRONCLAW_DOCKER_HOST_BASE_DIR"; + +fn looks_like_absolute_docker_host_path(path: &Path) -> bool { + path.is_absolute() || path.to_string_lossy().starts_with('/') +} + /// Which mode a sandbox container runs in. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum JobMode { @@ -252,6 +258,72 @@ fn validate_bind_mount_path( Ok(canonical) } +fn translate_bind_mount_source( + canonical_container_path: &Path, + canonical_container_base: &Path, + docker_host_base_dir: Option<&Path>, + job_id: Uuid, +) -> Result { + let Some(host_base) = docker_host_base_dir else { + return Ok(canonical_container_path.to_path_buf()); + }; + + if !looks_like_absolute_docker_host_path(host_base) { + return Err(OrchestratorError::ContainerCreationFailed { + job_id, + reason: format!( + "{} must be an absolute path, got {}", + DOCKER_HOST_BASE_DIR_ENV, + host_base.display() + ), + }); + } + + let relative = canonical_container_path.strip_prefix(canonical_container_base).map_err(|_| { + OrchestratorError::ContainerCreationFailed { + job_id, + reason: format!( + "project directory {} is outside canonical base {}", + canonical_container_path.display(), + canonical_container_base.display() + ), + } + })?; + + Ok(host_base.join(relative)) +} + +fn resolve_bind_mount_source(dir: &Path, job_id: Uuid) -> Result { + let canonical = validate_bind_mount_path(dir, job_id)?; + let canonical_base = ironclaw_base_dir().canonicalize().map_err(|e| { + OrchestratorError::ContainerCreationFailed { + job_id, + reason: format!("failed to canonicalize ironclaw base dir: {e}"), + } + })?; + let docker_host_base_dir = std::env::var_os(DOCKER_HOST_BASE_DIR_ENV) + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + + let bind_source = translate_bind_mount_source( + &canonical, + &canonical_base, + docker_host_base_dir.as_deref(), + job_id, + )?; + + if docker_host_base_dir.is_some() { + tracing::debug!( + job_id = %job_id, + container_path = %canonical.display(), + bind_source = %bind_source.display(), + "Translated sandbox bind source from container path to Docker host path" + ); + } + + Ok(bind_source) +} + /// Manages the lifecycle of Docker containers for sandboxed job execution. pub struct ContainerJobManager { config: ContainerJobConfig, @@ -439,8 +511,8 @@ impl ContainerJobManager { // Build volume mounts (validate project_dir stays within ~/.ironclaw/projects/) let mut binds = Vec::new(); if let Some(ref dir) = project_dir { - let canonical = validate_bind_mount_path(dir, job_id)?; - binds.push(format!("{}:/workspace:rw", canonical.display())); + let bind_source = resolve_bind_mount_source(dir, job_id)?; + binds.push(format!("{}:/workspace:rw", bind_source.display())); env_vec.push("IRONCLAW_WORKSPACE=/workspace".to_string()); } @@ -1022,6 +1094,61 @@ mod tests { ); } + #[test] + fn test_translate_bind_mount_source_passthrough_without_host_override() { + let tmp = tempfile::tempdir().unwrap(); + let canonical_base = tmp.path().join("ironclaw-home"); + let canonical_path = canonical_base.join("projects").join("job-123"); + let result = translate_bind_mount_source( + &canonical_path, + &canonical_base, + None, + Uuid::new_v4(), + ) + .unwrap(); + + assert_eq!(result, canonical_path); + } + + #[test] + fn test_translate_bind_mount_source_uses_docker_host_base_dir() { + let tmp = tempfile::tempdir().unwrap(); + let canonical_base = tmp.path().join("container-home"); + let canonical_path = canonical_base.join("projects").join("job-123"); + let host_base = PathBuf::from("/run/desktop/mnt/host/d/ironclaw-home"); + + let result = translate_bind_mount_source( + &canonical_path, + &canonical_base, + Some(host_base.as_path()), + Uuid::new_v4(), + ) + .unwrap(); + + assert_eq!(result, host_base.join("projects").join("job-123")); + } + + #[test] + fn test_translate_bind_mount_source_rejects_relative_host_base_dir() { + let tmp = tempfile::tempdir().unwrap(); + let canonical_base = tmp.path().join("container-home"); + let canonical_path = canonical_base.join("projects").join("job-123"); + + let err = translate_bind_mount_source( + &canonical_path, + &canonical_base, + Some(Path::new("relative/ironclaw-home")), + Uuid::new_v4(), + ) + .unwrap_err() + .to_string(); + + assert!( + err.contains("IRONCLAW_DOCKER_HOST_BASE_DIR must be an absolute path"), + "expected absolute-path validation error, got: {err}" + ); + } + #[tokio::test] async fn test_update_worker_status() { let store = TokenStore::new(); @@ -1301,6 +1428,15 @@ mod tests { ); } + #[test] + fn test_create_job_inner_resolves_workspace_bind_source() { + let source = include_str!("job_manager.rs"); + assert!( + source.contains("let bind_source = resolve_bind_mount_source(dir, job_id)?;"), + "create_job_inner must resolve the Docker bind source before mounting /workspace" + ); + } + #[test] fn test_server_side_max_iterations_clamp() { // Verify the server-side clamp uses the same constant as worker/job.rs From 529ddcceb8915a405b72c564eab3e2162424c523 Mon Sep 17 00:00:00 2001 From: JZKK720 Date: Fri, 22 May 2026 23:54:52 +0800 Subject: [PATCH 07/14] Accept Windows Docker host bind paths --- src/orchestrator/job_manager.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/orchestrator/job_manager.rs b/src/orchestrator/job_manager.rs index 2c7befc0b13..642e6fde7ce 100644 --- a/src/orchestrator/job_manager.rs +++ b/src/orchestrator/job_manager.rs @@ -21,7 +21,16 @@ use ironclaw_common::MAX_WORKER_ITERATIONS; const DOCKER_HOST_BASE_DIR_ENV: &str = "IRONCLAW_DOCKER_HOST_BASE_DIR"; fn looks_like_absolute_docker_host_path(path: &Path) -> bool { - path.is_absolute() || path.to_string_lossy().starts_with('/') + let path_str = path.to_string_lossy(); + let bytes = path_str.as_bytes(); + + path.is_absolute() + || path_str.starts_with('/') + || path_str.starts_with(r"\\") + || matches!( + bytes, + [drive, b':', sep, ..] if drive.is_ascii_alphabetic() && matches!(sep, b'\\' | b'/') + ) } /// Which mode a sandbox container runs in. @@ -1128,6 +1137,24 @@ mod tests { assert_eq!(result, host_base.join("projects").join("job-123")); } + #[test] + fn test_translate_bind_mount_source_accepts_windows_host_base_dir() { + let tmp = tempfile::tempdir().unwrap(); + let canonical_base = tmp.path().join("container-home"); + let canonical_path = canonical_base.join("projects").join("job-123"); + let host_base = PathBuf::from(r"D:\ironclaw-home"); + + let result = translate_bind_mount_source( + &canonical_path, + &canonical_base, + Some(host_base.as_path()), + Uuid::new_v4(), + ) + .unwrap(); + + assert_eq!(result, host_base.join("projects").join("job-123")); + } + #[test] fn test_translate_bind_mount_source_rejects_relative_host_base_dir() { let tmp = tempfile::tempdir().unwrap(); From f0c17bdef5de43b48f8bb121fd06dbfd21273ab4 Mon Sep 17 00:00:00 2001 From: JZKK720 Date: Sat, 23 May 2026 12:57:10 +0800 Subject: [PATCH 08/14] Persist sandbox job completion status --- src/orchestrator/api.rs | 30 ++++++++++ tests/orchestrator_api_integration.rs | 82 +++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 tests/orchestrator_api_integration.rs diff --git a/src/orchestrator/api.rs b/src/orchestrator/api.rs index 646c9cfeb90..934a84e6c77 100644 --- a/src/orchestrator/api.rs +++ b/src/orchestrator/api.rs @@ -298,6 +298,36 @@ async fn report_complete( ); } + if let Some(ref store) = state.store { + let status = if report.success { + "completed" + } else { + "failed" + }; + let message = if report.success { + None + } else { + report.message.as_deref() + }; + if let Err(e) = store + .update_sandbox_job_status( + job_id, + status, + Some(report.success), + message, + None, + Some(chrono::Utc::now()), + ) + .await + { + tracing::warn!( + job_id = %job_id, + error = %e, + "Failed to persist sandbox job completion status" + ); + } + } + // Store the result and clean up the container let result = crate::orchestrator::job_manager::CompletionResult { success: report.success, diff --git a/tests/orchestrator_api_integration.rs b/tests/orchestrator_api_integration.rs new file mode 100644 index 00000000000..12d7f1569b4 --- /dev/null +++ b/tests/orchestrator_api_integration.rs @@ -0,0 +1,82 @@ +#![cfg(feature = "libsql")] + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use ironclaw::history::SandboxJobRecord; +use ironclaw::orchestrator::api::OrchestratorState; +use ironclaw::orchestrator::{ + ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore, +}; +use ironclaw::testing::StubLlm; +use tokio::sync::Mutex; +use tower::ServiceExt; +use uuid::Uuid; + +#[tokio::test] +async fn report_complete_persists_sandbox_job_status() { + let (db, _tmp) = ironclaw::testing::test_db().await; + let token_store = TokenStore::new(); + let jm = ContainerJobManager::new(ContainerJobConfig::default(), token_store.clone()); + let job_id = Uuid::new_v4(); + let token = token_store.create_token(job_id).await; + let now = chrono::Utc::now(); + + db.save_sandbox_job(&SandboxJobRecord { + id: job_id, + task: "Sandbox health check".to_string(), + status: "running".to_string(), + user_id: "default".to_string(), + project_dir: "/workspace/test".to_string(), + success: None, + failure_reason: None, + created_at: now, + started_at: Some(now), + completed_at: None, + credential_grants_json: "[]".to_string(), + mcp_servers: None, + max_iterations: None, + }) + .await + .unwrap(); + + let state = OrchestratorState { + llm: Arc::new(StubLlm::default()), + job_manager: Arc::new(jm), + token_store, + job_event_tx: None, + prompt_queue: Arc::new(Mutex::new(HashMap::new())), + store: Some(db.clone()), + secrets_store: None, + job_owner_cache: Arc::new(std::sync::RwLock::new(HashMap::new())), + }; + + let router = OrchestratorApi::router(state); + let payload = serde_json::json!({ + "success": true, + "message": "Job completed successfully" + }); + + let req = Request::builder() + .method("POST") + .uri(format!("/worker/{}/complete", job_id)) + .header("Authorization", format!("Bearer {}", token)) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&payload).unwrap())) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let updated = db + .get_sandbox_job(job_id) + .await + .unwrap() + .expect("sandbox job should still exist"); + assert_eq!(updated.status, "completed"); + assert_eq!(updated.success, Some(true)); + assert!(updated.completed_at.is_some()); + assert_eq!(updated.failure_reason, None); +} From e30c80a9951d12749a29de4b1a3bc817e4b447e9 Mon Sep 17 00:00:00 2001 From: JZKK720 Date: Sat, 23 May 2026 14:09:02 +0800 Subject: [PATCH 09/14] release: prepare 0.28.2-f1 --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd87cb8ac84..b9399dea4b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.28.2-f1](https://github.com/jzkk720/ironclaw/compare/ironclaw-v0.28.2...ironclaw-v0.28.2-f1) - 2026-05-23 + +### Added + +- *(llm)* extract embeddings into `ironclaw_embeddings` crate ([#3739](https://github.com/nearai/ironclaw/pull/3739)) +- *(web)* support externally-provided tools in Responses API ([#3122](https://github.com/nearai/ironclaw/pull/3122)) +- *(gateway)* add logs download button ([#3588](https://github.com/nearai/ironclaw/pull/3588)) +- *(tui)* Ctrl-S downloads logs from the Logs tab ([#3658](https://github.com/nearai/ironclaw/pull/3658)) + +### Fixed + +- *(sandbox)* accept Windows Docker host bind paths +- *(sandbox)* fix sandbox worker bind source in Docker +- *(sandbox)* fix sandbox restart project dir permissions +- *(sandbox)* persist sandbox job completion status + +### CI / Release + +- *(docker)* publish fork images to GHCR +- *(docker)* publish `latest` images on `main` push +- *(docker)* retrigger docker publish after workflow re-enable + +### Docs / Maintenance + +- *(docs)* document the Responses API end to end ([#3709](https://github.com/nearai/ironclaw/pull/3709)) +- *(deps)* bump dependencies to address security advisories ([#3719](https://github.com/nearai/ironclaw/pull/3719)) + ## [0.28.2](https://github.com/nearai/ironclaw/compare/ironclaw-v0.28.1...ironclaw-v0.28.2) - 2026-05-14 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index f61e15f734e..c5d2c4c1618 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3846,7 +3846,7 @@ dependencies = [ [[package]] name = "ironclaw" -version = "0.28.2" +version = "0.28.2-f1" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 75451ff3227..49a90152ae8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ exclude = [ [package] name = "ironclaw" -version = "0.28.2" +version = "0.28.2-f1" edition = "2024" rust-version = "1.92" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" From 7add978ee718e979127e1b02bee9920a2e3f9988 Mon Sep 17 00:00:00 2001 From: JZKK720 Date: Sat, 23 May 2026 16:39:49 +0800 Subject: [PATCH 10/14] ci(release): support dispatched fork releases --- .github/workflows/release.yml | 50 ++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ae530abd85..048623279e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,12 @@ on: push: tags: - 'ironclaw-v[0-9]+.[0-9]+.[0-9]+*' + workflow_dispatch: + inputs: + release_tag: + description: Release tag to publish + required: true + type: string jobs: # Run 'dist plan' (or host) to determine what tasks we need to do @@ -49,14 +55,15 @@ jobs: runs-on: "ubuntu-22.04" outputs: val: ${{ steps.plan.outputs.manifest }} - tag: ${{ !github.event.pull_request && github.ref_name || '' }} - tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} - publishing: ${{ !github.event.pull_request }} + tag: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} + tag-flag: ${{ format('--tag={0}', github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name) }} + publishing: 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} persist-credentials: false submodules: recursive - name: Install dist @@ -69,21 +76,11 @@ jobs: with: name: cargo-dist-cache path: ~/.cargo/bin/dist - # sure would be cool if github gave us proper conditionals... - # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible - # functionality based on whether this is a pull_request, and whether it's from a fork. - # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* - # but also really annoying to build CI around when it needs secrets to work right.) - id: plan env: - IS_PUSH: ${{ !github.event.pull_request }} - REF_NAME: ${{ github.ref_name }} + REF_NAME: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} run: | - if [ "$IS_PUSH" = "true" ]; then - dist host --steps=create --tag="$REF_NAME" --output-format=json > plan-dist-manifest.json - else - dist plan --output-format=json > plan-dist-manifest.json - fi + dist host --steps=create --tag="$REF_NAME" --output-format=json > plan-dist-manifest.json echo "dist ran successfully" cat plan-dist-manifest.json echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" @@ -126,6 +123,7 @@ jobs: git config --global core.longpaths true - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: + ref: ${{ needs.plan.outputs.tag }} persist-credentials: false submodules: recursive - name: Install Rust non-interactively if not already installed @@ -152,7 +150,7 @@ jobs: if: ${{ needs.plan.outputs.publishing == 'true' }} shell: bash env: - RELEASE_TAG: ${{ github.ref_name }} + RELEASE_TAG: ${{ needs.plan.outputs.tag }} run: | CHECKSUMS="target/distrib/checksums.txt" if [ ! -f "$CHECKSUMS" ]; then @@ -173,7 +171,7 @@ jobs: continue fi name=$(echo "$filename" | sed "s/^${kind}-//" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//') - url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}" + url="https://github.com/JZKK720/ironclaw/releases/download/${RELEASE_TAG}/${filename}" manifest="registry/${kind}s/${name}.json" if [ -f "$manifest" ]; then @@ -228,6 +226,7 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: + ref: ${{ needs.plan.outputs.tag }} persist-credentials: false submodules: recursive - name: Install cached dist @@ -276,6 +275,7 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: + ref: ${{ needs.plan.outputs.tag }} persist-credentials: false submodules: recursive - name: Install Rust toolchain + wasm target @@ -414,6 +414,7 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: + ref: ${{ needs.plan.outputs.tag }} persist-credentials: false submodules: recursive - name: Install cached dist @@ -467,8 +468,14 @@ jobs: # Write and read notes from a file to avoid quoting breaking things echo "$ANNOUNCEMENT_BODY" > "$RUNNER_TEMP/notes.txt" - # shellcheck disable=SC2086 # PRERELEASE_FLAG is '--prerelease' or empty - gh release create "$RELEASE_TAG" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + # shellcheck disable=SC2086 # PRERELEASE_FLAG is '--prerelease' or empty + gh release edit "$RELEASE_TAG" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" + gh release upload "$RELEASE_TAG" --clobber artifacts/* + else + # shellcheck disable=SC2086 # PRERELEASE_FLAG is '--prerelease' or empty + gh release create "$RELEASE_TAG" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + fi # Build and push Docker Hub images (:version, :latest, :sha-*) after the GitHub Release exists. docker-image: @@ -510,7 +517,7 @@ jobs: - name: Patch manifests with SHA256 and version-pinned URL shell: bash env: - RELEASE_TAG: ${{ github.ref_name }} + RELEASE_TAG: ${{ needs.plan.outputs.tag }} run: | CHECKSUMS="target/wasm-bundles/checksums.txt" if [ ! -f "$CHECKSUMS" ]; then @@ -531,7 +538,7 @@ jobs: continue fi name=$(echo "$filename" | sed "s/^${kind}-//" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//') - url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}" + url="https://github.com/JZKK720/ironclaw/releases/download/${RELEASE_TAG}/${filename}" manifest="registry/${kind}s/${name}.json" if [ -f "$manifest" ]; then @@ -574,5 +581,6 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: + ref: ${{ needs.plan.outputs.tag }} persist-credentials: false submodules: recursive From 9bda215dc30ae05088e02fe0f62bf760f538110c Mon Sep 17 00:00:00 2001 From: JZKK720 Date: Sat, 23 May 2026 16:46:19 +0800 Subject: [PATCH 11/14] ci(release): allow docker publish in release workflow --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 048623279e7..6e776b70b53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -483,7 +483,7 @@ jobs: if: ${{ always() && needs.host.result == 'success' }} permissions: contents: read - packages: read + packages: write actions: write uses: ./.github/workflows/docker.yml with: From 084126a1ad88b8960fda53295219dd3b6410b219 Mon Sep 17 00:00:00 2001 From: JZKK720 Date: Sat, 23 May 2026 16:50:44 +0800 Subject: [PATCH 12/14] ci(release): pin docker reruns to the release ref --- .github/workflows/docker.yml | 12 +++++++++++- .github/workflows/release.yml | 5 ++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5fab7fe0d67..b55cf2887ed 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -12,6 +12,11 @@ on: required: false type: boolean default: false + source_ref: + description: "Git ref to checkout for reusable workflow runs" + required: false + type: string + default: "" tag: description: "Image tag override (leave empty for auto-detect)" required: false @@ -25,6 +30,11 @@ on: required: false type: boolean default: false + source_ref: + description: "Git ref to checkout before building" + required: false + type: string + default: "" tag: description: "Image tag override (leave empty for auto-detect)" required: false @@ -52,7 +62,7 @@ jobs: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - ref: ${{ github.event_name == 'schedule' && 'main' || '' }} + ref: ${{ github.event_name == 'workflow_call' && inputs.source_ref || github.event_name == 'workflow_dispatch' && inputs.source_ref || github.event_name == 'schedule' && 'main' || '' }} persist-credentials: false - name: Resolve source git commit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e776b70b53..5b53d041ff8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -479,7 +479,9 @@ jobs: # Build and push Docker Hub images (:version, :latest, :sha-*) after the GitHub Release exists. docker-image: - needs: host + needs: + - plan + - host if: ${{ always() && needs.host.result == 'success' }} permissions: contents: read @@ -488,6 +490,7 @@ jobs: uses: ./.github/workflows/docker.yml with: release: true + source_ref: ${{ needs.plan.outputs.tag }} secrets: inherit # Commit patched manifest SHA256 checksums back to main so the repo From fcc668ec0e708efa992058f0df3e3d5c03d4be0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=BA=E6=96=B9=E4=BA=91cubecloud-io?= Date: Sat, 23 May 2026 21:00:43 +0800 Subject: [PATCH 13/14] ci(release): skip msi for fork prerelease tags --- .github/workflows/release.yml | 1218 +++++++++++++++++---------------- 1 file changed, 629 insertions(+), 589 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b53d041ff8..f71763f2c8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,589 +1,629 @@ -# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist -# -# Copyright 2022-2024, axodotdev -# SPDX-License-Identifier: MIT or Apache-2.0 -# -# CI that: -# -# * checks for a Git Tag that looks like a release -# * builds artifacts with dist (archives, installers, hashes) -# * uploads those artifacts to temporary workflow zip -# * on success, uploads the artifacts to a GitHub Release -# -# Note that the GitHub Release will be created with a generated -# title/body based on your changelogs. - -name: Release -permissions: - contents: read - -# This task will run whenever you push a git tag that looks like a version -# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. -# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where -# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION -# must be a Cargo-style SemVer Version (must have at least major.minor.patch). -# -# If PACKAGE_NAME is specified, then the announcement will be for that -# package (erroring out if it doesn't have the given version or isn't dist-able). -# -# If PACKAGE_NAME isn't specified, then the announcement will be for all -# (dist-able) packages in the workspace with that version (this mode is -# intended for workspaces with only one dist-able package, or with all dist-able -# packages versioned/released in lockstep). -# -# If you push multiple tags at once, separate instances of this workflow will -# spin up, creating an independent announcement for each one. However, GitHub -# will hard limit this to 3 tags per commit, as it will assume more tags is a -# mistake. -# -# If there's a prerelease-style suffix to the version, then the release(s) -# will be marked as a prerelease. -on: - push: - tags: - - 'ironclaw-v[0-9]+.[0-9]+.[0-9]+*' - workflow_dispatch: - inputs: - release_tag: - description: Release tag to publish - required: true - type: string - -jobs: - # Run 'dist plan' (or host) to determine what tasks we need to do - plan: - runs-on: "ubuntu-22.04" - outputs: - val: ${{ steps.plan.outputs.manifest }} - tag: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} - tag-flag: ${{ format('--tag={0}', github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name) }} - publishing: 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} - persist-credentials: false - submodules: recursive - - name: Install dist - # we specify bash to get pipefail; it guards against the `curl` command - # failing. otherwise `sh` won't catch that `curl` returned non-0 - shell: bash - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" - - name: Cache dist - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: cargo-dist-cache - path: ~/.cargo/bin/dist - - id: plan - env: - REF_NAME: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} - run: | - dist host --steps=create --tag="$REF_NAME" --output-format=json > plan-dist-manifest.json - echo "dist ran successfully" - cat plan-dist-manifest.json - echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" - - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: artifacts-plan-dist-manifest - path: plan-dist-manifest.json - - # Build and packages all the platform-specific things - build-local-artifacts: - name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) - # Wait for WASM extensions so we can patch manifests with SHA256 checksums - # before build.rs bakes them into the embedded catalog. - needs: - - plan - - build-wasm-extensions - if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') && (needs.build-wasm-extensions.result == 'skipped' || needs.build-wasm-extensions.result == 'success') }} - strategy: - fail-fast: false - # Target platforms/runners are computed by dist in create-release. - # Each member of the matrix has the following arguments: - # - # - runner: the github runner - # - dist-args: cli flags to pass to dist - # - install-dist: expression to run to install dist on the runner - # - # Typically there will be: - # - 1 "global" task that builds universal installers - # - N "local" tasks that build each platform's binaries and platform-specific installers - matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} - runs-on: ${{ matrix.runner }} - container: ${{ matrix.container && matrix.container.image || null }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json - steps: - - name: enable windows longpaths - run: | - git config --global core.longpaths true - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ needs.plan.outputs.tag }} - persist-credentials: false - submodules: recursive - - name: Install Rust non-interactively if not already installed - if: ${{ matrix.container }} - run: | - if ! command -v cargo > /dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - echo "$HOME/.cargo/bin" >> $GITHUB_PATH - fi - - uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - key: ${{ join(matrix.targets, '-') }} - cache-provider: ${{ matrix.cache_provider }} - - name: Install dist - run: ${{ matrix.install_dist.run }} - # Get the dist-manifest - - name: Fetch local artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - pattern: artifacts-* - path: target/distrib/ - merge-multiple: true - - name: Patch manifests with WASM checksums - if: ${{ needs.plan.outputs.publishing == 'true' }} - shell: bash - env: - RELEASE_TAG: ${{ needs.plan.outputs.tag }} - run: | - CHECKSUMS="target/distrib/checksums.txt" - if [ ! -f "$CHECKSUMS" ]; then - echo "No checksums.txt found, skipping manifest patching" - exit 0 - fi - - while IFS= read -r line; do - sha256=$(echo "$line" | awk '{print $1}') - filename=$(echo "$line" | awk '{print $2}') - # Skip non-WASM entries (e.g. binary tarballs from cargo-dist) - case "$filename" in *-wasm32-wasip2.tar.gz) ;; *) continue ;; esac - # Parse kind-prefixed filename: "tool-slack-0.2.1-wasm32-wasip2.tar.gz" - # → kind=tool, name=slack - kind=$(echo "$filename" | cut -d'-' -f1) - if [ "$kind" != "tool" ] && [ "$kind" != "channel" ]; then - echo "::warning::Skipping '$filename': unrecognized kind prefix '$kind'" - continue - fi - name=$(echo "$filename" | sed "s/^${kind}-//" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//') - url="https://github.com/JZKK720/ironclaw/releases/download/${RELEASE_TAG}/${filename}" - - manifest="registry/${kind}s/${name}.json" - if [ -f "$manifest" ]; then - jq --arg sha "$sha256" --arg url "$url" \ - '.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \ - "$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest" - echo "Patched $manifest with sha256=$sha256 url=$url" - fi - done < "$CHECKSUMS" - - name: Install dependencies - run: | - ${{ matrix.packages_install }} - - name: Build artifacts - env: - TAG_FLAG: ${{ needs.plan.outputs.tag-flag }} - DIST_ARGS: ${{ matrix.dist_args }} - run: | - # Actually do builds and make zips and whatnot - # shellcheck disable=SC2086 # TAG_FLAG/DIST_ARGS may contain multiple args - dist build $TAG_FLAG --print=linkage --output-format=json $DIST_ARGS > dist-manifest.json - echo "dist ran successfully" - - id: cargo-dist - name: Post-build - # We force bash here just because github makes it really hard to get values up - # to "real" actions without writing to env-vars, and writing to env-vars has - # inconsistent syntax between shell and powershell. - shell: bash - run: | - # Parse out what we just built and upload it to scratch storage - echo "paths<> "$GITHUB_OUTPUT" - dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - cp dist-manifest.json "$BUILD_MANIFEST_NAME" - - name: "Upload artifacts" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: artifacts-build-local-${{ join(matrix.targets, '_') }} - path: | - ${{ steps.cargo-dist.outputs.paths }} - ${{ env.BUILD_MANIFEST_NAME }} - - # Build and package all the platform-agnostic(ish) things - build-global-artifacts: - needs: - - plan - - build-local-artifacts - runs-on: "ubuntu-22.04" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ needs.plan.outputs.tag }} - persist-credentials: false - submodules: recursive - - name: Install cached dist - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: cargo-dist-cache - path: ~/.cargo/bin/ - - run: chmod +x ~/.cargo/bin/dist - # Get all the local artifacts for the global tasks to use (for e.g. checksums) - - name: Fetch local artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - pattern: artifacts-* - path: target/distrib/ - merge-multiple: true - - id: cargo-dist - shell: bash - env: - TAG_FLAG: ${{ needs.plan.outputs.tag-flag }} - run: | - # shellcheck disable=SC2086 # TAG_FLAG may expand to '--tag=X' or empty - dist build $TAG_FLAG --output-format=json "--artifacts=global" > dist-manifest.json - echo "dist ran successfully" - - # Parse out what we just built and upload it to scratch storage - echo "paths<> "$GITHUB_OUTPUT" - jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - cp dist-manifest.json "$BUILD_MANIFEST_NAME" - - name: "Upload artifacts" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: artifacts-build-global - path: | - ${{ steps.cargo-dist.outputs.paths }} - ${{ env.BUILD_MANIFEST_NAME }} - # Build WASM extension bundles (tar.gz with .wasm + .capabilities.json) - build-wasm-extensions: - needs: - - plan - if: ${{ needs.plan.outputs.publishing == 'true' }} - runs-on: "ubuntu-22.04" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ needs.plan.outputs.tag }} - persist-credentials: false - submodules: recursive - - name: Install Rust toolchain + wasm target - run: rustup target add wasm32-wasip2 - - name: Install cargo-component - uses: ./.github/actions/install-cargo-component - - uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - key: wasm-extensions - - name: Build and package WASM extensions - shell: bash - run: | - set -euo pipefail - mkdir -p target/wasm-bundles - - # Process each manifest in registry/tools/ and registry/channels/ - for manifest in registry/tools/*.json registry/channels/*.json; do - [ -f "$manifest" ] || continue - - # file_stem: JSON filename without extension (e.g. "slack" for slack.json). - file_stem=$(basename "$manifest" .json) - # kind: "tool" or "channel" — used as bundle filename prefix to avoid - # collisions when a tool and channel share the same file_stem (e.g. slack). - kind=$(jq -r '.kind' "$manifest") - if [ "$kind" != "tool" ] && [ "$kind" != "channel" ]; then - echo "::error::Manifest '$manifest' has invalid or missing .kind ('$kind'); expected 'tool' or 'channel'" - exit 1 - fi - # ext_name: the manifest's .name field (e.g. "slack-tool"). - # Used for file names *inside* the archive — the installer extracts by manifest.name. - ext_name=$(jq -r '.name' "$manifest") - source_dir=$(jq -r '.source.dir' "$manifest") - caps_file=$(jq -r '.source.capabilities' "$manifest") - crate_name=$(jq -r '.source.crate_name' "$manifest") - ext_version=$(jq -r '.version // ""' "$manifest") - - if [ ! -d "$source_dir" ]; then - echo "::warning::Source dir '$source_dir' not found for '$file_stem', skipping" - continue - fi - - # Skip rebuild if this exact version was already built and checksummed. - # Checks that (1) the manifest already has a sha256, and (2) the version - # embedded in the existing artifact URL matches the current manifest version. - # This ensures stable checksums: only rebuild when the source version changes. - existing_sha=$(jq -r '.artifacts["wasm32-wasip2"].sha256 // ""' "$manifest") - existing_url=$(jq -r '.artifacts["wasm32-wasip2"].url // ""' "$manifest") - url_version=$(echo "$existing_url" | sed -n 's/.*-\([0-9].*\)-wasm32-wasip2\.tar\.gz$/\1/p') - - if [[ -n "$ext_version" && "$url_version" == "$ext_version" && -n "$existing_sha" ]]; then - echo "=== Skipping $file_stem v$ext_version — already checksummed at $existing_url ===" - continue - fi - - echo "=== Building $file_stem ($ext_name) v$ext_version from $source_dir ===" - - # Build WASM component - cargo component build --release --manifest-path "$source_dir/Cargo.toml" || { - echo "::warning::Build failed for '$file_stem', skipping" - continue - } - - # Find the built WASM file (Cargo uses underscores in artifact names) - wasm_artifact="${crate_name//-/_}" - wasm_path="" - for target_dir in wasm32-wasip2 wasm32-wasip1 wasm32-wasi; do - candidate="$source_dir/target/$target_dir/release/${wasm_artifact}.wasm" - if [ -f "$candidate" ]; then - wasm_path="$candidate" - break - fi - done - - if [ -z "$wasm_path" ]; then - echo "::warning::No WASM output found for '$file_stem', skipping" - continue - fi - - # Archive contents use ext_name (manifest .name) — the installer extracts - # files by manifest.name, so these must match even when file_stem differs. - cp "$wasm_path" "target/wasm-bundles/${ext_name}.wasm" - - caps_path="$source_dir/$caps_file" - if [ -f "$caps_path" ]; then - cp "$caps_path" "target/wasm-bundles/${ext_name}.capabilities.json" - else - echo "::warning::No capabilities file at '$caps_path' for '$file_stem'" - fi - - # Bundle filename uses kind+file_stem to avoid collisions when a tool - # and channel share the same name (e.g. tool-slack vs channel-slack). - bundle_name="${kind}-${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" - bundle="target/wasm-bundles/${bundle_name}" - (cd target/wasm-bundles && if [ -f "${ext_name}.capabilities.json" ]; then - tar czf "${bundle_name}" "${ext_name}.wasm" "${ext_name}.capabilities.json" - else - tar czf "${bundle_name}" "${ext_name}.wasm" - fi) - - # Compute SHA256 - sha256=$(sha256sum "$bundle" | cut -d' ' -f1) - echo "$sha256 ${bundle_name}" >> target/wasm-bundles/checksums.txt - - # Clean up intermediate files - rm -f "target/wasm-bundles/${ext_name}.wasm" "target/wasm-bundles/${ext_name}.capabilities.json" - - echo " -> $bundle ($sha256)" - done - - echo "=== WASM bundles built ===" - ls -la target/wasm-bundles/ - - name: "Upload WASM bundles" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: artifacts-wasm-extensions - path: | - target/wasm-bundles/*.tar.gz - target/wasm-bundles/checksums.txt - - # Determines if we should publish/announce - host: - needs: - - plan - - build-local-artifacts - - build-global-artifacts - - build-wasm-extensions - # Only run if we're "publishing", and only if plan, local, global, and wasm didn't fail (skipped is fine) - if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') && (needs.build-wasm-extensions.result == 'skipped' || needs.build-wasm-extensions.result == 'success') }} - permissions: - contents: write - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - runs-on: "ubuntu-22.04" - outputs: - val: ${{ steps.host.outputs.manifest }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ needs.plan.outputs.tag }} - persist-credentials: false - submodules: recursive - - name: Install cached dist - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: cargo-dist-cache - path: ~/.cargo/bin/ - - run: chmod +x ~/.cargo/bin/dist - # Fetch artifacts from scratch-storage - - name: Fetch artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - pattern: artifacts-* - path: target/distrib/ - merge-multiple: true - - id: host - shell: bash - env: - TAG_FLAG: ${{ needs.plan.outputs.tag-flag }} - run: | - # shellcheck disable=SC2086 # TAG_FLAG may expand to '--tag=X' or empty - dist host $TAG_FLAG --steps=upload --steps=release --output-format=json > dist-manifest.json - echo "artifacts uploaded and released successfully" - cat dist-manifest.json - echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" - - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - # Overwrite the previous copy - name: artifacts-dist-manifest - path: dist-manifest.json - # Create a GitHub Release while uploading all files to it - - name: "Download GitHub Artifacts" - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - pattern: artifacts-* - path: artifacts - merge-multiple: true - - name: Cleanup - run: | - # Remove the granular manifests - rm -f artifacts/*-dist-manifest.json - - name: Create GitHub Release - env: - PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" - ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" - ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" - RELEASE_COMMIT: "${{ github.sha }}" - RELEASE_TAG: ${{ needs.plan.outputs.tag }} - run: | - # Write and read notes from a file to avoid quoting breaking things - echo "$ANNOUNCEMENT_BODY" > "$RUNNER_TEMP/notes.txt" - - if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - # shellcheck disable=SC2086 # PRERELEASE_FLAG is '--prerelease' or empty - gh release edit "$RELEASE_TAG" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" - gh release upload "$RELEASE_TAG" --clobber artifacts/* - else - # shellcheck disable=SC2086 # PRERELEASE_FLAG is '--prerelease' or empty - gh release create "$RELEASE_TAG" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* - fi - - # Build and push Docker Hub images (:version, :latest, :sha-*) after the GitHub Release exists. - docker-image: - needs: - - plan - - host - if: ${{ always() && needs.host.result == 'success' }} - permissions: - contents: read - packages: write - actions: write - uses: ./.github/workflows/docker.yml - with: - release: true - source_ref: ${{ needs.plan.outputs.tag }} - secrets: inherit - - # Commit patched manifest SHA256 checksums back to main so the repo - # stays in sync with the released artifacts. - update-registry-checksums: - needs: - - plan - - host - - build-wasm-extensions - if: ${{ always() && needs.host.result == 'success' && needs.build-wasm-extensions.result == 'success' }} - runs-on: "ubuntu-22.04" - permissions: - contents: write - pull-requests: write - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: main - # persist-credentials kept enabled — job pushes a checksum-update branch. - - name: Fetch WASM checksums - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: artifacts-wasm-extensions - path: target/wasm-bundles/ - - name: Patch manifests with SHA256 and version-pinned URL - shell: bash - env: - RELEASE_TAG: ${{ needs.plan.outputs.tag }} - run: | - CHECKSUMS="target/wasm-bundles/checksums.txt" - if [ ! -f "$CHECKSUMS" ]; then - echo "No checksums.txt found" - exit 0 - fi - - while IFS= read -r line; do - sha256=$(echo "$line" | awk '{print $1}') - filename=$(echo "$line" | awk '{print $2}') - # Skip non-WASM entries (defensive — this checksums.txt should only have WASM) - case "$filename" in *-wasm32-wasip2.tar.gz) ;; *) continue ;; esac - # Parse kind-prefixed filename: "tool-slack-0.2.1-wasm32-wasip2.tar.gz" - # → kind=tool, name=slack - kind=$(echo "$filename" | cut -d'-' -f1) - if [ "$kind" != "tool" ] && [ "$kind" != "channel" ]; then - echo "::warning::Skipping '$filename': unrecognized kind prefix '$kind'" - continue - fi - name=$(echo "$filename" | sed "s/^${kind}-//" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//') - url="https://github.com/JZKK720/ironclaw/releases/download/${RELEASE_TAG}/${filename}" - - manifest="registry/${kind}s/${name}.json" - if [ -f "$manifest" ]; then - jq --arg sha "$sha256" --arg url "$url" \ - '.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \ - "$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest" - echo "Patched $manifest with sha256=$sha256 url=$url" - fi - done < "$CHECKSUMS" - - name: Create PR with updated manifests - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add registry/ - if git diff --cached --quiet; then - echo "No manifest changes to commit" - else - BRANCH="chore/update-checksums-$(date +%s)" - git checkout -b "$BRANCH" - git commit -m "chore: update WASM artifact SHA256 checksums [skip ci]" - git push origin "$BRANCH" - gh pr create \ - --title "chore: update WASM artifact checksums and version-pinned URLs" \ - --body "Auto-generated by release CI. Updates SHA256 checksums and version-pinned artifact URLs in registry manifests to match the released WASM artifacts. Only extensions whose version changed since the last release are included." \ - --base main \ - --head "$BRANCH" - fi - - announce: - needs: - - plan - - host - # use "always() && ..." to allow us to wait for all publish jobs while - # still allowing individual publish jobs to skip themselves (for prereleases). - # "host" however must run to completion, no skipping allowed! - if: ${{ always() && needs.host.result == 'success' }} - runs-on: "ubuntu-22.04" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ needs.plan.outputs.tag }} - persist-credentials: false - submodules: recursive +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + contents: read + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + push: + tags: + - 'ironclaw-v[0-9]+.[0-9]+.[0-9]+*' + workflow_dispatch: + inputs: + release_tag: + description: Release tag to publish + required: true + type: string + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} + tag-flag: ${{ format('--tag={0}', github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name) }} + publishing: 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} + persist-credentials: false + submodules: recursive + - name: Disable MSI for fork prerelease tags + shell: bash + env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} + run: | + if [[ "$RELEASE_TAG" =~ ^ironclaw-v[0-9]+\.[0-9]+\.[0-9]+-f[0-9]+$ ]]; then + python -c "from pathlib import Path; path = Path('Cargo.toml'); text = path.read_text(); old = 'installers = [\"shell\", \"powershell\", \"npm\", \"msi\"]'; new = 'installers = [\"shell\", \"powershell\", \"npm\"]'; assert old in text, 'expected installers list not found'; path.write_text(text.replace(old, new, 1)); print('Disabled MSI installer for fork prerelease release tag')" + else + echo "Keeping MSI installer enabled for $RELEASE_TAG" + fi + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + - id: plan + env: + REF_NAME: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} + run: | + dist host --steps=create --tag="$REF_NAME" --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Wait for WASM extensions so we can patch manifests with SHA256 checksums + # before build.rs bakes them into the embedded catalog. + needs: + - plan + - build-wasm-extensions + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') && (needs.build-wasm-extensions.result == 'skipped' || needs.build-wasm-extensions.result == 'success') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ needs.plan.outputs.tag }} + persist-credentials: false + submodules: recursive + - name: Disable MSI for fork prerelease tags + shell: bash + env: + RELEASE_TAG: ${{ needs.plan.outputs.tag }} + run: | + if [[ "$RELEASE_TAG" =~ ^ironclaw-v[0-9]+\.[0-9]+\.[0-9]+-f[0-9]+$ ]]; then + python -c "from pathlib import Path; path = Path('Cargo.toml'); text = path.read_text(); old = 'installers = [\"shell\", \"powershell\", \"npm\", \"msi\"]'; new = 'installers = [\"shell\", \"powershell\", \"npm\"]'; assert old in text, 'expected installers list not found'; path.write_text(text.replace(old, new, 1)); print('Disabled MSI installer for fork prerelease release tag')" + else + echo "Keeping MSI installer enabled for $RELEASE_TAG" + fi + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: ${{ join(matrix.targets, '-') }} + cache-provider: ${{ matrix.cache_provider }} + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Patch manifests with WASM checksums + if: ${{ needs.plan.outputs.publishing == 'true' }} + shell: bash + env: + RELEASE_TAG: ${{ needs.plan.outputs.tag }} + run: | + CHECKSUMS="target/distrib/checksums.txt" + if [ ! -f "$CHECKSUMS" ]; then + echo "No checksums.txt found, skipping manifest patching" + exit 0 + fi + + while IFS= read -r line; do + sha256=$(echo "$line" | awk '{print $1}') + filename=$(echo "$line" | awk '{print $2}') + # Skip non-WASM entries (e.g. binary tarballs from cargo-dist) + case "$filename" in *-wasm32-wasip2.tar.gz) ;; *) continue ;; esac + # Parse kind-prefixed filename: "tool-slack-0.2.1-wasm32-wasip2.tar.gz" + # 鈫?kind=tool, name=slack + kind=$(echo "$filename" | cut -d'-' -f1) + if [ "$kind" != "tool" ] && [ "$kind" != "channel" ]; then + echo "::warning::Skipping '$filename': unrecognized kind prefix '$kind'" + continue + fi + name=$(echo "$filename" | sed "s/^${kind}-//" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//') + url="https://github.com/JZKK720/ironclaw/releases/download/${RELEASE_TAG}/${filename}" + + manifest="registry/${kind}s/${name}.json" + if [ -f "$manifest" ]; then + jq --arg sha "$sha256" --arg url "$url" \ + '.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \ + "$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest" + echo "Patched $manifest with sha256=$sha256 url=$url" + fi + done < "$CHECKSUMS" + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + env: + TAG_FLAG: ${{ needs.plan.outputs.tag-flag }} + DIST_ARGS: ${{ matrix.dist_args }} + run: | + # Actually do builds and make zips and whatnot + # shellcheck disable=SC2086 # TAG_FLAG/DIST_ARGS may contain multiple args + dist build $TAG_FLAG --print=linkage --output-format=json $DIST_ARGS > dist-manifest.json + echo "dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ needs.plan.outputs.tag }} + persist-credentials: false + submodules: recursive + - name: Disable MSI for fork prerelease tags + shell: bash + env: + RELEASE_TAG: ${{ needs.plan.outputs.tag }} + run: | + if [[ "$RELEASE_TAG" =~ ^ironclaw-v[0-9]+\.[0-9]+\.[0-9]+-f[0-9]+$ ]]; then + python -c "from pathlib import Path; path = Path('Cargo.toml'); text = path.read_text(); old = 'installers = [\"shell\", \"powershell\", \"npm\", \"msi\"]'; new = 'installers = [\"shell\", \"powershell\", \"npm\"]'; assert old in text, 'expected installers list not found'; path.write_text(text.replace(old, new, 1)); print('Disabled MSI installer for fork prerelease release tag')" + else + echo "Keeping MSI installer enabled for $RELEASE_TAG" + fi + - name: Install cached dist + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + env: + TAG_FLAG: ${{ needs.plan.outputs.tag-flag }} + run: | + # shellcheck disable=SC2086 # TAG_FLAG may expand to '--tag=X' or empty + dist build $TAG_FLAG --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Build WASM extension bundles (tar.gz with .wasm + .capabilities.json) + build-wasm-extensions: + needs: + - plan + if: ${{ needs.plan.outputs.publishing == 'true' }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ needs.plan.outputs.tag }} + persist-credentials: false + submodules: recursive + - name: Install Rust toolchain + wasm target + run: rustup target add wasm32-wasip2 + - name: Install cargo-component + uses: ./.github/actions/install-cargo-component + - uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: wasm-extensions + - name: Build and package WASM extensions + shell: bash + run: | + set -euo pipefail + mkdir -p target/wasm-bundles + + # Process each manifest in registry/tools/ and registry/channels/ + for manifest in registry/tools/*.json registry/channels/*.json; do + [ -f "$manifest" ] || continue + + # file_stem: JSON filename without extension (e.g. "slack" for slack.json). + file_stem=$(basename "$manifest" .json) + # kind: "tool" or "channel" 鈥?used as bundle filename prefix to avoid + # collisions when a tool and channel share the same file_stem (e.g. slack). + kind=$(jq -r '.kind' "$manifest") + if [ "$kind" != "tool" ] && [ "$kind" != "channel" ]; then + echo "::error::Manifest '$manifest' has invalid or missing .kind ('$kind'); expected 'tool' or 'channel'" + exit 1 + fi + # ext_name: the manifest's .name field (e.g. "slack-tool"). + # Used for file names *inside* the archive 鈥?the installer extracts by manifest.name. + ext_name=$(jq -r '.name' "$manifest") + source_dir=$(jq -r '.source.dir' "$manifest") + caps_file=$(jq -r '.source.capabilities' "$manifest") + crate_name=$(jq -r '.source.crate_name' "$manifest") + ext_version=$(jq -r '.version // ""' "$manifest") + + if [ ! -d "$source_dir" ]; then + echo "::warning::Source dir '$source_dir' not found for '$file_stem', skipping" + continue + fi + + # Skip rebuild if this exact version was already built and checksummed. + # Checks that (1) the manifest already has a sha256, and (2) the version + # embedded in the existing artifact URL matches the current manifest version. + # This ensures stable checksums: only rebuild when the source version changes. + existing_sha=$(jq -r '.artifacts["wasm32-wasip2"].sha256 // ""' "$manifest") + existing_url=$(jq -r '.artifacts["wasm32-wasip2"].url // ""' "$manifest") + url_version=$(echo "$existing_url" | sed -n 's/.*-\([0-9].*\)-wasm32-wasip2\.tar\.gz$/\1/p') + + if [[ -n "$ext_version" && "$url_version" == "$ext_version" && -n "$existing_sha" ]]; then + echo "=== Skipping $file_stem v$ext_version 鈥?already checksummed at $existing_url ===" + continue + fi + + echo "=== Building $file_stem ($ext_name) v$ext_version from $source_dir ===" + + # Build WASM component + cargo component build --release --manifest-path "$source_dir/Cargo.toml" || { + echo "::warning::Build failed for '$file_stem', skipping" + continue + } + + # Find the built WASM file (Cargo uses underscores in artifact names) + wasm_artifact="${crate_name//-/_}" + wasm_path="" + for target_dir in wasm32-wasip2 wasm32-wasip1 wasm32-wasi; do + candidate="$source_dir/target/$target_dir/release/${wasm_artifact}.wasm" + if [ -f "$candidate" ]; then + wasm_path="$candidate" + break + fi + done + + if [ -z "$wasm_path" ]; then + echo "::warning::No WASM output found for '$file_stem', skipping" + continue + fi + + # Archive contents use ext_name (manifest .name) 鈥?the installer extracts + # files by manifest.name, so these must match even when file_stem differs. + cp "$wasm_path" "target/wasm-bundles/${ext_name}.wasm" + + caps_path="$source_dir/$caps_file" + if [ -f "$caps_path" ]; then + cp "$caps_path" "target/wasm-bundles/${ext_name}.capabilities.json" + else + echo "::warning::No capabilities file at '$caps_path' for '$file_stem'" + fi + + # Bundle filename uses kind+file_stem to avoid collisions when a tool + # and channel share the same name (e.g. tool-slack vs channel-slack). + bundle_name="${kind}-${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" + bundle="target/wasm-bundles/${bundle_name}" + (cd target/wasm-bundles && if [ -f "${ext_name}.capabilities.json" ]; then + tar czf "${bundle_name}" "${ext_name}.wasm" "${ext_name}.capabilities.json" + else + tar czf "${bundle_name}" "${ext_name}.wasm" + fi) + + # Compute SHA256 + sha256=$(sha256sum "$bundle" | cut -d' ' -f1) + echo "$sha256 ${bundle_name}" >> target/wasm-bundles/checksums.txt + + # Clean up intermediate files + rm -f "target/wasm-bundles/${ext_name}.wasm" "target/wasm-bundles/${ext_name}.capabilities.json" + + echo " -> $bundle ($sha256)" + done + + echo "=== WASM bundles built ===" + ls -la target/wasm-bundles/ + - name: "Upload WASM bundles" + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: artifacts-wasm-extensions + path: | + target/wasm-bundles/*.tar.gz + target/wasm-bundles/checksums.txt + + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + - build-wasm-extensions + # Only run if we're "publishing", and only if plan, local, global, and wasm didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') && (needs.build-wasm-extensions.result == 'skipped' || needs.build-wasm-extensions.result == 'success') }} + permissions: + contents: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ needs.plan.outputs.tag }} + persist-credentials: false + submodules: recursive + - name: Disable MSI for fork prerelease tags + shell: bash + env: + RELEASE_TAG: ${{ needs.plan.outputs.tag }} + run: | + if [[ "$RELEASE_TAG" =~ ^ironclaw-v[0-9]+\.[0-9]+\.[0-9]+-f[0-9]+$ ]]; then + python -c "from pathlib import Path; path = Path('Cargo.toml'); text = path.read_text(); old = 'installers = [\"shell\", \"powershell\", \"npm\", \"msi\"]'; new = 'installers = [\"shell\", \"powershell\", \"npm\"]'; assert old in text, 'expected installers list not found'; path.write_text(text.replace(old, new, 1)); print('Disabled MSI installer for fork prerelease release tag')" + else + echo "Keeping MSI installer enabled for $RELEASE_TAG" + fi + - name: Install cached dist + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + env: + TAG_FLAG: ${{ needs.plan.outputs.tag-flag }} + run: | + # shellcheck disable=SC2086 # TAG_FLAG may expand to '--tag=X' or empty + dist host $TAG_FLAG --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + RELEASE_TAG: ${{ needs.plan.outputs.tag }} + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > "$RUNNER_TEMP/notes.txt" + + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + # shellcheck disable=SC2086 # PRERELEASE_FLAG is '--prerelease' or empty + gh release edit "$RELEASE_TAG" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" + gh release upload "$RELEASE_TAG" --clobber artifacts/* + else + # shellcheck disable=SC2086 # PRERELEASE_FLAG is '--prerelease' or empty + gh release create "$RELEASE_TAG" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + fi + + # Build and push Docker Hub images (:version, :latest, :sha-*) after the GitHub Release exists. + docker-image: + needs: + - plan + - host + if: ${{ always() && needs.host.result == 'success' }} + permissions: + contents: read + packages: write + actions: write + uses: ./.github/workflows/docker.yml + with: + release: true + source_ref: ${{ needs.plan.outputs.tag }} + secrets: inherit + + # Commit patched manifest SHA256 checksums back to main so the repo + # stays in sync with the released artifacts. + update-registry-checksums: + needs: + - plan + - host + - build-wasm-extensions + if: ${{ always() && needs.host.result == 'success' && needs.build-wasm-extensions.result == 'success' }} + runs-on: "ubuntu-22.04" + permissions: + contents: write + pull-requests: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: main + # persist-credentials kept enabled 鈥?job pushes a checksum-update branch. + - name: Fetch WASM checksums + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: artifacts-wasm-extensions + path: target/wasm-bundles/ + - name: Patch manifests with SHA256 and version-pinned URL + shell: bash + env: + RELEASE_TAG: ${{ needs.plan.outputs.tag }} + run: | + CHECKSUMS="target/wasm-bundles/checksums.txt" + if [ ! -f "$CHECKSUMS" ]; then + echo "No checksums.txt found" + exit 0 + fi + + while IFS= read -r line; do + sha256=$(echo "$line" | awk '{print $1}') + filename=$(echo "$line" | awk '{print $2}') + # Skip non-WASM entries (defensive 鈥?this checksums.txt should only have WASM) + case "$filename" in *-wasm32-wasip2.tar.gz) ;; *) continue ;; esac + # Parse kind-prefixed filename: "tool-slack-0.2.1-wasm32-wasip2.tar.gz" + # 鈫?kind=tool, name=slack + kind=$(echo "$filename" | cut -d'-' -f1) + if [ "$kind" != "tool" ] && [ "$kind" != "channel" ]; then + echo "::warning::Skipping '$filename': unrecognized kind prefix '$kind'" + continue + fi + name=$(echo "$filename" | sed "s/^${kind}-//" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//') + url="https://github.com/JZKK720/ironclaw/releases/download/${RELEASE_TAG}/${filename}" + + manifest="registry/${kind}s/${name}.json" + if [ -f "$manifest" ]; then + jq --arg sha "$sha256" --arg url "$url" \ + '.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \ + "$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest" + echo "Patched $manifest with sha256=$sha256 url=$url" + fi + done < "$CHECKSUMS" + - name: Create PR with updated manifests + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add registry/ + if git diff --cached --quiet; then + echo "No manifest changes to commit" + else + BRANCH="chore/update-checksums-$(date +%s)" + git checkout -b "$BRANCH" + git commit -m "chore: update WASM artifact SHA256 checksums [skip ci]" + git push origin "$BRANCH" + gh pr create \ + --title "chore: update WASM artifact checksums and version-pinned URLs" \ + --body "Auto-generated by release CI. Updates SHA256 checksums and version-pinned artifact URLs in registry manifests to match the released WASM artifacts. Only extensions whose version changed since the last release are included." \ + --base main \ + --head "$BRANCH" + fi + + announce: + needs: + - plan + - host + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ needs.plan.outputs.tag }} + persist-credentials: false + submodules: recursive From a93da016a7b40599a9ac9a946349fb6cee297148 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 23 May 2026 13:44:46 +0000 Subject: [PATCH 14/14] chore: update WASM artifact SHA256 checksums [skip ci] --- registry/channels/slack.json | 4 ++-- registry/channels/telegram.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/registry/channels/slack.json b/registry/channels/slack.json index 0fcb79f070c..e867e4ec70b 100644 --- a/registry/channels/slack.json +++ b/registry/channels/slack.json @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.27.0/channel-slack-0.3.2-wasm32-wasip2.tar.gz", - "sha256": "6aefa0664613d85f1dc3b64c9dd53557044948d0816b367cfb58efecb14c5840" + "url": "https://github.com/JZKK720/ironclaw/releases/download/ironclaw-v0.28.2-f1/channel-slack-0.3.4-wasm32-wasip2.tar.gz", + "sha256": "a5e347c7a374c877c05ff200f5315078ae98f30302cd64fac6d9e52b7ed70a0a" } }, "auth_summary": { diff --git a/registry/channels/telegram.json b/registry/channels/telegram.json index b8b4bf424a4..a7f0b7cb0c4 100644 --- a/registry/channels/telegram.json +++ b/registry/channels/telegram.json @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.26.0/channel-telegram-0.2.10-wasm32-wasip2.tar.gz", - "sha256": "4f0df0f125cc2aa6216d48a5c5bfa6a3adc5f2b32025e81ef37c9b68406eea7e" + "url": "https://github.com/JZKK720/ironclaw/releases/download/ironclaw-v0.28.2-f1/channel-telegram-0.2.11-wasm32-wasip2.tar.gz", + "sha256": "d7bcc163be07c916b6a156d67f3fdad486e20cf414086bcddefd9cc60dcedde7" } }, "auth_summary": {