Release #240
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 | |
| 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: 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 | |
| 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] | |
| 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-code | |
| packages/adapters/clawdstrike-codex | |
| 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 be republished until 24 hours have passed" <<<"$output" \ | |
| || 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-code | |
| publish_workspace packages/adapters/clawdstrike-codex | |
| 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] | |
| 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] | |
| 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 | |
| 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 }} | |
| create-release: | |
| name: Create GitHub Release | |
| runs-on: ubuntu-latest | |
| needs: [resolve-version, build-binaries, 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@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: 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 }} |