forgectl 0.2.0

Keep a GitHub account's repositories in line with one manifest
name: Rust CI

# The checks shared by every Rust repo forge manages. Each repo's ci.yml calls
# it; triggers can't be shared, so they live there:
#
#   on:
#     pull_request:
#     push:
#       branches: [main]
#     schedule:
#       - cron: "0 6 * * 1"
#     workflow_dispatch:
#   permissions:
#     contents: read
#   jobs:
#     ci:
#       uses: lkurcak/forge/.github/workflows/rust-ci.yml@v1
#
# Pull requests test the branch merged into main as it was then; the push run
# tests what actually landed, which also covers direct pushes and is what the
# release workflow relies on. The weekly run catches new stable Rust breaking
# a repo nobody touched. A newer run of the same ref cancels an older one.
#
# Rust versions come from the repo, not from here: the MSRV job runs when
# Cargo.toml sets rust-version, with that toolchain. Writing the version as an
# input (not `dtolnay/rust-toolchain@1.85`) keeps Dependabot from bumping it.
#
# A repo shipping a container image also runs it on version tags, and lets
# the call publish packages:
#
#   on:
#     ...
#     push:
#       branches: [main]
#       tags: ["v[0-9]+.[0-9]+.[0-9]+"]
#   ...
#   jobs:
#     ci:
#       uses: lkurcak/forge/.github/workflows/rust-ci.yml@v1
#       permissions:
#         contents: read
#         packages: write
#       with:
#         container: true
#
# The container jobs take whatever the caller grants, so callers without
# containers don't have to grant packages: write; every other job keeps
# contents: read for itself.
#
# Image layers are cached between runs, so the Dockerfile compiles the
# dependencies in a layer of their own with cargo-chef (forge's `container`
# tag checks for it). That layer only rebuilds when Cargo.toml, Cargo.lock or
# the toolchain changes; a source change only recompiles the crate:
#
#   FROM rust:1-bookworm AS chef
#   RUN cargo install cargo-chef --locked
#   WORKDIR /build
#   # With a rust-toolchain.toml; without one, leave out these two lines.
#   COPY rust-toolchain.toml ./
#   RUN rustup toolchain install
#
#   FROM chef AS planner
#   COPY . .
#   RUN cargo chef prepare --recipe-path recipe.json
#
#   FROM chef AS builder
#   COPY --from=planner /build/recipe.json ./
#   RUN cargo chef cook --locked --release --recipe-path recipe.json
#   COPY Cargo.toml Cargo.lock ./
#   COPY src ./src
#   RUN cargo build --locked --release
#
#   FROM debian:bookworm-slim
#   COPY --from=builder /build/target/release/{binary} /usr/local/bin/
#   ...
#
# The planner copies the whole context, so .dockerignore should leave out
# target and .git. A pinned toolchain (a dated nightly, say) keeps the cache
# warm; `nightly` alone rebuilds everything daily.

on:
  workflow_call:
    inputs:
      bevy:
        description: >-
          The workspace builds Bevy (with --all-features, at least): install
          the system libraries it needs on Linux (BEVY_LINUX_PACKAGES).
        type: boolean
        default: false
      no-std:
        description: >-
          The crate works without std: build it with --no-default-features
          for NO_STD_TARGET, which has no std at all, on stable and the MSRV.
        type: boolean
        default: false
      container:
        description: >-
          The repo ships as a container image, from the Dockerfile at its
          root: test on Linux only, where it runs, then build the image for
          linux/amd64 and linux/arm64. Pull requests only build it; pushes publish it
          to ghcr.io/{owner}/{repo}, as latest from main and as X.Y.Z and X.Y
          from a vX.Y.Z tag, which must match Cargo.toml's version.
        type: boolean
        default: false

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  CARGO_TERM_COLOR: always
  # Bare-metal ARM: anything reaching for std, in the crate or its
  # dependencies, fails to build here.
  NO_STD_TARGET: thumbv7em-none-eabi
  # From Bevy's Linux setup guide: audio, input, windowing and GL.
  BEVY_LINUX_PACKAGES: >-
    libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
    libx11-dev libxrandr-dev libxcursor-dev libxi-dev libxinerama-dev
    libegl1-mesa-dev libgl1-mesa-dev libgles2-mesa-dev
  IMAGE: ghcr.io/${{ github.repository }}

jobs:
  fmt:
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt
      - run: cargo fmt --all --check

  clippy:
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy
      - if: inputs.bevy && runner.os == 'Linux'
        name: Install Bevy's Linux libraries
        run: |
          # shellcheck disable=SC2086 # one argument per package
          sudo apt-get update && sudo apt-get install -y --no-install-recommends $BEVY_LINUX_PACKAGES
      - uses: Swatinem/rust-cache@v2
      - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings

  test:
    permissions:
      contents: read
    strategy:
      fail-fast: false
      matrix:
        os: ${{ fromJSON(inputs.container && '["ubuntu-latest"]' || '["ubuntu-latest", "macos-latest", "windows-latest"]') }}
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - if: inputs.bevy && runner.os == 'Linux'
        name: Install Bevy's Linux libraries
        run: |
          # shellcheck disable=SC2086 # one argument per package
          sudo apt-get update && sudo apt-get install -y --no-install-recommends $BEVY_LINUX_PACKAGES
      - uses: Swatinem/rust-cache@v2
      - run: cargo test --workspace --all-features --locked

  doc:
    permissions:
      contents: read
    runs-on: ubuntu-latest
    env:
      RUSTDOCFLAGS: -D warnings
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - if: inputs.bevy && runner.os == 'Linux'
        name: Install Bevy's Linux libraries
        run: |
          # shellcheck disable=SC2086 # one argument per package
          sudo apt-get update && sudo apt-get install -y --no-install-recommends $BEVY_LINUX_PACKAGES
      - uses: Swatinem/rust-cache@v2
      - run: cargo doc --workspace --all-features --no-deps --locked

  # What the release would upload to crates.io, for crates that can be
  # published at all.
  package:
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: Dry-run cargo publish
        run: |
          publishable=$(cargo metadata --no-deps --format-version 1 \
            | jq '[.packages[] | select(.publish != [])] | length')
          if [ "$publishable" -eq 0 ]; then
            echo "Nothing here is published to crates.io"
            exit 0
          fi
          cargo publish --workspace --dry-run --locked

  msrv:
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - name: Read rust-version
        id: msrv
        run: |
          msrv=$(cargo metadata --no-deps --format-version 1 \
            | jq -r '[.packages[].rust_version // empty] | sort_by(split(".") | map(tonumber)) | last // empty')
          echo "version=$msrv" >> "$GITHUB_OUTPUT"
          [ -n "$msrv" ] || echo "No rust-version in Cargo.toml; nothing to check"
      - if: steps.msrv.outputs.version != ''
        uses: dtolnay/rust-toolchain@master
        with:
          toolchain: ${{ steps.msrv.outputs.version }}
          targets: ${{ inputs.no-std && env.NO_STD_TARGET || '' }}
      - if: inputs.bevy && runner.os == 'Linux'
        name: Install Bevy's Linux libraries
        run: |
          # shellcheck disable=SC2086 # one argument per package
          sudo apt-get update && sudo apt-get install -y --no-install-recommends $BEVY_LINUX_PACKAGES
      - if: steps.msrv.outputs.version != ''
        uses: Swatinem/rust-cache@v2
      - if: steps.msrv.outputs.version != ''
        run: cargo test --workspace --all-features --locked
      - if: steps.msrv.outputs.version != '' && inputs.no-std
        run: cargo build --workspace --no-default-features --locked --target "$NO_STD_TARGET"

  no-std:
    permissions:
      contents: read
    if: inputs.no-std
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ env.NO_STD_TARGET }}
      - uses: Swatinem/rust-cache@v2
      - run: cargo build --workspace --no-default-features --locked --target "$NO_STD_TARGET"

  # One image per architecture, each built natively and pushed by digest
  # alone; container-publish tags them together as one multi-arch image.
  container:
    if: inputs.container
    needs: [fmt, clippy, test, doc, package, msrv]
    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: linux/amd64
            runner: ubuntu-latest
          - platform: linux/arm64
            runner: ubuntu-24.04-arm
    runs-on: ${{ matrix.runner }}
    env:
      PUSH: ${{ github.event_name == 'push' }}
    steps:
      - uses: actions/checkout@v7
      - uses: docker/setup-buildx-action@v4
      - if: env.PUSH == 'true'
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: metadata
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.IMAGE }}
      - id: arch
        run: echo "arch=${PLATFORM#linux/}" >> "$GITHUB_OUTPUT"
        env:
          PLATFORM: ${{ matrix.platform }}
      - id: build
        uses: docker/build-push-action@v7
        with:
          context: .
          platforms: ${{ matrix.platform }}
          labels: ${{ steps.metadata.outputs.labels }}
          outputs: ${{ env.PUSH == 'true' && format('type=image,name={0},push-by-digest=true,name-canonical=true,push=true', env.IMAGE) || 'type=cacheonly' }}
          cache-from: type=gha,scope=container-${{ steps.arch.outputs.arch }}
          cache-to: type=gha,mode=max,scope=container-${{ steps.arch.outputs.arch }}
      - if: env.PUSH == 'true'
        name: Export digest
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
        run: |
          mkdir -p "$RUNNER_TEMP/digests"
          touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}"
      - if: env.PUSH == 'true'
        uses: actions/upload-artifact@v7
        with:
          name: digest-${{ steps.arch.outputs.arch }}
          path: ${{ runner.temp }}/digests/*
          if-no-files-found: error
          retention-days: 1

  container-publish:
    if: inputs.container && github.event_name == 'push'
    needs: container
    runs-on: ubuntu-latest
    steps:
      - if: github.ref_type == 'tag'
        uses: actions/checkout@v7
      - if: github.ref_type == 'tag'
        uses: dtolnay/rust-toolchain@stable
      # Same rule as rust-release.yml: every crate that gets published
      # shares the version (all of them, if none is published).
      - if: github.ref_type == 'tag'
        name: Check tag matches Cargo.toml version
        run: |
          version=${GITHUB_REF_NAME#v}
          mismatched=$(cargo metadata --no-deps --format-version 1 \
            | jq -r --arg v "$version" '.packages as $all
                | [$all[] | select(.publish != [])] | if length > 0 then . else $all end
                | .[] | select(.version != $v) | "\(.name) \(.version)"')
          if [ -n "$mismatched" ]; then
            echo "::error::Tag $GITHUB_REF_NAME does not match Cargo.toml: $mismatched"
            exit 1
          fi
      - uses: actions/download-artifact@v8
        with:
          path: ${{ runner.temp }}/digests
          pattern: digest-*
          merge-multiple: true
      - uses: docker/setup-buildx-action@v4
      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: metadata
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.IMAGE }}
          tags: |
            type=raw,value=latest,enable={{is_default_branch}}
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
      - name: Tag the images as one
        working-directory: ${{ runner.temp }}/digests
        env:
          TAGS: ${{ steps.metadata.outputs.tags }}
        run: |
          args=()
          while read -r tag; do
            [ -n "$tag" ] && args+=(--tag "$tag")
          done <<<"$TAGS"
          for digest in *; do
            args+=("$IMAGE@sha256:$digest")
          done
          docker buildx imagetools create "${args[@]}"

  # One check that passes only if every job above passed or was skipped:
  # what a ruleset requires (require-ci, "ci / ok"), so a PR's auto-merge
  # waits for all of CI, whichever jobs a repo's inputs turn on.
  ok:
    if: always()
    needs: [fmt, clippy, test, doc, package, msrv, no-std, container, container-publish]
    permissions: {}
    runs-on: ubuntu-latest
    steps:
      - name: Every job passed or was skipped
        env:
          RESULTS: ${{ join(needs.*.result, ' ') }}
        run: |
          echo "$RESULTS"
          for r in $RESULTS; do
            case "$r" in success|skipped) ;; *) exit 1 ;; esac
          done