Release Crate #15
Workflow file for this run
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
| # Build and publish cross-platform release binaries for a workspace binary crate. | |
| # Triggered by per-crate git tags (e.g. leo-lang-v4.0.1) or manual dispatch. | |
| # Fully idempotent - safe to re-run on failure. | |
| name: Release Crate | |
| on: | |
| push: | |
| tags: ['*-v[0-9]*'] | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: >- | |
| Tag to release (e.g. leo-lang-v4.0.1, leo-fmt-v1.0.0). Manual | |
| dispatch creates the tag if it is missing, for crates.io-published | |
| backfills. | |
| required: true | |
| # Serialize runs for the same tag. Avoids races between automated dispatch | |
| # from publish-crates.yml and manual backfills / reruns for the same release. | |
| concurrency: | |
| group: release-crate-${{ inputs.tag || github.ref_name }} | |
| cancel-in-progress: false | |
| env: | |
| RUST_BACKTRACE: 0 | |
| jobs: | |
| prepare: | |
| name: Prepare Release | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| outputs: | |
| tag: ${{ steps.parse.outputs.tag }} | |
| crate-name: ${{ steps.parse.outputs.crate-name }} | |
| version: ${{ steps.parse.outputs.version }} | |
| bins: ${{ steps.find.outputs.bins }} | |
| has-bins: ${{ steps.find.outputs.has-bins }} | |
| previous-tag: ${{ steps.previous.outputs.previous-tag }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Parse Tag | |
| id: parse | |
| run: | | |
| TAG="${{ github.event.inputs.tag || github.ref_name }}" | |
| # Split on last occurrence of "-v" followed by a digit. | |
| CRATE_NAME="${TAG%-v[0-9]*}" | |
| VERSION="${TAG#"$CRATE_NAME"-v}" | |
| if [ -z "$CRATE_NAME" ] || [ -z "$VERSION" ]; then | |
| echo "::error::Failed to parse tag '$TAG'" | |
| exit 1 | |
| fi | |
| { | |
| echo "tag=$TAG" | |
| echo "crate-name=$CRATE_NAME" | |
| echo "version=$VERSION" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "Parsed tag: crate=$CRATE_NAME version=$VERSION" | |
| - name: Resolve Release Ref | |
| id: ref | |
| run: | | |
| TAG="${{ steps.parse.outputs.tag }}" | |
| if git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then | |
| git checkout --detach "$TAG" | |
| { | |
| echo "tag-exists=true" | |
| echo "release-commit=$(git rev-parse HEAD)" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "Using existing tag $TAG" | |
| else | |
| { | |
| echo "tag-exists=false" | |
| echo "release-commit=$(git rev-parse HEAD)" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "Tag $TAG does not exist; manual dispatch will create it after validation." | |
| fi | |
| - name: Find Crate | |
| id: find | |
| run: | | |
| CRATE_NAME="${{ steps.parse.outputs.crate-name }}" | |
| VERSION="${{ steps.parse.outputs.version }}" | |
| # Find the crate by its package name. | |
| FOUND="" | |
| for toml in crates/*/Cargo.toml; do | |
| if grep -q "^name = \"$CRATE_NAME\"" "$toml"; then | |
| FOUND="$toml" | |
| break | |
| fi | |
| done | |
| if [ -z "$FOUND" ]; then | |
| echo "::error::No crate found with name '$CRATE_NAME'" | |
| exit 1 | |
| fi | |
| TOML_VERSION="$(grep '^version' "$FOUND" | head -1 | sed 's/.*= *"\(.*\)"/\1/')" | |
| if [ "$TOML_VERSION" != "$VERSION" ]; then | |
| echo "::error::Tag version ($VERSION) does not match $FOUND version ($TOML_VERSION)" | |
| exit 1 | |
| fi | |
| # Collect all [[bin]] names from this crate. Section-aware so | |
| # neighbouring tables like [[test]] do not leak into the result. | |
| BINS="$(awk -F'"' ' | |
| /^\[\[bin\]\]/ { in_bin = 1; next } | |
| /^\[/ { in_bin = 0; next } | |
| in_bin && /^name[[:space:]]*=/ { print $2 } | |
| ' "$FOUND" | tr '\n' ' ' | xargs)" | |
| if [ -z "$BINS" ]; then | |
| { | |
| echo "bins=" | |
| echo "has-bins=false" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "No [[bin]] entries found in $FOUND; no release artifacts to build." | |
| exit 0 | |
| fi | |
| { | |
| echo "bins=$BINS" | |
| echo "has-bins=true" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "Found crate: dir=$(dirname "$FOUND") bins=$BINS" | |
| - name: Ensure Tag Exists | |
| if: steps.find.outputs.has-bins == 'true' | |
| run: | | |
| TAG="${{ steps.parse.outputs.tag }}" | |
| if [ "${{ steps.ref.outputs.tag-exists }}" = "true" ]; then | |
| echo "Tag $TAG already exists" | |
| else | |
| echo "Creating tag $TAG" | |
| git tag "$TAG" "${{ steps.ref.outputs.release-commit }}" | |
| git push origin "refs/tags/$TAG" | |
| fi | |
| - name: Skip Non-Binary Crate | |
| if: steps.find.outputs.has-bins != 'true' | |
| run: echo "Skipping ${{ steps.parse.outputs.tag }} because ${{ steps.parse.outputs.crate-name }} has no binary targets." | |
| - name: Find Previous Tag | |
| id: previous | |
| if: steps.find.outputs.has-bins == 'true' | |
| run: | | |
| TAG="${{ steps.parse.outputs.tag }}" | |
| CRATE_NAME="${{ steps.parse.outputs.crate-name }}" | |
| PREVIOUS_TAG="$( | |
| git tag --list "${CRATE_NAME}-v[0-9]*" --sort=-v:refname \ | |
| | awk -v current="$TAG" 'seen { print; exit } $0 == current { seen = 1 }' | |
| )" | |
| echo "previous-tag=$PREVIOUS_TAG" >> "$GITHUB_OUTPUT" | |
| if [ -n "$PREVIOUS_TAG" ]; then | |
| echo "Previous same-crate tag: $PREVIOUS_TAG" | |
| else | |
| echo "No previous same-crate tag found" | |
| fi | |
| build: | |
| name: Build (${{ matrix.target }}) | |
| needs: prepare | |
| if: needs.prepare.outputs.has-bins == 'true' | |
| strategy: | |
| fail-fast: true | |
| matrix: | |
| include: | |
| - target: x86_64-unknown-linux-gnu | |
| os: ubuntu-latest | |
| - target: x86_64-unknown-linux-musl | |
| os: ubuntu-latest | |
| - target: x86_64-apple-darwin | |
| os: macos-14-large | |
| - target: aarch64-apple-darwin | |
| os: macos-latest | |
| - target: x86_64-pc-windows-msvc | |
| os: windows-latest | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.prepare.outputs.tag }} | |
| - name: Install Rust | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| toolchain: stable | |
| components: rustfmt | |
| - name: Add Target | |
| if: matrix.target == 'aarch64-apple-darwin' | |
| run: rustup target add ${{ matrix.target }} | |
| - name: Verify Cargo.lock | |
| shell: bash | |
| run: test -f Cargo.lock || (echo "::error::Cargo.lock missing" && exit 1) | |
| - name: Determine Build Flags | |
| id: flags | |
| shell: bash | |
| run: | | |
| FEATURES="" | |
| TARGET_FLAG="" | |
| if [ "${{ needs.prepare.outputs.crate-name }}" = "leo-lang" ]; then | |
| FEATURES="--features noconfig" | |
| fi | |
| if [ "${{ matrix.target }}" = "aarch64-apple-darwin" ]; then | |
| TARGET_FLAG="--target aarch64-apple-darwin" | |
| elif [ "${{ matrix.target }}" = "x86_64-unknown-linux-musl" ]; then | |
| TARGET_FLAG="--target x86_64-unknown-linux-musl" | |
| fi | |
| echo "features=$FEATURES" >> "$GITHUB_OUTPUT" | |
| echo "target-flag=$TARGET_FLAG" >> "$GITHUB_OUTPUT" | |
| # Standard build (linux-gnu, macos, windows) | |
| - name: Build | |
| if: matrix.target != 'x86_64-unknown-linux-musl' | |
| shell: bash | |
| run: | | |
| cargo build \ | |
| --package ${{ needs.prepare.outputs.crate-name }} \ | |
| --release --locked \ | |
| ${{ steps.flags.outputs.features }} \ | |
| ${{ steps.flags.outputs.target-flag }} | |
| env: | |
| CARGO_NET_GIT_FETCH_WITH_CLI: true | |
| # Musl build via Docker | |
| - name: Build (musl) | |
| if: matrix.target == 'x86_64-unknown-linux-musl' | |
| run: | | |
| docker run \ | |
| -v ${{ github.workspace }}:/home/rust/src \ | |
| --rm \ | |
| -t docker.io/blackdex/rust-musl:x86_64-musl-stable \ | |
| bash -lc " | |
| apt-get update && | |
| apt-get install -y clang libclang-dev && | |
| export LIBCLANG_PATH=\$(dirname \$(find /usr/lib -name 'libclang.so*' | head -1)) && | |
| rustup target add x86_64-unknown-linux-musl && | |
| cd /home/rust/src && | |
| cargo build \ | |
| --package ${{ needs.prepare.outputs.crate-name }} \ | |
| --target x86_64-unknown-linux-musl \ | |
| --release --locked \ | |
| ${{ steps.flags.outputs.features }} | |
| " | |
| # Container writes outputs as its own user; reclaim ownership so | |
| # subsequent host steps (strip, zip) can write into target/. | |
| sudo chown -R "$(id -u):$(id -g)" target | |
| - name: Collect Binaries | |
| id: bins | |
| shell: bash | |
| run: | | |
| BINS="${{ needs.prepare.outputs.bins }}" | |
| case "${{ matrix.target }}" in | |
| x86_64-pc-windows-msvc) DIR="target/release"; EXT=".exe" ;; | |
| aarch64-apple-darwin) DIR="target/aarch64-apple-darwin/release"; EXT="" ;; | |
| x86_64-unknown-linux-musl) DIR="target/x86_64-unknown-linux-musl/release"; EXT="" ;; | |
| *) DIR="target/release"; EXT="" ;; | |
| esac | |
| PATHS="" | |
| for bin in $BINS; do | |
| PATHS="$PATHS $DIR/${bin}${EXT}" | |
| done | |
| echo "paths=$PATHS" >> "$GITHUB_OUTPUT" | |
| - name: Strip Binaries | |
| if: runner.os != 'Windows' | |
| run: strip ${{ steps.bins.outputs.paths }} | |
| - name: Package (Unix) | |
| if: runner.os != 'Windows' | |
| run: | | |
| ARCHIVE="${{ needs.prepare.outputs.crate-name }}-v${{ needs.prepare.outputs.version }}-${{ matrix.target }}.zip" | |
| zip -j "$ARCHIVE" ${{ steps.bins.outputs.paths }} | |
| echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV" | |
| - name: Package (Windows) | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| run: | | |
| $archive = "${{ needs.prepare.outputs.crate-name }}-v${{ needs.prepare.outputs.version }}-${{ matrix.target }}.zip" | |
| $paths = "${{ steps.bins.outputs.paths }}".Trim() -split '\s+' | |
| Compress-Archive -Path $paths -DestinationPath $archive | |
| echo "ARCHIVE=$archive" >> $env:GITHUB_ENV | |
| - name: Upload Artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: release-${{ matrix.target }} | |
| path: ${{ env.ARCHIVE }} | |
| metadata: | |
| name: Generate Release Metadata | |
| needs: prepare | |
| if: needs.prepare.outputs.has-bins == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.prepare.outputs.tag }} | |
| fetch-depth: 0 | |
| - name: Install Rust | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| toolchain: stable | |
| - name: Install jq | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y jq | |
| - name: Generate Metadata | |
| shell: bash | |
| run: bash scripts/generate-release-metadata.sh "${{ needs.prepare.outputs.tag }}" | |
| env: | |
| PREVIOUS_TAG: ${{ needs.prepare.outputs.previous-tag }} | |
| - name: Upload Artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: release-metadata | |
| path: | | |
| release-body.md | |
| leo-release.toml | |
| release: | |
| name: Publish Release | |
| needs: [prepare, build, metadata] | |
| if: needs.prepare.outputs.has-bins == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Download Artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| pattern: release-* | |
| merge-multiple: true | |
| - name: Create Release | |
| if: needs.prepare.outputs.previous-tag != '' | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ needs.prepare.outputs.tag }} | |
| name: "${{ needs.prepare.outputs.crate-name }} v${{ needs.prepare.outputs.version }}" | |
| body_path: release-body.md | |
| generate_release_notes: true | |
| previous_tag: ${{ needs.prepare.outputs.previous-tag }} | |
| files: | | |
| *.zip | |
| leo-release.toml | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Create First Release | |
| if: needs.prepare.outputs.previous-tag == '' | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ needs.prepare.outputs.tag }} | |
| name: "${{ needs.prepare.outputs.crate-name }} v${{ needs.prepare.outputs.version }}" | |
| body_path: release-body.md | |
| files: | | |
| *.zip | |
| leo-release.toml | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |