Skip to content

Release

Release #250

Workflow file for this run

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