moadim 3.2.7

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
name: GitHub Release

# Cut a GitHub Release for a version. Triggered two ways:
#   * `workflow_call` from auto-release.yml, right after it pushes the tag on a
#     version bump (the automated path — see auto-release.yml). That commit
#     already passed lint/test before landing on main (via cut-release.yml's
#     `needs: [version, lint, test]` gate and/or PR branch protection), so
#     re-running here would be redundant — skipped below.
#   * a manual `v*` tag push (escape hatch for hotfixes cut by hand). Unlike
#     the automated path, this can tag *any* commit, verified or not, so this
#     is where the gap was (see #260): a release could be cut without ever
#     checking cargo test/fmt/clippy. Fixed by calling lint.yml/test.yml
#     (already workflow_call-callable) and gating `release` on them via
#     `needs:`.
# Release notes are extracted from the matching version section in CHANGELOG.md
# so the published notes stay in sync with the changelog. Runs independently of
# publish.yml — neither blocks the other.
#
# The `lint`/`test` skip below keys off `inputs.tag`, not `github.event_name`:
# a *nested* `workflow_call` (this workflow, called from auto-release.yml,
# itself triggered by a `push` to main) inherits `github.event_name` from the
# chain's originating event — it reads `push` here too, not `workflow_call` —
# so `github.event_name == 'push'` was always true and never actually skipped
# anything. That silently ran this workflow's own `test.yml` call alongside
# publish.yml's identical call *and* the plain push-triggered `test.yml`, all
# three racing for the same `test-${{ github.ref }}` concurrency group with
# `cancel-in-progress: true` — auto-release.yml's `publish`/`release` jobs
# routinely lost that race. `inputs` is only populated on the `workflow_call`
# path (`required: true` there), so `inputs.tag == ''` reliably means "not
# called with a tag" — i.e. the direct tag-push escape hatch — regardless of
# what `github.event_name` reports.

on:
  push:
    tags:
      - 'v*'
  workflow_call:
    inputs:
      tag:
        description: Tag to release (e.g. v0.14.0)
        required: true
        type: string

permissions:
  contents: write
  id-token: write # required for npm provenance in the called npm-packages workflow

jobs:
  # Gate for the manual `push: tags` escape hatch only — see #260. Skipped
  # entirely on the `workflow_call` path since that commit is already verified
  # (see header comment above).
  lint:
    name: Lint tagged commit
    if: inputs.tag == ''
    uses: ./.github/workflows/lint.yml

  test:
    name: Test tagged commit
    if: inputs.tag == ''
    uses: ./.github/workflows/test.yml

  release:
    name: Create GitHub Release
    needs: [lint, test]
    # Run if lint/test passed (push path) or were skipped (workflow_call path).
    # Do NOT use the default `success()` here: a skipped need would otherwise
    # make this job skip too.
    if: always() && (needs.lint.result == 'success' || needs.lint.result == 'skipped') && (needs.test.result == 'success' || needs.test.result == 'skipped')
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ env.TAG }}
    env:
      TAG: ${{ inputs.tag || github.ref_name }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Extract changelog section for this tag
        id: notes
        run: |
          VERSION=${TAG#v}
          # Pull lines between '## [VERSION]' and the next '## [' heading.
          awk -v ver="$VERSION" '
            $0 ~ "^## \\[" ver "\\]" { capture=1; next }
            capture && /^## \[/ { exit }
            capture { print }
          ' CHANGELOG.md > release-notes.md
          if [ ! -s release-notes.md ]; then
            echo "No changelog section found for [$VERSION]" >&2
            exit 1
          fi
          echo "Release notes for $VERSION:"
          cat release-notes.md

      - name: Create release
        uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
        with:
          tag_name: ${{ env.TAG }}
          name: ${{ env.TAG }}
          body_path: release-notes.md

  npm-packages:
    name: Build and publish npm packages
    needs: release
    uses: ./.github/workflows/npm-packages.yml
    with:
      tag: ${{ needs.release.outputs.tag }}
      publish: true

  # Cross-compiled binary archives attached to the release created above, so `cargo binstall
  # moadim` and users without a Rust toolchain have a prebuilt executable to download instead of
  # `cargo install`'s from-source compile (#329). `cargo build --release` alone already embeds the
  # UI: build.rs falls back to the committed prebuilt.html verbatim whenever pnpm isn't installed
  # (see prebuilt.yml's header comment), which is exactly this job's case, so no extra UI-build
  # step is needed here.
  #
  # Unix-only, matching the daemon itself (see #187's tmux/crontab dependency). Linux aarch64 is a
  # nice-to-have per #329 and left for later — the three targets below cover the common case.
  build-binaries:
    name: Build binary (${{ matrix.target }})
    needs: release
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            target: x86_64-unknown-linux-gnu
          - os: macos-latest
            target: aarch64-apple-darwin
          - os: macos-latest
            target: x86_64-apple-darwin
    env:
      TAG: ${{ needs.release.outputs.tag }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Install Rust
        uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
        with:
          toolchain: stable
          targets: ${{ matrix.target }}

      - name: Build
        run: cargo build --release --locked --target ${{ matrix.target }}

      - name: Package archive and checksum
        id: package
        run: |
          VERSION=${TAG#v}
          ARCHIVE="moadim-${VERSION}-${{ matrix.target }}.tar.gz"
          tar -czf "$ARCHIVE" -C "target/${{ matrix.target }}/release" moadim
          shasum -a 256 "$ARCHIVE" > "$ARCHIVE.sha256"
          echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"

      - name: Attach to release
        uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
        with:
          tag_name: ${{ env.TAG }}
          files: |
            ${{ steps.package.outputs.archive }}
            ${{ steps.package.outputs.archive }}.sha256