safe-chains 0.230.5

Auto-allow safe bash commands in agentic coding tools
Documentation
name: Release

on:
  push:
    branches: [main]

env:
  CARGO_TERM_COLOR: always

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

permissions:
  contents: read

jobs:
  check-version:
    name: Check for version bump
    runs-on: ubuntu-latest
    timeout-minutes: 10
    outputs:
      should_release: ${{ steps.check.outputs.should_release }}
      version: ${{ steps.check.outputs.version }}
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
          fetch-tags: true

      - name: Check if version tag exists
        id: check
        run: |
          VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
          if git tag -l "v$VERSION" | grep -q "v$VERSION"; then
            echo "Tag v$VERSION already exists, skipping release"
            echo "should_release=false" >> "$GITHUB_OUTPUT"
          else
            echo "Tag v$VERSION does not exist, proceeding with release"
            echo "should_release=true" >> "$GITHUB_OUTPUT"
          fi

  ci:
    name: CI checks
    needs: check-version
    if: needs.check-version.outputs.should_release == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v7

      # The version and components come from rust-toolchain.toml.
      - name: Install the pinned toolchain
        run: rustup toolchain install

      - uses: Swatinem/rust-cache@v2

      - name: cargo fmt
        run: cargo fmt --all --check

      - name: cargo check
        run: cargo check --locked

      - name: cargo test
        run: cargo test --locked

      - name: cargo clippy
        run: cargo clippy --locked --all-targets -- -D warnings

      - uses: taiki-e/install-action@cargo-deny

      - name: cargo deny check
        run: cargo deny check

      - name: Verify COMMANDS.md is up to date
        run: |
          cargo run --locked -- --list-commands > COMMANDS.md.check
          diff -u COMMANDS.md COMMANDS.md.check || {
            echo "::error::COMMANDS.md is out of date. Run ./generate-docs.sh and commit."
            exit 1
          }

      - name: Generate distribution assets
        run: cargo run --locked --example generate_assets

      - name: Upload distribution assets
        uses: actions/upload-artifact@v7
        with:
          name: dist-assets
          path: target/assets/
          retention-days: 1

  build:
    name: Build ${{ matrix.target }}
    needs: ci
    runs-on: ${{ matrix.runner }}
    timeout-minutes: 60
    strategy:
      matrix:
        include:
          - target: x86_64-apple-darwin
            runner: macos-26
            archive: safe-chains-x86_64-apple-darwin.tar.gz
            is_macos: true
          - target: aarch64-apple-darwin
            runner: macos-26
            archive: safe-chains-aarch64-apple-darwin.tar.gz
            is_macos: true
          - target: x86_64-unknown-linux-gnu
            runner: ubuntu-latest
            archive: safe-chains-x86_64-unknown-linux-gnu.tar.gz
          - target: aarch64-unknown-linux-gnu
            runner: ubuntu-latest
            archive: safe-chains-aarch64-unknown-linux-gnu.tar.gz
          - target: x86_64-pc-windows-msvc
            runner: windows-latest
            archive: safe-chains-x86_64-pc-windows-msvc.zip
    steps:
      - uses: actions/checkout@v7

      # The version and components come from rust-toolchain.toml.
      - name: Install the pinned toolchain
        run: |
          rustup toolchain install
          rustup target add ${{ matrix.target }}

      - name: Install cross
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        uses: taiki-e/install-action@cross

      - name: Build binary
        shell: bash
        run: |
          if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then
            cross build --release --locked --target ${{ matrix.target }}
          else
            cargo build --release --locked --target ${{ matrix.target }}
          fi

      - name: Import Apple certificate
        if: matrix.is_macos && env.APPLE_CERT != ''
        env:
          APPLE_CERT: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
          APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
        run: |
          KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
          KEYCHAIN_PASSWORD="$(openssl rand -hex 16)"

          echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
          echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV"

          echo "$APPLE_CERT" | base64 --decode > "$RUNNER_TEMP/certificate.p12"

          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 "$RUNNER_TEMP/certificate.p12" -P "$APPLE_CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
          security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
          security list-keychain -d user -s "$KEYCHAIN_PATH"

          rm "$RUNNER_TEMP/certificate.p12"

      - name: Sign binary
        if: matrix.is_macos && env.APPLE_CERT != ''
        env:
          APPLE_CERT: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
        run: |
          BINARY="target/${{ matrix.target }}/release/safe-chains"
          IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | head -1 | sed 's/.*"\(.*\)".*/\1/')
          echo "Signing with identity: $IDENTITY"
          codesign --force --options=runtime --sign "$IDENTITY" --timestamp "$BINARY"
          codesign -v --verify --deep --strict "$BINARY"

      - name: Notarize binary
        if: matrix.is_macos && env.APPLE_CERT != ''
        env:
          APPLE_CERT: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
        run: |
          BINARY="target/${{ matrix.target }}/release/safe-chains"
          /usr/bin/ditto -c -k "$BINARY" "$RUNNER_TEMP/safe-chains.zip"
          xcrun notarytool submit "$RUNNER_TEMP/safe-chains.zip" \
            --apple-id "$APPLE_ID" \
            --password "$APPLE_ID_PASSWORD" \
            --team-id "$APPLE_TEAM_ID" \
            --wait
          rm "$RUNNER_TEMP/safe-chains.zip"

      - name: Download distribution assets
        uses: actions/download-artifact@v8
        with:
          name: dist-assets
          path: dist-assets

      - name: Package binary (unix)
        if: runner.os != 'Windows'
        run: |
          mkdir -p staging/completions
          cp target/${{ matrix.target }}/release/safe-chains staging/
          cp dist-assets/safe-chains.1 staging/
          cp dist-assets/completions/* staging/completions/
          cp README.md LICENSE-MIT LICENSE-APACHE opencode-plugin.js staging/
          cd staging
          tar czf ../${{ matrix.archive }} safe-chains safe-chains.1 completions/ opencode-plugin.js README.md LICENSE-MIT LICENSE-APACHE

      - name: Package binary (windows)
        if: runner.os == 'Windows'
        shell: pwsh
        run: |
          Compress-Archive -Path "target/${{ matrix.target }}/release/safe-chains.exe" -DestinationPath "${{ matrix.archive }}"

      - name: Upload artifact
        uses: actions/upload-artifact@v7
        with:
          name: ${{ matrix.archive }}
          path: ${{ matrix.archive }}
          retention-days: 1

      - name: Cleanup keychain
        if: always() && matrix.is_macos && env.KEYCHAIN_PATH != ''
        run: security delete-keychain "$KEYCHAIN_PATH"

  release:
    name: Create release and publish
    needs: [check-version, build]
    runs-on: ubuntu-latest
    timeout-minutes: 30
    # Pushes the version tag and creates the GitHub release.
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0

      # The version and components come from rust-toolchain.toml.
      - name: Install the pinned toolchain
        run: rustup toolchain install

      - name: Publish to crates.io
        run: cargo publish || echo "::warning::Publish failed (version may already exist)"
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

      - name: Download all artifacts
        uses: actions/download-artifact@v8
        with:
          path: artifacts
          merge-multiple: true

      - name: Create git tag
        run: |
          git tag "v${{ needs.check-version.outputs.version }}"
          git push origin "v${{ needs.check-version.outputs.version }}"

      - uses: taiki-e/install-action@git-cliff

      - name: Generate release notes
        run: git cliff --latest --strip all > RELEASE_NOTES.md

      - name: Generate checksums
        run: |
          cd artifacts
          sha256sum *.tar.gz *.zip > SHA256SUMS.txt
          cat SHA256SUMS.txt

      - name: Create GitHub release
        uses: softprops/action-gh-release@v3
        with:
          tag_name: v${{ needs.check-version.outputs.version }}
          name: v${{ needs.check-version.outputs.version }}
          body_path: RELEASE_NOTES.md
          files: |
            artifacts/*.tar.gz
            artifacts/*.zip
            artifacts/SHA256SUMS.txt

      - name: Trigger Homebrew tap update
        if: env.TAP_TOKEN != ''
        env:
          TAP_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
        uses: peter-evans/repository-dispatch@v4
        with:
          token: ${{ secrets.TAP_GITHUB_TOKEN }}
          repository: michaeldhopkins/homebrew-tap
          event-type: update-formula
          client-payload: '{"formula": "safe-chains", "repo": "michaeldhopkins/safe-chains", "version": "${{ needs.check-version.outputs.version }}"}'

      - name: Trigger michaeldhopkins.com release index update
        if: env.SITE_TOKEN != ''
        env:
          SITE_TOKEN: ${{ secrets.SITE_GITHUB_TOKEN }}
        uses: peter-evans/repository-dispatch@v4
        with:
          token: ${{ secrets.SITE_GITHUB_TOKEN }}
          repository: michaeldhopkins/michaeldhopkins.com
          event-type: release-published
          client-payload: '{"repo": "michaeldhopkins/safe-chains", "version": "${{ needs.check-version.outputs.version }}"}'

  publish-docs:
    name: Publish docs to michaeldhopkins.com
    needs: [check-version, release]
    # Flip the SITE_DOCS_ENABLED variable on once SITE_GITHUB_TOKEN is set;
    # secrets are not readable from a job-level `if`, so a variable gates it.
    if: vars.SITE_DOCS_ENABLED == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v7

      # generate-docs.sh builds the binary and runs --generate-book before
      # mdbook, so the docs build needs the full toolchain.
      # The version and components come from rust-toolchain.toml.
      - name: Install the pinned toolchain
        run: rustup toolchain install
      - uses: Swatinem/rust-cache@v2
      - uses: taiki-e/install-action@mdbook

      - name: Check out michaeldhopkins.com
        uses: actions/checkout@v7
        with:
          repository: michaeldhopkins/michaeldhopkins.com
          token: ${{ secrets.SITE_GITHUB_TOKEN }}
          path: site
          ref: main
          # Needed to rebase if the site's release-index workflow pushes mid-run.
          fetch-depth: 0

      - name: Build docs into the site checkout
        env:
          SITE_DIR: ${{ github.workspace }}/site/public/docs/safe-chains
        run: ./generate-docs.sh

      - name: Verify the book was produced
        # generate-docs.sh only warns when mdbook is missing, and then never
        # rsyncs. Checking the site path would pass anyway on the docs already
        # committed there, so assert against this run's build output instead —
        # docs/book/ exists only if mdbook actually ran against a fresh checkout.
        run: |
          test -s docs/book/index.html
          test -s site/public/docs/safe-chains/index.html

      - name: Commit and push
        working-directory: site
        env:
          VERSION: ${{ needs.check-version.outputs.version }}
        run: |
          git config user.name 'github-actions[bot]'
          git config user.email 'github-actions[bot]@users.noreply.github.com'
          git add public/docs/safe-chains
          if git diff --cached --quiet; then
            echo "No docs changes"
            exit 0
          fi
          git commit -m "Refresh safe-chains docs for v${VERSION}"

          # The site's own release-index workflow commits to main too. Different
          # paths, so the rebase never conflicts, but the push can be rejected.
          for attempt in $(seq 1 5); do
            if git push origin HEAD:main; then
              echo "pushed on attempt $attempt"
              exit 0
            fi
            echo "push rejected; rebasing onto latest main (attempt $attempt)"
            git fetch origin main
            git rebase origin/main
            sleep $((RANDOM % 5 + 1))
          done
          echo "::error::failed to push docs after 5 attempts"
          exit 1