Release #250
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: "Release version (X.Y.Z)" | |
| required: true | |
| type: string | |
| dry_run: | |
| description: "Run release validation without publishing packages or mutating GitHub Releases" | |
| required: false | |
| default: "false" | |
| type: choice | |
| options: | |
| - "false" | |
| - "true" | |
| permissions: | |
| contents: read | |
| id-token: write | |
| env: | |
| CARGO_TERM_COLOR: always | |
| concurrency: | |
| group: release-${{ github.ref_name || inputs.version }} | |
| cancel-in-progress: false | |
| jobs: | |
| resolve-version: | |
| name: Resolve Version | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.resolve.outputs.version }} | |
| tag: ${{ steps.resolve.outputs.tag }} | |
| steps: | |
| - id: resolve | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then | |
| TAG="${GITHUB_REF_NAME}" | |
| VERSION="${TAG#v}" | |
| else | |
| VERSION="${{ inputs.version }}" | |
| if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "Version must be strict semver: X.Y.Z" >&2 | |
| exit 1 | |
| fi | |
| TAG="v${VERSION}" | |
| fi | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "tag=$TAG" >> "$GITHUB_OUTPUT" | |
| echo "Resolved release version: $VERSION" | |
| echo "Resolved release tag: $TAG" | |
| preflight: | |
| name: Preflight Checks | |
| runs-on: ubuntu-latest | |
| needs: resolve-version | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Install protoc | |
| run: sudo apt-get update && sudo apt-get install -y protobuf-compiler | |
| - name: Validate version consistency | |
| run: scripts/release-preflight.sh "${{ needs.resolve-version.outputs.version }}" | |
| - name: Run tests | |
| run: cargo test --workspace | |
| - name: Run clippy | |
| run: cargo clippy --all-targets --all-features -- -D warnings | |
| publish-crates: | |
| name: Publish crates.io | |
| runs-on: ubuntu-latest | |
| needs: [resolve-version, preflight] | |
| if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Detect missing crate versions | |
| id: detect | |
| env: | |
| VERSION: ${{ needs.resolve-version.outputs.version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| crates=(hush-core hush-proxy hush-spine clawdstrike hush-cli) | |
| missing=() | |
| for crate in "${crates[@]}"; do | |
| code="$(curl -s -o /dev/null -w '%{http_code}' "https://crates.io/api/v1/crates/${crate}/${VERSION}")" | |
| if [[ "$code" == "200" ]]; then | |
| echo "${crate}@${VERSION} already exists" | |
| else | |
| echo "${crate}@${VERSION} missing" | |
| missing+=("$crate") | |
| fi | |
| done | |
| if [[ "${#missing[@]}" -eq 0 ]]; then | |
| echo "none_missing=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "none_missing=false" >> "$GITHUB_OUTPUT" | |
| echo "missing_crates=${missing[*]}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Publish missing crates in dependency order | |
| if: steps.detect.outputs.none_missing == 'false' | |
| env: | |
| CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} | |
| VERSION: ${{ needs.resolve-version.outputs.version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| publish_with_retry() { | |
| local crate="$1" | |
| local max_attempts=20 | |
| local attempt=1 | |
| local output | |
| local status | |
| while (( attempt <= max_attempts )); do | |
| echo "Publishing ${crate}@${VERSION} (attempt ${attempt}/${max_attempts})" | |
| set +e | |
| output="$(cargo publish -p "$crate" --token "$CARGO_REGISTRY_TOKEN" 2>&1)" | |
| status=$? | |
| set -e | |
| printf '%s\n' "$output" | |
| if [[ $status -eq 0 ]]; then | |
| echo "Published ${crate}@${VERSION}" | |
| return 0 | |
| fi | |
| if grep -q "already exists on crates.io index" <<<"$output"; then | |
| echo "${crate}@${VERSION} already published; skipping" | |
| return 0 | |
| fi | |
| echo "Publish attempt failed for ${crate}; waiting for index propagation" | |
| sleep 30 | |
| attempt=$((attempt + 1)) | |
| done | |
| echo "Failed to publish ${crate}@${VERSION}" | |
| exit 1 | |
| } | |
| publish_with_retry hush-core | |
| publish_with_retry hush-proxy | |
| publish_with_retry hush-spine | |
| publish_with_retry clawdstrike | |
| publish_with_retry hush-cli | |
| - name: Crates already published | |
| if: steps.detect.outputs.none_missing == 'true' | |
| run: echo "All crate versions already exist; skipping crates publish" | |
| publish-npm: | |
| name: Publish npm packages | |
| runs-on: ubuntu-latest | |
| needs: [resolve-version, preflight] | |
| if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: "24" | |
| registry-url: "https://registry.npmjs.org" | |
| - name: Detect missing npm package versions | |
| id: detect | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| packages=( | |
| packages/adapters/clawdstrike-adapter-core | |
| packages/sdk/hush-ts | |
| packages/sdk/clawdstrike | |
| packages/policy/clawdstrike-policy | |
| packages/adapters/clawdstrike-claude | |
| packages/adapters/clawdstrike-openai | |
| packages/adapters/clawdstrike-vercel-ai | |
| packages/adapters/clawdstrike-langchain | |
| packages/adapters/clawdstrike-openclaw | |
| packages/adapters/clawdstrike-opencode | |
| packages/adapters/clawdstrike-hush-cli-engine | |
| packages/adapters/clawdstrike-hushd-engine | |
| ) | |
| missing=() | |
| for pkg in "${packages[@]}"; do | |
| name="$(node -e "console.log(require('./${pkg}/package.json').name)")" | |
| version="$(node -e "console.log(require('./${pkg}/package.json').version)")" | |
| if npm view "${name}@${version}" version >/dev/null 2>&1; then | |
| echo "${name}@${version} already exists" | |
| else | |
| echo "${name}@${version} missing" | |
| missing+=("$pkg") | |
| fi | |
| done | |
| if [[ "${#missing[@]}" -eq 0 ]]; then | |
| echo "none_missing=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "none_missing=false" >> "$GITHUB_OUTPUT" | |
| echo "missing_packages=${missing[*]}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Install workspace dependencies | |
| if: steps.detect.outputs.none_missing == 'false' | |
| run: npm ci --ignore-scripts | |
| - name: Publish missing npm packages in dependency order | |
| if: steps.detect.outputs.none_missing == 'false' | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| RELEASE_VERSION: ${{ needs.resolve-version.outputs.version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| publish_workspace() { | |
| local pkg="$1" | |
| local name version | |
| name="$(node -e "console.log(require('./${pkg}/package.json').name)")" | |
| version="$(node -e "console.log(require('./${pkg}/package.json').version)")" | |
| if [[ "$version" != "$RELEASE_VERSION" ]]; then | |
| echo "Version mismatch for $name: package.json=$version release=$RELEASE_VERSION" >&2 | |
| exit 1 | |
| fi | |
| echo "Building ${name}@${version}" | |
| npm run build -w "$pkg" | |
| echo "Testing ${name}@${version}" | |
| npm test -w "$pkg" | |
| if npm view "${name}@${version}" version >/dev/null 2>&1; then | |
| echo "${name}@${version} already published; skipping publish" | |
| return 0 | |
| fi | |
| echo "Publishing ${name}@${version}" | |
| local output status | |
| set +e | |
| output="$(npm publish -w "$pkg" --access public --provenance 2>&1)" | |
| status=$? | |
| set -e | |
| printf '%s\n' "$output" | |
| if [[ $status -eq 0 ]]; then | |
| return 0 | |
| fi | |
| if grep -q "Cannot publish over previously published version" <<<"$output" \ | |
| || grep -q "You cannot publish over the previously published versions" <<<"$output"; then | |
| echo "${name}@${version} appears already published; continuing" | |
| return 0 | |
| fi | |
| return $status | |
| } | |
| publish_workspace packages/adapters/clawdstrike-adapter-core | |
| publish_workspace packages/sdk/hush-ts | |
| publish_workspace packages/sdk/clawdstrike | |
| publish_workspace packages/policy/clawdstrike-policy | |
| publish_workspace packages/adapters/clawdstrike-claude | |
| publish_workspace packages/adapters/clawdstrike-openai | |
| publish_workspace packages/adapters/clawdstrike-vercel-ai | |
| publish_workspace packages/adapters/clawdstrike-langchain | |
| publish_workspace packages/adapters/clawdstrike-openclaw | |
| publish_workspace packages/adapters/clawdstrike-opencode | |
| publish_workspace packages/adapters/clawdstrike-hush-cli-engine | |
| publish_workspace packages/adapters/clawdstrike-hushd-engine | |
| - name: npm packages already published | |
| if: steps.detect.outputs.none_missing == 'true' | |
| run: echo "All npm package versions already exist; skipping npm publish" | |
| publish-wasm-npm: | |
| name: Publish @clawdstrike/wasm | |
| runs-on: ubuntu-latest | |
| needs: [resolve-version, preflight, publish-npm] | |
| if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: "24" | |
| registry-url: "https://registry.npmjs.org" | |
| - name: Detect missing wasm npm version | |
| id: detect | |
| env: | |
| RELEASE_VERSION: ${{ needs.resolve-version.outputs.version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| name="$(node -e "console.log(require('./crates/libs/hush-wasm/package.json').name)")" | |
| version="$(node -e "console.log(require('./crates/libs/hush-wasm/package.json').version)")" | |
| echo "name=${name}" >> "$GITHUB_OUTPUT" | |
| echo "version=${version}" >> "$GITHUB_OUTPUT" | |
| if [[ "$version" != "$RELEASE_VERSION" ]]; then | |
| echo "Version mismatch for $name: package.json=$version release=$RELEASE_VERSION" >&2 | |
| exit 1 | |
| fi | |
| if npm view "${name}@${version}" version >/dev/null 2>&1; then | |
| echo "none_missing=true" >> "$GITHUB_OUTPUT" | |
| echo "${name}@${version} already exists" | |
| else | |
| echo "none_missing=false" >> "$GITHUB_OUTPUT" | |
| echo "${name}@${version} missing" | |
| fi | |
| - name: Setup Rust target | |
| if: steps.detect.outputs.none_missing == 'false' | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: wasm32-unknown-unknown | |
| - name: Install wasm-pack | |
| if: steps.detect.outputs.none_missing == 'false' | |
| run: cargo install wasm-pack --locked --version 0.14.0 | |
| - name: Build and publish wasm package | |
| if: steps.detect.outputs.none_missing == 'false' | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| cd crates/libs/hush-wasm | |
| wasm-pack build --target web --release --out-dir pkg | |
| cp package.json pkg/ | |
| cp README.npm.md pkg/README.md | |
| cp types/hush_wasm.d.ts pkg/ | |
| cd pkg | |
| npm publish --access public --provenance | |
| - name: wasm package already published | |
| if: steps.detect.outputs.none_missing == 'true' | |
| run: echo "wasm package version already exists; skipping wasm publish" | |
| publish-pypi: | |
| name: Publish PyPI package | |
| runs-on: ubuntu-latest | |
| needs: [resolve-version, preflight] | |
| if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/setup-python@v6 | |
| with: | |
| python-version: "3.12" | |
| - name: Detect missing PyPI version | |
| id: detect | |
| env: | |
| RELEASE_VERSION: ${{ needs.resolve-version.outputs.version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| pkg_name="$(python3 - <<'PY' | |
| import tomllib | |
| with open("packages/sdk/hush-py/pyproject.toml", "rb") as f: | |
| data = tomllib.load(f) | |
| print(data["project"]["name"]) | |
| PY | |
| )" | |
| pkg_version="$(python3 - <<'PY' | |
| import tomllib | |
| with open("packages/sdk/hush-py/pyproject.toml", "rb") as f: | |
| data = tomllib.load(f) | |
| print(data["project"]["version"]) | |
| PY | |
| )" | |
| if [[ "$pkg_version" != "$RELEASE_VERSION" ]]; then | |
| echo "Version mismatch for ${pkg_name}: pyproject=$pkg_version release=$RELEASE_VERSION" >&2 | |
| exit 1 | |
| fi | |
| echo "name=${pkg_name}" >> "$GITHUB_OUTPUT" | |
| echo "version=${pkg_version}" >> "$GITHUB_OUTPUT" | |
| code="$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/${pkg_name}/${pkg_version}/json")" | |
| if [[ "$code" == "200" ]]; then | |
| echo "none_missing=true" >> "$GITHUB_OUTPUT" | |
| echo "${pkg_name}==${pkg_version} already exists on PyPI" | |
| else | |
| echo "none_missing=false" >> "$GITHUB_OUTPUT" | |
| echo "${pkg_name}==${pkg_version} missing on PyPI" | |
| fi | |
| - name: Build and publish PyPI package | |
| if: steps.detect.outputs.none_missing == 'false' | |
| env: | |
| TWINE_USERNAME: __token__ | |
| TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| python -m pip install --upgrade pip | |
| python -m pip install build twine | |
| python -m build --sdist --wheel packages/sdk/hush-py | |
| twine upload packages/sdk/hush-py/dist/* | |
| - name: PyPI package already published | |
| if: steps.detect.outputs.none_missing == 'true' | |
| run: echo "PyPI version already exists; skipping PyPI publish" | |
| build-binaries: | |
| name: Build Binaries | |
| runs-on: ${{ matrix.os }} | |
| needs: preflight | |
| if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' }} | |
| strategy: | |
| matrix: | |
| include: | |
| - target: x86_64-unknown-linux-gnu | |
| os: ubuntu-latest | |
| artifact: hush-linux-x86_64 | |
| - target: x86_64-apple-darwin | |
| os: macos-latest | |
| artifact: hush-darwin-x86_64 | |
| - target: aarch64-apple-darwin | |
| os: macos-latest | |
| artifact: hush-darwin-aarch64 | |
| - target: x86_64-pc-windows-msvc | |
| os: windows-latest | |
| artifact: hush-windows-x86_64.exe | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ matrix.target }} | |
| - name: Build release binary | |
| run: cargo build --release --target ${{ matrix.target }} -p hush-cli | |
| - name: Rename binary (Unix) | |
| if: runner.os != 'Windows' | |
| run: cp target/${{ matrix.target }}/release/hush ${{ matrix.artifact }} | |
| - name: Rename binary (Windows) | |
| if: runner.os == 'Windows' | |
| run: cp target/${{ matrix.target }}/release/hush.exe ${{ matrix.artifact }} | |
| - name: Upload artifact | |
| uses: actions/upload-artifact@v6 | |
| with: | |
| name: ${{ matrix.artifact }} | |
| path: ${{ matrix.artifact }} | |
| build-hushd-binaries: | |
| name: Build hushd Binaries | |
| runs-on: ${{ matrix.os }} | |
| needs: preflight | |
| if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' }} | |
| strategy: | |
| matrix: | |
| include: | |
| - target: x86_64-unknown-linux-gnu | |
| os: ubuntu-latest | |
| artifact: hushd-linux-x86_64 | |
| - target: x86_64-apple-darwin | |
| os: macos-latest | |
| artifact: hushd-darwin-x86_64 | |
| - target: aarch64-apple-darwin | |
| os: macos-latest | |
| artifact: hushd-darwin-aarch64 | |
| - target: x86_64-pc-windows-msvc | |
| os: windows-latest | |
| artifact: hushd-windows-x86_64.exe | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ matrix.target }} | |
| - name: Build release hushd binary | |
| run: cargo build --release --target ${{ matrix.target }} -p hushd | |
| - name: Rename hushd binary (Unix) | |
| if: runner.os != 'Windows' | |
| run: cp target/${{ matrix.target }}/release/hushd ${{ matrix.artifact }} | |
| - name: Rename hushd binary (Windows) | |
| if: runner.os == 'Windows' | |
| run: cp target/${{ matrix.target }}/release/hushd.exe ${{ matrix.artifact }} | |
| - name: Upload hushd artifact | |
| uses: actions/upload-artifact@v6 | |
| with: | |
| name: ${{ matrix.artifact }} | |
| path: ${{ matrix.artifact }} | |
| build-agent-bundles: | |
| name: Build Agent macOS Bundles | |
| runs-on: ${{ matrix.os }} | |
| needs: [resolve-version, preflight] | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - os: macos-15-intel | |
| arch: x86_64 | |
| - os: macos-14 | |
| arch: arm64 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Setup Node | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: "24" | |
| cache: npm | |
| cache-dependency-path: apps/cloud-dashboard/package-lock.json | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Install Tauri CLI | |
| run: cargo install tauri-cli --version "^2.0" --locked | |
| - name: Setup Apple signing identity (optional) | |
| id: signing | |
| env: | |
| APPLE_DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_CERT_P12_BASE64 }} | |
| APPLE_DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERT_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| echo "enabled=false" >> "$GITHUB_OUTPUT" | |
| echo "identity=" >> "$GITHUB_OUTPUT" | |
| echo "team_id=" >> "$GITHUB_OUTPUT" | |
| if [[ -z "${APPLE_DEVELOPER_ID_CERT_P12_BASE64:-}" || -z "${APPLE_DEVELOPER_ID_CERT_PASSWORD:-}" || -z "${APPLE_TEAM_ID:-}" ]]; then | |
| echo "Apple signing secrets are not fully configured; building unsigned artifacts." | |
| exit 0 | |
| fi | |
| KEYCHAIN_PATH="$RUNNER_TEMP/agent-signing.keychain-db" | |
| KEYCHAIN_PASSWORD="$(openssl rand -hex 20)" | |
| CERT_PATH="$RUNNER_TEMP/agent-signing-cert.p12" | |
| python3 -c "import base64, pathlib, sys; pathlib.Path(sys.argv[2]).write_bytes(base64.b64decode(sys.argv[1], validate=True))" \ | |
| "${APPLE_DEVELOPER_ID_CERT_P12_BASE64}" \ | |
| "${CERT_PATH}" | |
| security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" | |
| security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| security import "$CERT_PATH" \ | |
| -k "$KEYCHAIN_PATH" \ | |
| -P "$APPLE_DEVELOPER_ID_CERT_PASSWORD" \ | |
| -T /usr/bin/codesign \ | |
| -T /usr/bin/security \ | |
| -T /usr/bin/productbuild | |
| security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| security list-keychain -d user -s "$KEYCHAIN_PATH" | |
| SIGNING_IDENTITY="$( | |
| security find-identity -v -p codesigning "$KEYCHAIN_PATH" \ | |
| | awk -F'"' '/Developer ID Application/{print $2; exit}' | |
| )" | |
| if [[ -z "$SIGNING_IDENTITY" ]]; then | |
| echo "Failed to resolve Developer ID Application identity from imported certificate." >&2 | |
| exit 1 | |
| fi | |
| echo "enabled=true" >> "$GITHUB_OUTPUT" | |
| echo "identity=$SIGNING_IDENTITY" >> "$GITHUB_OUTPUT" | |
| echo "team_id=$APPLE_TEAM_ID" >> "$GITHUB_OUTPUT" | |
| echo "Configured signing identity: $SIGNING_IDENTITY" | |
| - name: Build agent app + dmg | |
| env: | |
| APPLE_SIGNING_IDENTITY: ${{ steps.signing.outputs.identity }} | |
| APPLE_TEAM_ID: ${{ steps.signing.outputs.team_id }} | |
| run: | | |
| set -euo pipefail | |
| pushd apps/agent/src-tauri >/dev/null | |
| cargo tauri build --bundles app,dmg | |
| popd >/dev/null | |
| - name: Re-sign bundled hushd and refresh app/dmg signatures | |
| if: steps.signing.outputs.enabled == 'true' | |
| env: | |
| APPLE_SIGNING_IDENTITY: ${{ steps.signing.outputs.identity }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| APP_PATH="$(find apps/agent/src-tauri/target/release/bundle/macos -type d -name '*.app' | head -n1)" | |
| DMG_PATH="$(find apps/agent/src-tauri/target/release/bundle/dmg -type f -name '*.dmg' | head -n1)" | |
| if [[ -z "$APP_PATH" || -z "$DMG_PATH" ]]; then | |
| echo "Unable to locate built app/dmg artifacts for re-signing." >&2 | |
| exit 1 | |
| fi | |
| HUSHD_APP_PATH="$APP_PATH/Contents/Resources/resources/bin/hushd" | |
| if [[ ! -f "$HUSHD_APP_PATH" ]]; then | |
| echo "Bundled hushd not found at $HUSHD_APP_PATH" >&2 | |
| exit 1 | |
| fi | |
| /usr/bin/codesign --force --timestamp --options runtime --sign "$APPLE_SIGNING_IDENTITY" "$HUSHD_APP_PATH" | |
| /usr/bin/codesign --verify --verbose=2 "$HUSHD_APP_PATH" | |
| /usr/bin/codesign -dv --verbose=4 "$HUSHD_APP_PATH" >/dev/null 2>&1 || true | |
| /usr/bin/codesign --force --timestamp --options runtime --sign "$APPLE_SIGNING_IDENTITY" "$APP_PATH" | |
| /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" | |
| TMP_DMG="${DMG_PATH%.dmg}.resigned.dmg" | |
| VOL_NAME="$(basename "$APP_PATH" .app)" | |
| hdiutil create -volname "$VOL_NAME" -srcfolder "$APP_PATH" -ov -format UDZO "$TMP_DMG" | |
| mv -f "$TMP_DMG" "$DMG_PATH" | |
| - name: Notarize and staple bundles (optional) | |
| id: notarization | |
| if: steps.signing.outputs.enabled == 'true' | |
| env: | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| APPLE_NOTARY_API_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_API_KEY_P8_BASE64 }} | |
| APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} | |
| APPLE_NOTARY_ISSUER: ${{ secrets.APPLE_NOTARY_ISSUER }} | |
| NOTARY_POLL_ATTEMPTS: "240" | |
| NOTARY_POLL_SLEEP_SECONDS: "20" | |
| ALLOW_NOTARIZATION_FAILURE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| echo "enabled=false" >> "$GITHUB_OUTPUT" | |
| if [[ -z "${APPLE_NOTARY_API_KEY_P8_BASE64:-}" || -z "${APPLE_NOTARY_KEY_ID:-}" || -z "${APPLE_NOTARY_ISSUER:-}" || -z "${APPLE_TEAM_ID:-}" ]]; then | |
| echo "Notary API secrets are not fully configured; skipping notarization." | |
| exit 0 | |
| fi | |
| KEY_DIR="$RUNNER_TEMP/notary/private_keys" | |
| KEY_PATH="$KEY_DIR/AuthKey_${APPLE_NOTARY_KEY_ID}.p8" | |
| mkdir -p "$KEY_DIR" | |
| python3 -c "import base64, pathlib, sys; pathlib.Path(sys.argv[2]).write_bytes(base64.b64decode(sys.argv[1], validate=True))" \ | |
| "${APPLE_NOTARY_API_KEY_P8_BASE64}" \ | |
| "${KEY_PATH}" | |
| chmod 600 "$KEY_PATH" | |
| DMG_PATH="$(find apps/agent/src-tauri/target/release/bundle/dmg -type f -name '*.dmg' | head -n1)" | |
| APP_PATH="$(find apps/agent/src-tauri/target/release/bundle/macos -type d -name '*.app' | head -n1)" | |
| if [[ -z "$DMG_PATH" || -z "$APP_PATH" ]]; then | |
| echo "Unable to locate built app/dmg artifacts for notarization." >&2 | |
| exit 1 | |
| fi | |
| SUBMIT_JSON="$RUNNER_TEMP/notary-submit.json" | |
| if ! xcrun notarytool submit "$DMG_PATH" \ | |
| --key "$KEY_PATH" \ | |
| --key-id "$APPLE_NOTARY_KEY_ID" \ | |
| --issuer "$APPLE_NOTARY_ISSUER" \ | |
| --output-format json > "$SUBMIT_JSON"; then | |
| if [[ "${ALLOW_NOTARIZATION_FAILURE}" == "true" ]]; then | |
| echo "Notarization submit failed in dry-run mode; continuing without stapling." >&2 | |
| exit 0 | |
| fi | |
| echo "Notarization submit failed." >&2 | |
| exit 1 | |
| fi | |
| NOTARY_ID="$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get("id",""))' "$SUBMIT_JSON")" | |
| if [[ -z "$NOTARY_ID" ]]; then | |
| if [[ "${ALLOW_NOTARIZATION_FAILURE}" == "true" ]]; then | |
| echo "No notarization submission id returned in dry-run mode; continuing." >&2 | |
| exit 0 | |
| fi | |
| echo "No notarization submission id returned." >&2 | |
| exit 1 | |
| fi | |
| INFO_JSON="$RUNNER_TEMP/notary-info.json" | |
| NOTARY_STATUS="" | |
| for _attempt in $(seq 1 "${NOTARY_POLL_ATTEMPTS}"); do | |
| if python3 -c 'import pathlib, subprocess, sys; out_path, key_path, key_id, issuer, notary_id = sys.argv[1:]; cmd = ["xcrun", "notarytool", "info", notary_id, "--key", key_path, "--key-id", key_id, "--issuer", issuer, "--output-format", "json"]; result = subprocess.run(cmd, capture_output=True, text=True, timeout=45); (sys.stderr.write(result.stderr), sys.exit(result.returncode)) if result.returncode != 0 else pathlib.Path(out_path).write_text(result.stdout, encoding="utf-8")' "$INFO_JSON" "$KEY_PATH" "$APPLE_NOTARY_KEY_ID" "$APPLE_NOTARY_ISSUER" "$NOTARY_ID" | |
| then | |
| NOTARY_STATUS="$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get("status",""))' "$INFO_JSON")" | |
| echo "Notary status: ${NOTARY_STATUS:-unknown}" | |
| if [[ "$NOTARY_STATUS" == "Accepted" ]]; then | |
| break | |
| fi | |
| if [[ "$NOTARY_STATUS" == "Invalid" || "$NOTARY_STATUS" == "Rejected" ]]; then | |
| break | |
| fi | |
| else | |
| echo "Notary status request failed or timed out; retrying..." >&2 | |
| fi | |
| sleep "${NOTARY_POLL_SLEEP_SECONDS}" | |
| done | |
| if [[ "$NOTARY_STATUS" != "Accepted" ]]; then | |
| echo "Notarization status: ${NOTARY_STATUS:-unknown}" >&2 | |
| xcrun notarytool log "$NOTARY_ID" \ | |
| --key "$KEY_PATH" \ | |
| --key-id "$APPLE_NOTARY_KEY_ID" \ | |
| --issuer "$APPLE_NOTARY_ISSUER" || true | |
| if [[ "${ALLOW_NOTARIZATION_FAILURE}" == "true" ]]; then | |
| echo "Notarization was not accepted in dry-run mode; continuing without stapling." >&2 | |
| exit 0 | |
| fi | |
| exit 1 | |
| fi | |
| if ! xcrun stapler staple "$APP_PATH"; then | |
| if [[ "${ALLOW_NOTARIZATION_FAILURE}" == "true" ]]; then | |
| echo "Failed to staple app in dry-run mode; continuing." >&2 | |
| exit 0 | |
| fi | |
| exit 1 | |
| fi | |
| if ! xcrun stapler staple "$DMG_PATH"; then | |
| if [[ "${ALLOW_NOTARIZATION_FAILURE}" == "true" ]]; then | |
| echo "Failed to staple dmg in dry-run mode; continuing." >&2 | |
| exit 0 | |
| fi | |
| exit 1 | |
| fi | |
| if ! xcrun stapler validate "$APP_PATH"; then | |
| if [[ "${ALLOW_NOTARIZATION_FAILURE}" == "true" ]]; then | |
| echo "Failed to validate stapled app in dry-run mode; continuing." >&2 | |
| exit 0 | |
| fi | |
| exit 1 | |
| fi | |
| if ! xcrun stapler validate "$DMG_PATH"; then | |
| if [[ "${ALLOW_NOTARIZATION_FAILURE}" == "true" ]]; then | |
| echo "Failed to validate stapled dmg in dry-run mode; continuing." >&2 | |
| exit 0 | |
| fi | |
| exit 1 | |
| fi | |
| echo "enabled=true" >> "$GITHUB_OUTPUT" | |
| - name: Collect agent artifacts | |
| env: | |
| RELEASE_VERSION: ${{ needs.resolve-version.outputs.version }} | |
| BUILD_ARCH: ${{ matrix.arch }} | |
| SIGNING_ENABLED: ${{ steps.signing.outputs.enabled }} | |
| NOTARIZATION_ENABLED: ${{ steps.notarization.outputs.enabled || 'false' }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| BUNDLE_DIR="apps/agent/src-tauri/target/release/bundle" | |
| DMG_SOURCE="$(find "$BUNDLE_DIR/dmg" -type f -name "*.dmg" | head -n1)" | |
| APP_SOURCE="$(find "$BUNDLE_DIR/macos" -type d -name "*.app" | head -n1)" | |
| if [[ -z "$DMG_SOURCE" || -z "$APP_SOURCE" ]]; then | |
| echo "Failed to find built agent dmg/app artifacts in $BUNDLE_DIR" >&2 | |
| exit 1 | |
| fi | |
| ARTIFACT_DIR="artifacts/agent-macos-${BUILD_ARCH}" | |
| mkdir -p "$ARTIFACT_DIR" | |
| DMG_NAME="clawdstrike-agent-${RELEASE_VERSION}-macos-${BUILD_ARCH}.dmg" | |
| APP_TAR_NAME="clawdstrike-agent-${RELEASE_VERSION}-macos-${BUILD_ARCH}.app.tar.gz" | |
| META_NAME="clawdstrike-agent-${RELEASE_VERSION}-macos-${BUILD_ARCH}.metadata.txt" | |
| cp "$DMG_SOURCE" "$ARTIFACT_DIR/$DMG_NAME" | |
| tar -C "$(dirname "$APP_SOURCE")" -czf "$ARTIFACT_DIR/$APP_TAR_NAME" "$(basename "$APP_SOURCE")" | |
| printf '%s\n' \ | |
| "release_version: ${RELEASE_VERSION}" \ | |
| "arch: ${BUILD_ARCH}" \ | |
| "signed: ${SIGNING_ENABLED}" \ | |
| "notarized: ${NOTARIZATION_ENABLED}" \ | |
| "dmg_asset: ${DMG_NAME}" \ | |
| "app_archive_asset: ${APP_TAR_NAME}" \ | |
| > "$ARTIFACT_DIR/$META_NAME" | |
| ls -lh "$ARTIFACT_DIR" | |
| - name: Upload agent artifacts | |
| uses: actions/upload-artifact@v6 | |
| with: | |
| name: clawdstrike-agent-macos-${{ matrix.arch }} | |
| path: artifacts/agent-macos-${{ matrix.arch }}/* | |
| if-no-files-found: error | |
| create-release: | |
| name: Create GitHub Release | |
| runs-on: ubuntu-latest | |
| needs: [resolve-version, build-binaries, build-hushd-binaries, build-agent-bundles, publish-crates, publish-npm, publish-wasm-npm, publish-pypi] | |
| if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' }} | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - name: Ensure release tag exists | |
| env: | |
| TAG: ${{ needs.resolve-version.outputs.tag }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| git fetch --tags --force | |
| if git rev-parse "refs/tags/${TAG}" >/dev/null 2>&1; then | |
| echo "Tag ${TAG} already exists" | |
| exit 0 | |
| fi | |
| if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then | |
| echo "Expected existing tag ${TAG} for non-dispatch release" >&2 | |
| exit 1 | |
| fi | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git tag -a "${TAG}" "${GITHUB_SHA}" -m "Release ${TAG}" | |
| git push origin "refs/tags/${TAG}" | |
| echo "Created and pushed missing tag ${TAG}" | |
| - name: Download all artifacts | |
| uses: actions/download-artifact@v7 | |
| with: | |
| path: artifacts | |
| - name: Create checksums | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| cd artifacts | |
| for dir in */; do | |
| cd "$dir" | |
| for file in *; do | |
| sha256sum "$file" > "$file.sha256" | |
| done | |
| cd .. | |
| done | |
| - name: Flatten artifacts | |
| run: | | |
| mkdir -p release-files | |
| find artifacts -type f -exec mv {} release-files/ \; | |
| - name: Generate and sign hushd OTA manifests | |
| shell: bash | |
| env: | |
| RELEASE_VERSION: ${{ needs.resolve-version.outputs.version }} | |
| RELEASE_TAG: ${{ needs.resolve-version.outputs.tag }} | |
| HUSHD_OTA_SIGNING_PRIVATE_KEY_PEM: ${{ secrets.HUSHD_OTA_SIGNING_PRIVATE_KEY_PEM }} | |
| HUSHD_OTA_SIGNING_PUBLIC_KEY_HEX: ${{ secrets.HUSHD_OTA_SIGNING_PUBLIC_KEY_HEX }} | |
| run: | | |
| set -euo pipefail | |
| if [[ -z "${HUSHD_OTA_SIGNING_PRIVATE_KEY_PEM:-}" ]]; then | |
| echo "Missing required secret: HUSHD_OTA_SIGNING_PRIVATE_KEY_PEM" >&2 | |
| exit 1 | |
| fi | |
| pub_args=() | |
| if [[ -n "${HUSHD_OTA_SIGNING_PUBLIC_KEY_HEX:-}" ]]; then | |
| pub_args=(--public-key "${HUSHD_OTA_SIGNING_PUBLIC_KEY_HEX}") | |
| fi | |
| scripts/generate-hushd-ota-manifest.sh \ | |
| --version "${RELEASE_VERSION}" \ | |
| --tag "${RELEASE_TAG}" \ | |
| --channel stable \ | |
| --assets-dir release-files \ | |
| --output release-files/hushd-ota-manifest-stable.unsigned.json \ | |
| "${pub_args[@]}" | |
| scripts/sign-hushd-ota-manifest.sh \ | |
| --input release-files/hushd-ota-manifest-stable.unsigned.json \ | |
| --output release-files/hushd-ota-manifest-stable.json \ | |
| "${pub_args[@]}" | |
| rm -f release-files/hushd-ota-manifest-stable.unsigned.json | |
| scripts/generate-hushd-ota-manifest.sh \ | |
| --version "${RELEASE_VERSION}" \ | |
| --tag "${RELEASE_TAG}" \ | |
| --channel beta \ | |
| --assets-dir release-files \ | |
| --output release-files/hushd-ota-manifest-beta.unsigned.json \ | |
| "${pub_args[@]}" | |
| scripts/sign-hushd-ota-manifest.sh \ | |
| --input release-files/hushd-ota-manifest-beta.unsigned.json \ | |
| --output release-files/hushd-ota-manifest-beta.json \ | |
| "${pub_args[@]}" | |
| rm -f release-files/hushd-ota-manifest-beta.unsigned.json | |
| - name: Create or update GitHub Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ needs.resolve-version.outputs.tag }} | |
| generate_release_notes: true | |
| files: release-files/* | |
| overwrite_files: true | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| publish-homebrew-agent-cask: | |
| name: Publish Homebrew Agent Cask (Optional) | |
| runs-on: ubuntu-latest | |
| needs: [resolve-version, create-release] | |
| if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' }} | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Check Homebrew tap configuration | |
| id: tap | |
| env: | |
| HOMEBREW_TAP_REPO: ${{ secrets.HOMEBREW_TAP_REPO }} | |
| HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| echo "enabled=false" >> "$GITHUB_OUTPUT" | |
| if [[ -z "${HOMEBREW_TAP_REPO:-}" || -z "${HOMEBREW_TAP_GITHUB_TOKEN:-}" ]]; then | |
| echo "Homebrew cask publish skipped (missing HOMEBREW_TAP_REPO or HOMEBREW_TAP_GITHUB_TOKEN secret)." | |
| exit 0 | |
| fi | |
| echo "enabled=true" >> "$GITHUB_OUTPUT" | |
| - name: Download macOS agent DMG release assets | |
| if: steps.tap.outputs.enabled == 'true' | |
| env: | |
| RELEASE_TAG: ${{ needs.resolve-version.outputs.tag }} | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mkdir -p dist | |
| gh release download "$RELEASE_TAG" \ | |
| --repo "$GITHUB_REPOSITORY" \ | |
| --pattern "clawdstrike-agent-*-macos-*.dmg" \ | |
| --dir dist | |
| ls -lh dist | |
| - name: Render Homebrew cask | |
| if: steps.tap.outputs.enabled == 'true' | |
| id: cask | |
| env: | |
| RELEASE_VERSION: ${{ needs.resolve-version.outputs.version }} | |
| GITHUB_REPO: ${{ github.repository }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| INTEL_DMG="$(find dist -maxdepth 1 -type f -name "clawdstrike-agent-${RELEASE_VERSION}-macos-x86_64.dmg" | head -n1)" | |
| ARM_DMG="$(find dist -maxdepth 1 -type f -name "clawdstrike-agent-${RELEASE_VERSION}-macos-arm64.dmg" | head -n1)" | |
| if [[ -z "$INTEL_DMG" || -z "$ARM_DMG" ]]; then | |
| echo "Expected both Intel and ARM DMGs for release ${RELEASE_VERSION}" >&2 | |
| exit 1 | |
| fi | |
| INTEL_SHA="$(sha256sum "$INTEL_DMG" | awk '{print $1}')" | |
| ARM_SHA="$(sha256sum "$ARM_DMG" | awk '{print $1}')" | |
| CASK_OUT="$RUNNER_TEMP/clawdstrike-agent.rb" | |
| scripts/render-homebrew-cask-agent.sh \ | |
| --version "$RELEASE_VERSION" \ | |
| --intel-sha256 "$INTEL_SHA" \ | |
| --arm-sha256 "$ARM_SHA" \ | |
| --github-repo "$GITHUB_REPO" \ | |
| --output "$CASK_OUT" | |
| echo "cask_path=$CASK_OUT" >> "$GITHUB_OUTPUT" | |
| cat "$CASK_OUT" | |
| - name: Publish cask to Homebrew tap | |
| if: steps.tap.outputs.enabled == 'true' | |
| env: | |
| HOMEBREW_TAP_REPO: ${{ secrets.HOMEBREW_TAP_REPO }} | |
| HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} | |
| RELEASE_VERSION: ${{ needs.resolve-version.outputs.version }} | |
| CASK_PATH: ${{ steps.cask.outputs.cask_path }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| TAP_DIR="$RUNNER_TEMP/homebrew-tap" | |
| git clone "https://x-access-token:${HOMEBREW_TAP_GITHUB_TOKEN}@github.com/${HOMEBREW_TAP_REPO}.git" "$TAP_DIR" | |
| mkdir -p "$TAP_DIR/Casks" | |
| cp "$CASK_PATH" "$TAP_DIR/Casks/clawdstrike-agent.rb" | |
| pushd "$TAP_DIR" >/dev/null | |
| if git diff --quiet -- Casks/clawdstrike-agent.rb; then | |
| echo "Homebrew cask already up to date." | |
| exit 0 | |
| fi | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git add Casks/clawdstrike-agent.rb | |
| git commit -m "chore(cask): clawdstrike-agent v${RELEASE_VERSION}" | |
| git push origin HEAD | |
| popd >/dev/null |