Release #41
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: | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Version tag (e.g., v0.1.0)' | |
| required: true | |
| type: string | |
| # Only one release at a time — prevent races on the same tag. | |
| concurrency: | |
| group: release-${{ inputs.version }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| # `actions: write` is needed for the final step that dispatches | |
| # npm-publish.yml via `gh workflow run`. Without it, `gh workflow run` | |
| # gets a 403 from the GitHub API. | |
| actions: write | |
| env: | |
| CARGO_TERM_COLOR: always | |
| RELEASE_VERSION: ${{ inputs.version }} | |
| jobs: | |
| # ── Preflight: validate branch, tag, and version match ────────────── | |
| preflight: | |
| runs-on: ubuntu-latest | |
| name: Preflight checks | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Validate version input | |
| run: | | |
| if [[ ! "$RELEASE_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then | |
| echo "::error::Version must be a v-prefixed SemVer tag, for example v0.1.0." | |
| exit 1 | |
| fi | |
| - name: Must be on main branch | |
| run: | | |
| if [[ "${{ github.ref }}" != "refs/heads/main" ]]; then | |
| echo "::error::Releases must be triggered from the main branch (got ${{ github.ref }})" | |
| exit 1 | |
| fi | |
| - name: Tag must not already exist | |
| run: | | |
| if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_VERSION" >/dev/null 2>&1; then | |
| echo "::error::Tag $RELEASE_VERSION already exists" | |
| exit 1 | |
| fi | |
| - name: Version tag must match Cargo.toml | |
| run: | | |
| CARGO_VERSION=$(grep '^version' src-rust/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') | |
| TAG_VERSION="$RELEASE_VERSION" | |
| TAG_VERSION="${TAG_VERSION#v}" # strip leading v | |
| if [[ "$CARGO_VERSION" != "$TAG_VERSION" ]]; then | |
| echo "::error::Tag version ($TAG_VERSION) does not match Cargo.toml ($CARGO_VERSION). Update Cargo.toml first." | |
| exit 1 | |
| fi | |
| echo "Version verified: $CARGO_VERSION" | |
| # ── Build matrix ──────────────────────────────────────────────────── | |
| build: | |
| needs: preflight | |
| strategy: | |
| fail-fast: true | |
| matrix: | |
| include: | |
| - target: x86_64-pc-windows-msvc | |
| os: windows-latest | |
| artifact: coven-code-windows-x86_64 | |
| ext: .exe | |
| - target: x86_64-unknown-linux-gnu | |
| os: ubuntu-latest | |
| artifact: coven-code-linux-x86_64 | |
| ext: "" | |
| - target: aarch64-unknown-linux-gnu | |
| os: ubuntu-latest | |
| artifact: coven-code-linux-aarch64 | |
| ext: "" | |
| cross: true | |
| - target: x86_64-apple-darwin | |
| os: macos-latest | |
| artifact: coven-code-macos-x86_64 | |
| ext: "" | |
| - target: aarch64-apple-darwin | |
| os: macos-latest | |
| artifact: coven-code-macos-aarch64 | |
| ext: "" | |
| runs-on: ${{ matrix.os }} | |
| name: Build ${{ matrix.artifact }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| persist-credentials: false | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ matrix.target }} | |
| # Linux native: install system libraries for ALSA (voice feature). | |
| - name: Install Linux system dependencies | |
| if: runner.os == 'Linux' && !matrix.cross | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y libasound2-dev pkg-config | |
| # Linux ARM64: use cross for reliable cross-compilation (handles | |
| # sysroot, pkg-config, ALSA automatically via Docker). | |
| - name: Install cross (aarch64-linux) | |
| if: matrix.cross | |
| run: cargo install cross --git https://github.com/cross-rs/cross | |
| - name: Cache cargo registry & build | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| src-rust/target | |
| key: ${{ matrix.target }}-cargo-${{ hashFiles('src-rust/Cargo.lock') }} | |
| restore-keys: ${{ matrix.target }}-cargo- | |
| - name: Build release binary (native) | |
| if: ${{ !matrix.cross }} | |
| working-directory: src-rust | |
| run: cargo build --release --locked --package claurst --target ${{ matrix.target }} | |
| - name: Create Cross.toml for cross-compilation | |
| if: matrix.cross | |
| working-directory: src-rust | |
| run: | | |
| cat > Cross.toml << 'EOF' | |
| [target.aarch64-unknown-linux-gnu] | |
| pre-build = [ | |
| "dpkg --add-architecture $CROSS_DEB_ARCH", | |
| "apt-get update", | |
| "apt-get install -y pkg-config libasound2-dev:$CROSS_DEB_ARCH" | |
| ] | |
| EOF | |
| - name: Build release binary (cross) | |
| if: matrix.cross | |
| working-directory: src-rust | |
| run: cross build --release --locked --package claurst --target ${{ matrix.target }} | |
| - name: Verify binary exists | |
| shell: bash | |
| run: | | |
| BINARY="src-rust/target/${{ matrix.target }}/release/coven-code${{ matrix.ext }}" | |
| if [[ ! -f "$BINARY" ]]; then | |
| echo "::error::Binary not found at $BINARY" | |
| exit 1 | |
| fi | |
| ls -lh "$BINARY" | |
| # Stage the binary into a per-archive directory using the canonical | |
| # name "coven-code" (or "coven-code.exe"). The install scripts assume this | |
| # name inside the archive — do not change without updating install.sh | |
| # and install.ps1. | |
| - name: Stage binary for packaging | |
| shell: bash | |
| run: | | |
| mkdir -p "stage/${{ matrix.artifact }}" | |
| cp "src-rust/target/${{ matrix.target }}/release/coven-code${{ matrix.ext }}" \ | |
| "stage/${{ matrix.artifact }}/coven-code${{ matrix.ext }}" | |
| - name: Upload artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ${{ matrix.artifact }} | |
| path: stage/${{ matrix.artifact }}/coven-code${{ matrix.ext }} | |
| # ── Create GitHub release ─────────────────────────────────────────── | |
| release: | |
| needs: build | |
| runs-on: ubuntu-latest | |
| name: Create Release | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Download all artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| path: artifacts | |
| - name: Verify all expected assets exist | |
| run: | | |
| EXPECTED=( | |
| coven-code-windows-x86_64 | |
| coven-code-linux-x86_64 | |
| coven-code-linux-aarch64 | |
| coven-code-macos-x86_64 | |
| coven-code-macos-aarch64 | |
| ) | |
| MISSING=() | |
| for name in "${EXPECTED[@]}"; do | |
| if [[ ! -d "artifacts/$name" ]]; then | |
| MISSING+=("$name") | |
| fi | |
| done | |
| if [[ ${#MISSING[@]} -gt 0 ]]; then | |
| echo "::error::Missing artifacts: ${MISSING[*]}" | |
| exit 1 | |
| fi | |
| echo "All 5 artifacts present." | |
| - name: Prepare release archives | |
| run: | | |
| mkdir -p release | |
| for dir in artifacts/*/; do | |
| name=$(basename "$dir") | |
| binary=$(find "$dir" -maxdepth 1 -type f | head -1) | |
| if [[ -z "$binary" ]]; then | |
| echo "::error::No file in $dir" | |
| exit 1 | |
| fi | |
| if [[ "$binary" == *.exe ]]; then | |
| (cd "$dir" && zip "../../release/${name}.zip" "$(basename "$binary")") | |
| else | |
| chmod +x "$binary" | |
| tar -czf "release/${name}.tar.gz" -C "$dir" "$(basename "$binary")" | |
| fi | |
| done | |
| # Stage install scripts as release assets so users can run: | |
| # curl -fsSL https://github.com/<repo>/releases/latest/download/install.sh | bash | |
| # irm https://github.com/<repo>/releases/latest/download/install.ps1 | iex | |
| if [[ -f install.sh ]]; then | |
| cp install.sh release/install.sh | |
| fi | |
| if [[ -f install.ps1 ]]; then | |
| cp install.ps1 release/install.ps1 | |
| fi | |
| echo "Release assets:" | |
| ls -lh release/ | |
| # Build release notes in two passes: | |
| # 1. Pull GitHub's auto-generated notes via the API — this gives us | |
| # the "What's Changed" PR list with @author mentions, the | |
| # "New Contributors" section, the "Full Changelog" link, and the | |
| # label-driven categorisation defined in .github/release.yml. | |
| # 2. Append a "Direct commits" section listing non-merge commits | |
| # that did NOT land via a squash-merged PR (those already appear | |
| # in the API output). This captures direct pushes to main, which | |
| # `generate_release_notes` ignores. | |
| # | |
| # We don't pass `generate_release_notes: true` on the release action | |
| # because it conflicts with `body_path`. The API call below is the | |
| # exact same endpoint the action would have hit. | |
| - name: Generate release notes | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| CURRENT_TAG="$RELEASE_VERSION" | |
| PREVIOUS_TAG="$(git tag --sort=-v:refname | grep -vxF "$CURRENT_TAG" | head -n 1 || true)" | |
| # ── Pass 1: GitHub-generated PR notes ────────────────────────── | |
| # Same endpoint the gh-release action's `generate_release_notes` | |
| # toggle hits. Honours .github/release.yml categories and | |
| # surfaces "New Contributors" + "Full Changelog" footer. | |
| if [[ -n "$PREVIOUS_TAG" ]]; then | |
| gh api \ | |
| -X POST \ | |
| "/repos/${{ github.repository }}/releases/generate-notes" \ | |
| -f tag_name="$CURRENT_TAG" \ | |
| -f previous_tag_name="$PREVIOUS_TAG" \ | |
| -f target_commitish="${{ github.sha }}" \ | |
| --jq '.body' \ | |
| > pr-notes.md | |
| else | |
| echo "" > pr-notes.md | |
| fi | |
| # ── Pass 2: direct commits not associated with a merged PR ───── | |
| # Heuristic: squash-merged PRs land with their PR number appended | |
| # to the subject as "(#NN)" — strip those, they're already in the | |
| # PR section. Merge commits are excluded outright. | |
| if [[ -n "$PREVIOUS_TAG" ]]; then | |
| RANGE="${PREVIOUS_TAG}..${{ github.sha }}" | |
| else | |
| RANGE="${{ github.sha }}" | |
| fi | |
| DIRECT_COMMITS="$( | |
| git log --no-merges --pretty=format:'%H%x09%s' "$RANGE" \ | |
| | grep -Ev '\(#[0-9]+\)$' \ | |
| | awk -F'\t' -v repo="${{ github.repository }}" ' | |
| { | |
| short=substr($1,1,7) | |
| printf("- %s ([`%s`](https://github.com/%s/commit/%s))\n", $2, short, repo, $1) | |
| } | |
| ' \ | |
| || true | |
| )" | |
| # ── Pass 3: split pr-notes.md into its three logical sections ── | |
| # GitHub's generate-notes returns one blob containing, in order: | |
| # 1. Category headings (✨ Features, 🐛 Fixes, …) + Other Changes | |
| # 2. "## New Contributors" list | |
| # 3. "**Full Changelog**: …compare link" | |
| # We want to re-order the final body as: | |
| # Direct commits → Other changes → New Contributors → Full Changelog | |
| # so split on the two well-known anchors. awk keeps this portable | |
| # and avoids shelling out to a heredoc-python in the workflow. | |
| awk ' | |
| BEGIN { section = "categories" } | |
| /^## New Contributors[[:space:]]*$/ { section = "contributors"; next } | |
| /^\*\*Full Changelog\*\*:/ { section = "changelog" } | |
| { print > (section ".md") } | |
| ' pr-notes.md | |
| # Ensure all three files exist even when empty so the composition | |
| # block below doesn't fail under `set -e`. | |
| touch categories.md contributors.md changelog.md | |
| # ── Compose final body in the requested order ────────────────── | |
| # Direct commits first, then everything carried over from the PR | |
| # categorisation, then New Contributors, then the Full Changelog | |
| # footer link. Blank-line spacers are emitted only between | |
| # sections that have content, so a release with (say) no direct | |
| # commits doesn't get a leading blank line. | |
| : > release-notes.md | |
| first=1 | |
| append_section() { | |
| local body="$1" | |
| [[ -z "$body" ]] && return 0 | |
| if [[ $first -eq 0 ]]; then | |
| { echo; echo; } >> release-notes.md | |
| fi | |
| printf '%s\n' "$body" >> release-notes.md | |
| first=0 | |
| } | |
| if [[ -n "$DIRECT_COMMITS" ]]; then | |
| append_section "$(printf '## 🛠 Direct commits\n\nWork pushed to `main` outside of pull requests in this release:\n\n%s' "$DIRECT_COMMITS")" | |
| fi | |
| # `categories.md` already carries its own per-section `##` headings | |
| # (e.g. `## ✨ Features`) emitted by GitHub's generator, so we pipe | |
| # it through verbatim — no extra wrapper heading needed. | |
| CATEGORIES="$(sed -e :a -e '/^[[:space:]]*$/{$d;N;ba' -e '}' categories.md)" | |
| append_section "$CATEGORIES" | |
| CONTRIBUTORS="$(sed -e :a -e '/^[[:space:]]*$/{$d;N;ba' -e '}' contributors.md)" | |
| if [[ -n "$CONTRIBUTORS" ]]; then | |
| append_section "$(printf '## New Contributors\n\n%s' "$CONTRIBUTORS")" | |
| fi | |
| CHANGELOG="$(sed -e :a -e '/^[[:space:]]*$/{$d;N;ba' -e '}' changelog.md)" | |
| append_section "$CHANGELOG" | |
| echo "── Final release notes ──────────────────────────────" | |
| cat release-notes.md | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@v3 | |
| with: | |
| tag_name: ${{ env.RELEASE_VERSION }} | |
| target_commitish: ${{ github.sha }} | |
| name: Coven Code ${{ env.RELEASE_VERSION }} | |
| draft: false | |
| prerelease: false | |
| body_path: release-notes.md | |
| files: release/* | |
| # Explicit hand-off to npm-publish.yml. | |
| # | |
| # We can't rely solely on the `workflow_run` trigger in npm-publish.yml | |
| # because this Release workflow is itself triggered by a workflow | |
| # dispatch from auto-release.yml's GITHUB_TOKEN, and GitHub's | |
| # automatic-token rules don't list `workflow_run` as an exception to | |
| # the "events from GITHUB_TOKEN don't create new workflow runs" rule. | |
| # In practice that means the workflow_run chain silently no-ops on | |
| # auto-cut releases. Dispatching directly here makes the publish step | |
| # deterministic — the workflow_run trigger stays as a manual-release | |
| # fallback. | |
| - name: Dispatch npm-publish.yml | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| gh workflow run npm-publish.yml \ | |
| --repo "${{ github.repository }}" \ | |
| --ref main \ | |
| -f version="$RELEASE_VERSION" | |
| echo "Dispatched npm-publish.yml for $RELEASE_VERSION." |