agent-desktop/.github/workflows/release.yml
Lahfir 3f322728b4
feat!: implement Playwright-grade foundation contract
Settle the Playwright-grade reliability contract in agent-desktop-core
before the Windows/Linux adapters are built, so they inherit it instead
of redesigning it. Every command now observes, waits, verifies, and
reports honestly instead of firing blindly.

Highlights: capability-supertrait split of PlatformAdapter with
not_supported() defaults; canonical role/state vocabulary with live
`is --property visible`; display enumeration (`list-displays`) and honest
`--screen` with scale factor; truthful Automation permission; `native_id`
identity spine; window-id-first resolution; serializable `LocatorQuery`
with live `find`; default-on auto-wait before every ref action; three-way
`hit_test` occlusion gate; `scroll_into_view` in core; core accessible-name
precedence; typed `ActionStep` delivery tier; `ProcessState` and
`APP_UNRESPONSIVE`; `LaunchOptions`; baseline-diff desktop signals
(`wait --event`); typed clipboard (`Text`/`Image`/`FileUrls`); mouse
modifier chords and `mouse-wheel`. Hardened through a 35-reviewer pass with
independent validation and a green live e2e gate (109/0), plus a
head-vs-main performance comparison harness.

BREAKING CHANGE: default-on auto-wait changes the timing of every
previously-untouched ref-action call (bounded 5000 ms default; `--timeout-ms 0`
restores single-shot). `ENVELOPE_VERSION` is now `2.1` (adds the
`APP_UNRESPONSIVE` code and process state in error details). FFI ABI major
is `3` (append-only struct evolution; `wait --event` is intentionally not
exposed over FFI). The legacy string clipboard API is removed in favor of
typed content. `key-down`/`key-up` fail closed until daemon-owned held input
exists. `close-app` verifies termination and the osascript fallback path is
removed. `--text` matching is subtree containment: `find --text X --first`
returns the outermost matching container.
2026-07-20 00:21:38 -07:00

553 lines
21 KiB
YAML

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" <<EOF
# agent-desktop FFI cdylib — v${VERSION} (${{ matrix.target }})
- \`lib/${{ matrix.lib_name }}\` — shared library
- \`include/agent_desktop.h\` — committed C ABI header
- \`LICENSE\` — Apache-2.0
Docs: https://github.com/lahfir/agent-desktop/tree/main/skills/agent-desktop-ffi
macOS \`install_name\` is \`@rpath/libagent_desktop_ffi.dylib\` — linking
consumers need \`-rpath\` pointing at the dylib's directory; \`dlopen\` /
ctypes callers resolve by path and need nothing.
EOF
if [ "$RUNNER_OS" = "macOS" ]; then
# shellcheck disable=SC2016
echo '- `lib/agent-desktop-macos-helper` — required helper, colocated for dylib-image discovery' >> "$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 <file> --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