Release #268
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 | |
| 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 |