Skip to content

Release

Release #268

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
permissions:
contents: read
id-token: write
env:
CARGO_TERM_COLOR: always
CIBUILDWHEEL_VERSION: "2.23.3"
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]
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
# Keep this list to crates that verify against published registry dependencies.
# clawdstrike/hunt/hush-cli currently rely on vendored nono+hushspec APIs that have
# not been published to crates.io in matching form, so they are intentionally excluded
# from the automated crates.io release lane until the upstream registry surface catches up.
crates=(hush-core hush-proxy hush-spine)
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
if grep -Eq "no matching package named|failed to get `.*` as a dependency|download of config\\.json failed|failed to download from|service unavailable|timed out|timeout" <<<"$output"; then
echo "Publish attempt failed for ${crate} with a retryable crates.io/index error; waiting for propagation"
sleep 30
attempt=$((attempt + 1))
continue
fi
echo "Publish attempt failed for ${crate} with a non-retryable error"
exit $status
done
echo "Failed to publish ${crate}@${VERSION}"
exit 1
}
publish_with_retry hush-core
publish_with_retry hush-proxy
publish_with_retry hush-spine
- 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]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Discover public npm workspaces
id: discover
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const { appendFileSync, readFileSync } = require("fs");
const path = require("path");
const rootPackage = JSON.parse(readFileSync("package.json", "utf8"));
const workspacePaths = Array.isArray(rootPackage.workspaces) ? rootPackage.workspaces : [];
const manifests = [];
for (const workspace of workspacePaths) {
if (workspace === "crates/libs/hush-wasm") {
continue;
}
const manifestPath = path.join(workspace, "package.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
const publishAccess = manifest.publishConfig?.access;
if (manifest.private === true || publishAccess !== "public") {
continue;
}
manifests.push({
workspace,
name: manifest.name,
dependencies: Object.keys(manifest.dependencies || {}).concat(
Object.keys(manifest.optionalDependencies || {})
),
});
}
const byName = new Map(manifests.map((manifest) => [manifest.name, manifest]));
const indegree = new Map(manifests.map((manifest) => [manifest.workspace, 0]));
const edges = new Map(manifests.map((manifest) => [manifest.workspace, []]));
for (const manifest of manifests) {
const internalDeps = [...new Set(manifest.dependencies.filter((dep) => byName.has(dep)))];
for (const dependencyName of internalDeps) {
const dependency = byName.get(dependencyName);
edges.get(dependency.workspace).push(manifest.workspace);
indegree.set(manifest.workspace, indegree.get(manifest.workspace) + 1);
}
}
const queue = manifests
.map((manifest) => manifest.workspace)
.filter((workspace) => indegree.get(workspace) === 0)
.sort();
const ordered = [];
while (queue.length > 0) {
const workspace = queue.shift();
ordered.push(workspace);
for (const dependent of edges.get(workspace).sort()) {
indegree.set(dependent, indegree.get(dependent) - 1);
if (indegree.get(dependent) === 0) {
queue.push(dependent);
}
}
queue.sort();
}
if (ordered.length !== manifests.length) {
throw new Error("failed to topologically order npm release workspaces");
}
appendFileSync(process.env.GITHUB_OUTPUT, `packages=${JSON.stringify(ordered)}\n`);
NODE
- name: Detect missing npm package versions
id: detect
env:
PACKAGE_PATHS_JSON: ${{ steps.discover.outputs.packages }}
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const { appendFileSync, readFileSync } = require("fs");
const { execFileSync } = require("child_process");
const packages = JSON.parse(process.env.PACKAGE_PATHS_JSON || "[]");
const missing = [];
for (const pkg of packages) {
const manifest = JSON.parse(readFileSync(`./${pkg}/package.json`, "utf8"));
const name = manifest.name;
const version = manifest.version;
try {
execFileSync("npm", ["view", `${name}@${version}`, "version"], {
stdio: ["ignore", "ignore", "ignore"],
});
console.log(`${name}@${version} already exists`);
} catch (_error) {
console.log(`${name}@${version} missing`);
missing.push(pkg);
}
}
appendFileSync(process.env.GITHUB_OUTPUT, `none_missing=${missing.length === 0}\n`);
appendFileSync(process.env.GITHUB_OUTPUT, `missing_packages=${JSON.stringify(missing)}\n`);
NODE
- 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 }}
MISSING_PACKAGES_JSON: ${{ steps.detect.outputs.missing_packages }}
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
}
node <<'NODE' > /tmp/release-npm-packages.txt
const packages = JSON.parse(process.env.MISSING_PACKAGES_JSON || "[]");
for (const pkg of packages) {
console.log(pkg);
}
NODE
while IFS= read -r pkg; do
[[ -n "$pkg" ]] || continue
publish_workspace "$pkg"
done < /tmp/release-npm-packages.txt
- 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]
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"
pypi-detect:
name: Detect PyPI package status
runs-on: ubuntu-latest
needs: [resolve-version, preflight]
outputs:
name: ${{ steps.detect.outputs.name }}
version: ${{ steps.detect.outputs.version }}
none_missing: ${{ steps.detect.outputs.none_missing }}
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
python3 - <<'PY'
import tomllib
from pathlib import Path
release_version = "${{ needs.resolve-version.outputs.version }}"
pure = tomllib.loads(Path("packages/sdk/hush-py/pyproject.toml").read_text(encoding="utf-8"))
native = tomllib.loads(Path("packages/sdk/hush-py/hush-native/pyproject.toml").read_text(encoding="utf-8"))
pure_name = pure["project"]["name"]
pure_version = pure["project"]["version"]
native_name = native["project"]["name"]
native_version = native["project"]["version"]
if pure_name != "clawdstrike":
raise SystemExit(f"Unexpected Python package name in pure pyproject: {pure_name}")
if native_name != "clawdstrike":
raise SystemExit(f"Unexpected Python package name in native pyproject: {native_name}")
if pure_version != release_version:
raise SystemExit(
f"Version mismatch for pure pyproject: pyproject={pure_version} release={release_version}"
)
if native_version != release_version:
raise SystemExit(
f"Version mismatch for native pyproject: pyproject={native_version} release={release_version}"
)
PY
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
)"
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
build-pypi-native-wheels:
name: Build native PyPI wheels (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: [resolve-version, preflight, pypi-detect]
if: needs.pypi-detect.outputs.none_missing == 'false'
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
cibw_archs_linux: "x86_64 aarch64"
cibw_archs_macos: ""
cibw_archs_windows: ""
artifact: pypi-native-wheels-linux
- os: macos-latest
cibw_archs_linux: ""
cibw_archs_macos: "x86_64 arm64"
cibw_archs_windows: ""
artifact: pypi-native-wheels-macos
- os: windows-latest
cibw_archs_linux: ""
cibw_archs_macos: ""
cibw_archs_windows: "AMD64"
artifact: pypi-native-wheels-windows
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Add Rust cross-compilation targets (macOS)
if: runner.os == 'macOS'
run: rustup target add x86_64-apple-darwin aarch64-apple-darwin
- name: Setup QEMU (linux aarch64 wheels)
if: runner.os == 'Linux'
uses: docker/setup-qemu-action@v3
- name: Sync Python sources for native wheel build
shell: bash
run: scripts/sync-hush-py-native-sources.sh
- name: Install cibuildwheel
shell: bash
run: |
set -euo pipefail
python -m pip install --upgrade pip
python -m pip install "cibuildwheel==${CIBUILDWHEEL_VERSION}"
- name: Prime cibuildwheel virtualenv cache
env:
CIBW_CACHE_PATH: ${{ runner.temp }}/cibuildwheel-cache
shell: bash
run: |
set -euo pipefail
python - <<'PY'
import importlib.resources
import os
import time
import tomllib
import urllib.request
from pathlib import Path
config_path = importlib.resources.files("cibuildwheel.resources").joinpath("virtualenv.toml")
configuration = tomllib.loads(config_path.read_text(encoding="utf-8"))["default"]
version = str(configuration["version"])
primary_url = str(configuration["url"])
raw_url = (
primary_url.replace("https://github.com/", "https://raw.githubusercontent.com/")
.replace("/blob/", "/")
.replace("?raw=true", "")
)
fallback_urls = [
raw_url,
primary_url,
f"https://cdn.jsdelivr.net/gh/pypa/get-virtualenv@{version}/public/virtualenv.pyz",
]
cache_path = Path(os.environ["CIBW_CACHE_PATH"])
target = cache_path / f"virtualenv-{version}.pyz"
cache_path.mkdir(parents=True, exist_ok=True)
if target.exists():
print(f"Using cached virtualenv bootstrap: {target}")
raise SystemExit(0)
last_error = None
for attempt in range(1, 6):
for url in fallback_urls:
try:
print(f"Downloading {url} (attempt {attempt}/5)")
with urllib.request.urlopen(url, timeout=60) as response:
payload = response.read()
if len(payload) < 100_000:
raise RuntimeError(f"downloaded payload too small: {len(payload)} bytes")
target.write_bytes(payload)
print(f"Cached virtualenv bootstrap at {target}")
raise SystemExit(0)
except Exception as exc: # pragma: no cover - exercised in CI
last_error = exc
print(f"Download failed from {url}: {exc}")
time.sleep(min(attempt * 15, 60))
raise SystemExit(f"failed to cache cibuildwheel virtualenv bootstrap: {last_error}")
PY
- name: Build native wheels
env:
CIBW_BUILD: "cp310-*"
CIBW_SKIP: "*-musllinux_* *-manylinux_i686 *-win32"
CIBW_ARCHS_LINUX: ${{ matrix.cibw_archs_linux }}
CIBW_ARCHS_MACOS: ${{ matrix.cibw_archs_macos }}
CIBW_ARCHS_WINDOWS: ${{ matrix.cibw_archs_windows }}
CIBW_BEFORE_ALL_LINUX: "curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal && . $HOME/.cargo/env"
CIBW_CACHE_PATH: ${{ runner.temp }}/cibuildwheel-cache
MACOSX_DEPLOYMENT_TARGET: "10.12"
shell: bash
run: |
set -euo pipefail
python -m cibuildwheel packages/sdk/hush-py/hush-native --output-dir wheelhouse
ls -la wheelhouse
- name: Upload native wheel artifacts
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.artifact }}
path: wheelhouse/*.whl
if-no-files-found: error
publish-pypi:
name: Publish PyPI package
runs-on: ubuntu-latest
needs: [resolve-version, preflight, pypi-detect, build-pypi-native-wheels]
if: >
always() &&
needs.resolve-version.result == 'success' &&
needs.preflight.result == 'success' &&
needs.pypi-detect.result == 'success'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Ensure native wheel matrix succeeded
if: needs.pypi-detect.outputs.none_missing == 'false'
shell: bash
run: |
set -euo pipefail
if [[ "${{ needs.build-pypi-native-wheels.result }}" != "success" ]]; then
echo "Native wheel build matrix did not complete successfully" >&2
exit 1
fi
- name: Build pure Python sdist and universal wheel
if: needs.pypi-detect.outputs.none_missing == 'false'
shell: bash
run: |
set -euo pipefail
python -m pip install --upgrade pip
python -m pip install build
python -m build --sdist --wheel packages/sdk/hush-py
- name: Download native wheel artifacts
if: needs.pypi-detect.outputs.none_missing == 'false'
uses: actions/download-artifact@v6
with:
pattern: pypi-native-wheels-*
path: packages/sdk/hush-py/dist
merge-multiple: true
- name: Verify wheel set completeness
if: needs.pypi-detect.outputs.none_missing == 'false'
shell: bash
run: |
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
dist = Path("packages/sdk/hush-py/dist")
files = [p.name for p in dist.glob("clawdstrike-*.whl")]
if not files:
raise SystemExit("No clawdstrike wheels found in dist/")
checks = {
"pure py3-none-any wheel": any("py3-none-any.whl" in f for f in files),
"linux native wheel": any("manylinux" in f for f in files),
"macOS native wheel": any("macosx" in f for f in files),
"windows x86_64 native wheel": any("win_amd64" in f for f in files),
}
missing = [name for name, ok in checks.items() if not ok]
if missing:
raise SystemExit(f"Missing expected wheel artifacts: {', '.join(missing)}")
if not any(p.suffix == ".gz" and p.name.endswith(".tar.gz") for p in dist.glob("clawdstrike-*.tar.gz")):
raise SystemExit("Missing clawdstrike source distribution (.tar.gz)")
PY
- name: Upload PyPI package
if: needs.pypi-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 twine
twine upload packages/sdk/hush-py/dist/*
- name: PyPI package already published
if: needs.pypi-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
strategy:
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
artifact: hush-linux-x86_64
archive: clawdstrike-linux-x86_64
- target: x86_64-apple-darwin
os: macos-latest
artifact: hush-darwin-x86_64
archive: clawdstrike-darwin-x86_64
- target: aarch64-apple-darwin
os: macos-latest
artifact: hush-darwin-aarch64
archive: clawdstrike-darwin-aarch64
- target: x86_64-pc-windows-msvc
os: windows-latest
artifact: hush-windows-x86_64.exe
archive: ""
steps:
- uses: actions/checkout@v6
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Build release binaries
run: |
cargo build --release --target ${{ matrix.target }} -p hush-cli
cargo build --release --target ${{ matrix.target }} -p hushd
- 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 }}
- name: Create release archive (Unix)
if: runner.os != 'Windows'
shell: bash
run: |
mkdir -p _archive
cp target/${{ matrix.target }}/release/hush _archive/
cp target/${{ matrix.target }}/release/clawdstrike _archive/
cp target/${{ matrix.target }}/release/hushd _archive/
tar -czf ${{ matrix.archive }}.tar.gz -C _archive .
- name: Upload release archive
if: runner.os != 'Windows'
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.archive }}.tar.gz
path: ${{ matrix.archive }}.tar.gz
build-hushd-binaries:
name: Build hushd Binaries
runs-on: ${{ matrix.os }}
needs: preflight
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-dmg:
name: Build Agent DMG
runs-on: macos-latest
needs: preflight
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "24"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install tauri-cli
run: cargo install tauri-cli --locked --version '^2'
- name: Build agent DMG bundle
working-directory: apps/agent
run: cargo tauri build --bundles dmg
- name: Upload agent DMG artifact
uses: actions/upload-artifact@v6
with:
name: clawdstrike-agent-dmg
path: apps/agent/src-tauri/target/release/bundle/dmg/*.dmg
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-dmg, publish-crates, publish-npm, publish-wasm-npm, publish-pypi]
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@v6
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 }}
update-homebrew:
name: Update Homebrew Tap
runs-on: ubuntu-latest
needs: [resolve-version, create-release]
steps:
- name: Download release archives
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
TAG: ${{ needs.resolve-version.outputs.tag }}
run: |
mkdir -p dl
gh release download "$TAG" \
--repo backbay-labs/clawdstrike \
--pattern "clawdstrike-*.tar.gz" \
--dir dl
- name: Compute SHA256 checksums
id: sha
run: |
echo "darwin_arm64=$(sha256sum dl/clawdstrike-darwin-aarch64.tar.gz | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "darwin_x86_64=$(sha256sum dl/clawdstrike-darwin-x86_64.tar.gz | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "linux_x86_64=$(sha256sum dl/clawdstrike-linux-x86_64.tar.gz | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- name: Update homebrew-tap formula
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
VERSION: ${{ needs.resolve-version.outputs.version }}
SHA_DARWIN_ARM64: ${{ steps.sha.outputs.darwin_arm64 }}
SHA_DARWIN_X86_64: ${{ steps.sha.outputs.darwin_x86_64 }}
SHA_LINUX_X86_64: ${{ steps.sha.outputs.linux_x86_64 }}
run: |
set -euo pipefail
git clone "https://x-access-token:${GH_TOKEN}@github.com/backbay-labs/homebrew-tap.git" tap
mkdir -p tap/Formula
cat > tap/Formula/clawdstrike.rb << 'RUBY'
class Clawdstrike < Formula
desc "Runtime security enforcement for AI agents"
homepage "https://github.com/backbay-labs/clawdstrike"
version "__VERSION__"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/backbay-labs/clawdstrike/releases/download/v#{version}/clawdstrike-darwin-aarch64.tar.gz"
sha256 "__SHA_DARWIN_ARM64__"
else
url "https://github.com/backbay-labs/clawdstrike/releases/download/v#{version}/clawdstrike-darwin-x86_64.tar.gz"
sha256 "__SHA_DARWIN_X86_64__"
end
end
on_linux do
url "https://github.com/backbay-labs/clawdstrike/releases/download/v#{version}/clawdstrike-linux-x86_64.tar.gz"
sha256 "__SHA_LINUX_X86_64__"
end
def install
bin.install "hush"
bin.install "clawdstrike"
bin.install "hushd"
end
test do
assert_match version.to_s, shell_output("#{bin}/hush --version")
assert_match "hushd", shell_output("#{bin}/hushd --version")
end
end
RUBY
sed -i "s/__VERSION__/${VERSION}/" tap/Formula/clawdstrike.rb
sed -i "s/__SHA_DARWIN_ARM64__/${SHA_DARWIN_ARM64}/" tap/Formula/clawdstrike.rb
sed -i "s/__SHA_DARWIN_X86_64__/${SHA_DARWIN_X86_64}/" tap/Formula/clawdstrike.rb
sed -i "s/__SHA_LINUX_X86_64__/${SHA_LINUX_X86_64}/" tap/Formula/clawdstrike.rb
cd tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/clawdstrike.rb
git diff --cached --quiet && echo "Formula unchanged; skipping" && exit 0
git commit -m "clawdstrike ${VERSION}"
git push