name: Release on: push: branches: [main] workflow_dispatch: inputs: tag_name: description: Release tag to build and publish, for example v0.2.0 required: true type: string version: description: Semver version without the v prefix, for example 0.2.0 required: true type: string publish_npm: description: Publish the npm package after GitHub assets are uploaded required: true default: true type: boolean publish_skills: description: Publish skills after the release is built required: true default: true type: boolean # One release run per commit — never cancel, just queue. concurrency: group: release-${{ github.sha }} # Deny all permissions at workflow level; each job declares only what it needs. permissions: {} env: CARGO_TERM_COLOR: always jobs: release-please: name: Release Please runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write pull-requests: write outputs: release_created: ${{ steps.release.outputs.release_created || steps.manual.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name || steps.manual.outputs.tag_name }} version: ${{ steps.release.outputs.major && format('{0}.{1}.{2}', steps.release.outputs.major, steps.release.outputs.minor, steps.release.outputs.patch) || steps.manual.outputs.version }} publish_npm: ${{ steps.manual.outputs.publish_npm || 'true' }} publish_skills: ${{ steps.manual.outputs.publish_skills || steps.release.outputs.release_created }} steps: - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 if: github.event_name != 'workflow_dispatch' id: release with: config-file: release-please-config.json manifest-file: .release-please-manifest.json - name: Use manual release inputs if: github.event_name == 'workflow_dispatch' id: manual env: TAG_NAME: ${{ inputs.tag_name }} VERSION: ${{ inputs.version }} PUBLISH_NPM: ${{ inputs.publish_npm }} PUBLISH_SKILLS: ${{ inputs.publish_skills }} run: | if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "Invalid version: ${VERSION}" >&2 exit 1 fi if [[ "$TAG_NAME" != "v${VERSION}" ]]; then echo "Tag ${TAG_NAME} must match version ${VERSION}" >&2 exit 1 fi { echo "release_created=true" echo "tag_name=${TAG_NAME}" echo "version=${VERSION}" echo "publish_npm=${PUBLISH_NPM}" echo "publish_skills=${PUBLISH_SKILLS}" } >> "$GITHUB_OUTPUT" - name: Sync Cargo.lock on release PR if: github.event_name != 'workflow_dispatch' && steps.release.outputs.release_created != 'true' env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} run: | set -euo pipefail BRANCH="release-please--branches--main--components--agent-desktop" OPEN_RELEASE_PRS=$(gh pr list \ --head "$BRANCH" \ --state open \ --json number \ --jq 'length') if [ "$OPEN_RELEASE_PRS" = "0" ]; then echo "No open release PR branch to sync" exit 0 fi git init -b main release-pr cd release-pr git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${GH_REPO}.git" git fetch --depth=1 origin "$BRANCH" git checkout -B "$BRANCH" FETCH_HEAD rustup show VERSION=$(sed -n 's/.*version *= *"\([^"]*\)".*/\1/p' Cargo.toml | head -1) cargo update -p agent-desktop --precise "$VERSION" scripts/check-release-consistency.sh if git diff --quiet -- Cargo.lock; then echo "Cargo.lock already matches release version ${VERSION}" exit 0 fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add Cargo.lock git commit -m "chore: sync cargo lock for release ${VERSION}" git push origin "HEAD:${BRANCH}" build: name: Build (${{ matrix.target }}) needs: release-please if: needs.release-please.outputs.release_created == 'true' runs-on: macos-latest timeout-minutes: 30 permissions: contents: read strategy: fail-fast: false matrix: target: - aarch64-apple-darwin - x86_64-apple-darwin steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ needs.release-please.outputs.tag_name }} - name: Install Rust toolchain run: | rustup show rustup target add ${{ matrix.target }} - name: Cache cargo registry uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }} restore-keys: ${{ runner.os }}-${{ matrix.target }}-cargo- - name: Build release binary run: | cargo build --locked --release -p agent-desktop --target ${{ matrix.target }} cargo build --locked --release -p agent-desktop-macos --bin agent-desktop-macos-helper --target ${{ matrix.target }} - name: Check binary size run: | SIZE=$(stat -f%z target/${{ matrix.target }}/release/agent-desktop) HELPER_SIZE=$(stat -f%z target/${{ matrix.target }}/release/agent-desktop-macos-helper) LIMIT=$((15 * 1024 * 1024)) echo "Binary size: $(du -sh target/${{ matrix.target }}/release/agent-desktop | cut -f1)" echo "Helper size: $(du -sh target/${{ matrix.target }}/release/agent-desktop-macos-helper | cut -f1)" if [ "$SIZE" -gt "$LIMIT" ] || [ "$HELPER_SIZE" -gt "$LIMIT" ]; then echo "FAIL: shipped executable exceeds 15MB limit (binary=${SIZE}, helper=${HELPER_SIZE} bytes)" exit 1 fi - name: Create tarball env: VERSION: ${{ needs.release-please.outputs.version }} run: | TARBALL="agent-desktop-v${VERSION}-${{ matrix.target }}.tar.gz" BINARY="target/${{ matrix.target }}/release/agent-desktop" HELPER="target/${{ matrix.target }}/release/agent-desktop-macos-helper" BINARY_SHA=$(shasum -a 256 "$BINARY" | awk '{print $1}') HELPER_SHA=$(shasum -a 256 "$HELPER" | awk '{print $1}') tar -czf "${TARBALL}" -C "$(dirname "$BINARY")" agent-desktop agent-desktop-macos-helper if [ "$BINARY_SHA" != "$(shasum -a 256 "$BINARY" | awk '{print $1}')" ] || \ [ "$HELPER_SHA" != "$(shasum -a 256 "$HELPER" | awk '{print $1}')" ]; then echo "Release executable changed while packaging" >&2 exit 1 fi shasum -a 256 "${TARBALL}" > "${TARBALL}.sha256" - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: binary-${{ matrix.target }} path: | agent-desktop-v*.tar.gz agent-desktop-v*.tar.gz.sha256 retention-days: 1 # FFI cdylib for language-binding consumers (Python/Swift/Go/Ruby/Node/C). # Ships alongside CLI tarballs on the same release tag; npm stays CLI-only. build-ffi: name: Build FFI (${{ matrix.target }}) needs: release-please if: needs.release-please.outputs.release_created == 'true' runs-on: ${{ matrix.runner }} timeout-minutes: 30 permissions: contents: read strategy: fail-fast: false matrix: include: - target: aarch64-apple-darwin runner: macos-latest archive: tar.gz lib_name: libagent_desktop_ffi.dylib - target: x86_64-apple-darwin runner: macos-latest archive: tar.gz lib_name: libagent_desktop_ffi.dylib - target: x86_64-unknown-linux-gnu runner: ubuntu-22.04 archive: tar.gz lib_name: libagent_desktop_ffi.so - target: aarch64-unknown-linux-gnu runner: ubuntu-22.04-arm archive: tar.gz lib_name: libagent_desktop_ffi.so - target: x86_64-pc-windows-msvc runner: windows-latest archive: zip lib_name: agent_desktop_ffi.dll steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ needs.release-please.outputs.tag_name }} - name: Install Rust toolchain run: | rustup show rustup target add ${{ matrix.target }} - name: Cache cargo registry uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ key: ${{ runner.os }}-${{ matrix.target }}-ffi-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }} restore-keys: ${{ runner.os }}-${{ matrix.target }}-ffi-cargo- # NEVER add --features stub-adapter to the release FFI build — it ships a no-op dylib # where every adapter call returns PLATFORM_NOT_SUPPORTED. The guard step below enforces this. - name: Guard — release dylib must not be a stub build shell: bash run: | # Co-occurrence of the release profile token and the stub flag on the same # line is the discriminator. Legitimate stub uses in this file (release gates, # passthrough test) omit the release-ffi profile, so they do not trigger. PROFILE_TOKEN="release-ffi" STUB_TOKEN="stub-adapter" if grep -nE "${PROFILE_TOKEN}.*${STUB_TOKEN}|${STUB_TOKEN}.*${PROFILE_TOKEN}" \ .github/workflows/release.yml; then echo "FAIL: release-ffi build path must not enable" echo " the stub-adapter feature — that would ship a no-op dylib" exit 1 fi echo "OK: no stub flag on the shipping release-ffi build path" - name: Build FFI cdylib (release-ffi profile) run: cargo build --locked --profile release-ffi -p agent-desktop-ffi --target ${{ matrix.target }} - name: Build macOS helper for FFI consumers if: runner.os == 'macOS' run: cargo build --locked --release -p agent-desktop-macos --bin agent-desktop-macos-helper --target ${{ matrix.target }} # build.rs bakes install_name=@rpath/... ; a regression here silently # breaks Swift/SPM consumers, so verify before shipping. - name: Verify macOS install_name if: runner.os == 'macOS' shell: bash run: | ACTUAL=$(otool -D target/${{ matrix.target }}/release-ffi/${{ matrix.lib_name }} | tail -1) EXPECTED="@rpath/libagent_desktop_ffi.dylib" if [ "$ACTUAL" != "$EXPECTED" ]; then echo "FAIL: install_name is '$ACTUAL', expected '$EXPECTED'" exit 1 fi echo "OK: install_name = $ACTUAL" - name: Stage tarball contents shell: bash env: VERSION: ${{ needs.release-please.outputs.version }} run: | STAGE="agent-desktop-ffi-v${VERSION}-${{ matrix.target }}" mkdir -p "$STAGE/lib" "$STAGE/include" cp "target/${{ matrix.target }}/release-ffi/${{ matrix.lib_name }}" "$STAGE/lib/" if [ "$RUNNER_OS" = "macOS" ]; then cp "target/${{ matrix.target }}/release/agent-desktop-macos-helper" "$STAGE/lib/" chmod +x "$STAGE/lib/agent-desktop-macos-helper" fi cp crates/ffi/include/agent_desktop.h "$STAGE/include/" cp LICENSE "$STAGE/" cat > "$STAGE/README.md" <> "$STAGE/README.md" fi if [ "$RUNNER_OS" != "Windows" ]; then (cd "$STAGE" && shasum -a 256 lib/* include/* LICENSE > SHA256SUMS) fi echo "STAGE=$STAGE" >> "$GITHUB_ENV" - name: Create archive (Unix) if: runner.os != 'Windows' shell: bash run: | tar -czf "${STAGE}.tar.gz" "${STAGE}" (cd "$STAGE" && shasum -a 256 -c SHA256SUMS) if [ "$RUNNER_OS" = "macOS" ]; then tar -tzf "${STAGE}.tar.gz" | grep -Fx "${STAGE}/lib/agent-desktop-macos-helper" fi shasum -a 256 "${STAGE}.tar.gz" > "${STAGE}.tar.gz.sha256" - name: Create archive (Windows) if: runner.os == 'Windows' shell: pwsh run: | Compress-Archive -Path "${env:STAGE}" -DestinationPath "${env:STAGE}.zip" $hash = (Get-FileHash "${env:STAGE}.zip" -Algorithm SHA256).Hash.ToLower() "$hash ${env:STAGE}.zip" | Out-File -Encoding ascii "${env:STAGE}.zip.sha256" - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ffi-${{ matrix.target }} path: | agent-desktop-ffi-v*.tar.gz agent-desktop-ffi-v*.tar.gz.sha256 agent-desktop-ffi-v*.zip agent-desktop-ffi-v*.zip.sha256 retention-days: 1 # Re-runs FFI integrity gates at release time. Header verification runs on # macOS because cbindgen may omit macOS-gated symbols on Linux. ffi-release-gates: name: FFI Release Gates needs: release-please if: needs.release-please.outputs.release_created == 'true' runs-on: macos-latest timeout-minutes: 30 permissions: contents: read steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ needs.release-please.outputs.tag_name }} - name: Install Rust toolchain run: rustup show - name: Cache cargo registry uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ key: ${{ runner.os }}-ffi-gates-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }} restore-keys: ${{ runner.os }}-ffi-gates-cargo- - name: Install pinned cbindgen 0.29.4 run: cargo install cbindgen --version 0.29.4 --locked - name: Header drift check (cbindgen --verify) run: > cbindgen crates/ffi --config crates/ffi/cbindgen.toml --output crates/ffi/include/agent_desktop.h --verify - name: Stub passthrough test run: > scripts/cargo-test-isolated-home.sh test --locked -p agent-desktop-ffi --features stub-adapter --test c_abi_passthrough - name: Assert release-ffi profile keeps panic=unwind run: | if ! grep -A5 '\[profile\.release-ffi\]' Cargo.toml | grep -q 'panic.*=.*"unwind"'; then echo "FAIL: [profile.release-ffi] must set panic=\"unwind\" — a flip to" echo " panic=\"abort\" defeats every trap_panic boundary in the dylib." exit 1 fi echo "OK: release-ffi profile retains panic=\"unwind\"" - name: Prove the shipped cdylib catches an exported-boundary panic run: bash crates/ffi/tests/run_cdylib_panic_probe.sh publish-github: name: Publish to GitHub Release needs: [release-please, build, build-ffi, ffi-release-gates] runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write id-token: write # Sigstore OIDC exchange attestations: write # write provenance bundle back to GitHub steps: - name: Download all artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: "{binary,ffi}-*" merge-multiple: true - name: Create checksums file run: | cat ./*.sha256 > checksums.txt cat checksums.txt - name: Upload assets to GitHub Release env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} TAG_NAME: ${{ needs.release-please.outputs.tag_name }} run: | gh release view "$TAG_NAME" >/dev/null 2>&1 \ || gh release create "$TAG_NAME" \ --title "$TAG_NAME" \ --notes "Release ${TAG_NAME}" gh release upload "$TAG_NAME" \ ./*.tar.gz ./*.zip checksums.txt \ --clobber # Keyless Sigstore provenance. Verify with: # gh attestation verify --repo lahfir/agent-desktop - name: Attest build provenance uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: | *.tar.gz *.zip checksums.txt publish-npm: name: Publish to npm needs: [release-please, publish-github] if: needs.release-please.outputs.release_created == 'true' && needs.release-please.outputs.publish_npm == 'true' runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read id-token: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ needs.release-please.outputs.tag_name }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' registry-url: https://registry.npmjs.org package-manager-cache: false - name: Verify GitHub Release assets env: GH_TOKEN: ${{ github.token }} TAG_NAME: ${{ needs.release-please.outputs.tag_name }} run: | # Expected: 2 CLI tarballs + 4 FFI tarballs + 1 FFI zip + checksums = 8. ASSETS=$(gh release view "$TAG_NAME" --json assets --jq '.assets | length') if [ "$ASSETS" -lt 8 ]; then echo "FAIL: expected at least 8 assets, found ${ASSETS}" exit 1 fi echo "OK: ${ASSETS} assets found on release" - name: Prepare npm package env: VERSION: ${{ needs.release-please.outputs.version }} run: | cd npm node -e " const version = process.env.VERSION; const pkg = require('./package.json'); pkg.version = version; require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); " echo "npm package version set to ${VERSION}" cp ../README.md README.md cp ../CHANGELOG.md CHANGELOG.md 2>/dev/null || true - name: Publish to npm run: npm publish --access public working-directory: npm publish-skills: name: Publish Skills to ClawHub needs: [release-please] if: needs.release-please.outputs.release_created == 'true' && needs.release-please.outputs.publish_skills == 'true' runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ needs.release-please.outputs.tag_name }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' - name: Install ClawHub CLI run: npm i -g clawhub - name: Authenticate with ClawHub run: clawhub login --token "$CLAWHUB_TOKEN" --no-browser env: CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }} - name: Publish all skills to ClawHub run: | set -euo pipefail SOURCE_COMMIT="$(git rev-parse HEAD)" for skill_dir in skills/*; do [ -d "$skill_dir" ] || continue [ -f "$skill_dir/SKILL.md" ] || continue slug="$(basename "$skill_dir")" clawhub publish "$skill_dir" \ --slug "$slug" \ --changelog "Release ${{ needs.release-please.outputs.tag_name }}" \ --tags latest \ --source-repo "${GITHUB_REPOSITORY}" \ --source-commit "$SOURCE_COMMIT" \ --source-ref "${{ needs.release-please.outputs.tag_name }}" \ --source-path "$skill_dir" done