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
- 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
- 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
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- 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]
if: vars.SITE_DOCS_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- 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
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
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