15.4 KiB · text History 33961a4
name: Rust CI and Docker Build

on:
  push:
    branches: [ master ]
    tags: [ 'v*' ]
  pull_request:
    branches: [ master ]

env:
  CARGO_TERM_COLOR: always
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

# A newer push supersedes this run: on a branch or PR the run in
# progress is cancelled; on master it finishes, and only a run still
# waiting is replaced.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}

jobs:
  build:
    strategy:
      matrix:
        include:
          - runner: arc-runner-set
            arch: x86_64
          - runner: ubuntu-24.04-arm
            arch: aarch64
    runs-on: ${{ matrix.runner }}
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Install system dependencies (Linux)
        if: runner.os == 'Linux'
        run: |
          # A hosted runner is an unprivileged user with sudo. A job
          # container is already root and usually ships no sudo at all,
          # so hardcoding it fails with `sudo: command not found`
          # before the first package is ever fetched. Ask what we are
          # rather than assuming either one.
          if [ "$(id -u)" -eq 0 ]; then
            SUDO=
          elif command -v sudo &> /dev/null; then
            SUDO=sudo
          else
            echo "Not root and no sudo available; cannot install system dependencies."
            exit 1
          fi

          if command -v apt-get &> /dev/null; then
            $SUDO apt-get update
            $SUDO apt-get install -y build-essential pkg-config libssl-dev libgtk-3-dev libgdk-pixbuf-2.0-dev libpango1.0-dev libglib2.0-dev
          elif command -v yum &> /dev/null; then
            $SUDO yum install -y pkgconfig openssl-devel gtk3-devel gdk-pixbuf-devel pango-devel
          elif command -v dnf &> /dev/null; then
            $SUDO dnf install -y pkgconfig openssl-devel
          elif command -v pacman &> /dev/null; then
            $SUDO pacman -Sy --noconfirm pkgconf openssl
          elif command -v apk &> /dev/null; then
            $SUDO apk add --no-cache pkgconfig openssl-dev
          else
            echo "Package manager not found. Please install pkg-config and openssl-dev manually."
            exit 1
          fi

      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: "1.98"
          components: rustfmt, clippy

      - name: Setup Rust cache
        uses: Swatinem/rust-cache@v2

      - name: Build
        run: cargo build --verbose

      - name: Run tests
        run: cargo test --verbose

      - name: Check formatting
        run: cargo fmt -- --check

      - name: Run clippy
        run: cargo clippy --workspace --all-targets --locked -- -D warnings

      - name: Check
        run: cargo check

      - name: Build release artifact
        run: cargo build --release --verbose

      - name: Strip binary
        run: strip target/release/markprompter

      # Built with `stable`, not with this project's pinned toolchain.
      #
      # cargo-deb 3.8.0 uses let-chains, which need rustc 1.88 — and it
      # declares `rust-version = 1.85`, so the MSRV field can't be
      # trusted to tell you that. Pinning the tool backwards doesn't
      # help either: 3.7.0 fails on 1.86 the same way.
      #
      # The pin exists to say what this crate compiles with. These are
      # build-time utilities that never link into the binary, so
      # constraining them by the project's MSRV buys nothing and breaks
      # every time the ecosystem moves. `--locked` keeps two runs a
      # month apart installing the same bytes.
      - name: Install the packaging tools
        run: |
          set -euo pipefail
          rustup toolchain install stable --profile minimal
          cargo +stable install cargo-deb --locked
          cargo +stable install cargo-generate-rpm --locked

      # Every CI build has to carry a distinct version, or the second
      # one publishes the same filename with different bytes and the
      # registry correctly refuses it with a 409 — and no machine that
      # installed the first ever sees the second.
      #
      # `+` rather than `-`: RPM forbids `-` in a version outright, and
      # in both ecosystems `0.2.0+dev.<stamp>` sorts *after* 0.2.0,
      # which is what you want from a repository you added in order to
      # run the latest build. A tag publishes the bare Cargo version.
      #
      # One stamp for the whole job, so the .deb and the .rpm from a
      # single run agree about what they are.
      - name: Stamp the package version
        id: version
        run: |
          set -euo pipefail
          base=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2)

          if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
            version="$base"
          else
            version="$base+dev.$(date -u +%s)"
          fi

          echo "version: $version"
          echo "version=$version" >> "$GITHUB_OUTPUT"

          # Rewritten after the release build, so the binary is cached
          # on the unstamped manifest and only the packaging sees this.
          sed -i "0,/^version = .*/s//version = \"$version\"/" Cargo.toml

      - name: Build the Debian package
        run: cargo deb --no-build --no-strip

      - name: Build the RPM package
        run: cargo generate-rpm

      # This repository has two remotes, and only one of them has a
      # package registry. `vars.CI_FORGE_URL` is what the forge injects
      # into a run; fall back to what the runner already knows.
      #
      # Not publishing is a legitimate outcome here, not an error:
      # github.com has no registry at these paths, and a workflow that
      # is permanently red on one remote teaches everyone to ignore it.
      # So this decides, and the publish step reads the decision.
      - name: Resolve the forge URL
        env:
          CI_FORGE_URL: ${{ vars.CI_FORGE_URL }}
        run: |
          set -euo pipefail
          forge_url=${CI_FORGE_URL:-${GITHUB_SERVER_URL:-}}

          if [ -z "$forge_url" ]; then
            echo "No forge URL available; not publishing."
            echo "Set a CI_FORGE_URL variable on this repository to publish."
            echo "PUBLISH=false" >> "$GITHUB_ENV"
            exit 0
          fi

          case "$forge_url" in
            *://github.com|*://github.com/*|*://www.github.com*)
              echo "Running on GitHub, which has no package registry here."
              echo "Set CI_FORGE_URL to publish from this remote as well."
              echo "PUBLISH=false" >> "$GITHUB_ENV"
              ;;
            *)
              echo "publishing to $forge_url"
              echo "FORGE_URL=$forge_url" >> "$GITHUB_ENV"
              echo "PUBLISH=true" >> "$GITHUB_ENV"
              ;;
          esac

      # `${{ github.token }}` is minted for this job and authorized to
      # publish to THIS repository's packages and nowhere else, so
      # there is no secret to create first.
      #
      # Pull requests don't publish: a PR build is a check, and a
      # registry that carries every proposed change is not a registry
      # anyone can install from.
      - name: Publish the .deb and the .rpm to the forge
        if: github.event_name != 'pull_request' && env.PUBLISH == 'true'
        env:
          REPOSITORY: ${{ github.repository }}
          TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail

          for deb in target/debian/*.deb; do
            echo "publishing $deb"
            curl --fail-with-body -sS \
              -u "gitgud-actions:$TOKEN" \
              -H "Content-Type: application/vnd.debian.binary-package" \
              --data-binary "@$deb" \
              "$FORGE_URL/pkg/deb/$REPOSITORY/upload?dist=stable&component=main"
            echo
          done

          for rpm in target/generate-rpm/*.rpm; do
            echo "publishing $rpm"
            curl --fail-with-body -sS \
              -u "gitgud-actions:$TOKEN" \
              -H "Content-Type: application/x-rpm" \
              --data-binary "@$rpm" \
              "$FORGE_URL/pkg/rpm/$REPOSITORY/upload"
            echo
          done

      - name: How to install it
        if: github.event_name != 'pull_request' && env.PUBLISH == 'true'
        env:
          OWNER: ${{ github.repository_owner }}
        run: |
          cat <<EOF
          # Debian / Ubuntu
          curl -fsSL $FORGE_URL/pkg/_keys/deb/$OWNER | sudo tee /etc/apt/keyrings/$OWNER.asc >/dev/null
          echo "deb [signed-by=/etc/apt/keyrings/$OWNER.asc] $FORGE_URL/pkg/deb/$OWNER/_all stable main" | sudo tee /etc/apt/sources.list.d/$OWNER.list
          sudo apt update && sudo apt install markprompter

          # Fedora / RHEL / openSUSE — /etc/yum.repos.d/$OWNER.repo
          [$OWNER]
          name=$OWNER
          baseurl=$FORGE_URL/pkg/rpm/$OWNER/_all
          enabled=1
          repo_gpgcheck=1
          # cargo-generate-rpm produces unsigned packages; the forge
          # signs the index, not what it was handed.
          gpgcheck=0
          gpgkey=$FORGE_URL/pkg/_keys/rpm/$OWNER
          EOF


  release:
    name: Release
    if: startsWith(github.ref, 'refs/tags/v')
    needs: build
    permissions:
      contents: write
      packages: write
    strategy:
      matrix:
        include:
          - os: ubuntu-latest
            runner: arc-runner-set
            name: markprompter-x86_64-linux-gnu.tar.gz
            archive_cmd: tar -czf
            binary_ext: ""
            rust: "1.98"
          - os: ubuntu-24.04-arm
            runner: ubuntu-24.04-arm
            name: markprompter-aarch64-linux-gnu.tar.gz
            archive_cmd: tar -czf
            binary_ext: ""
            rust: "1.98"
          - os: macos-latest
            runner: macos-latest
            name: markprompter-aarch64-macos.tar.gz
            archive_cmd: tar -czf
            binary_ext: ""
            rust: "1.98"
          - os: windows-latest
            runner: windows-latest
            name: markprompter-x86_64-windows.zip
            archive_cmd: 7z a
            binary_ext: ".exe"
            rust: "1.98"
    runs-on: ${{ matrix.runner }}

    steps:
      - uses: actions/checkout@v4

      - name: Install system dependencies (self-hosted Linux)
        if: runner.os == 'Linux'
        run: |
          # A hosted runner is an unprivileged user with sudo. A job
          # container is already root and usually ships no sudo at all,
          # so hardcoding it fails with `sudo: command not found`
          # before the first package is ever fetched. Ask what we are
          # rather than assuming either one.
          if [ "$(id -u)" -eq 0 ]; then
            SUDO=
          elif command -v sudo &> /dev/null; then
            SUDO=sudo
          else
            echo "Not root and no sudo available; cannot install system dependencies."
            exit 1
          fi

          if command -v apt-get &> /dev/null; then
            $SUDO apt-get update
            $SUDO apt-get install -y build-essential pkg-config libssl-dev libgtk-3-dev libgdk-pixbuf-2.0-dev libpango1.0-dev libglib2.0-dev
          elif command -v yum &> /dev/null; then
            $SUDO yum install -y pkgconfig openssl-devel gtk3-devel gdk-pixbuf-devel pango-devel
          elif command -v dnf &> /dev/null; then
            $SUDO dnf install -y pkgconfig openssl-devel
          elif command -v pacman &> /dev/null; then
            $SUDO pacman -Sy --noconfirm pkgconf openssl
          elif command -v apk &> /dev/null; then
            $SUDO apk add --no-cache pkgconfig openssl-dev
          else
            echo "Package manager not found. Please install pkg-config and openssl-dev manually."
            exit 1
          fi

      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: ${{ matrix.rust }}

      - name: Setup Rust cache
        uses: Swatinem/rust-cache@v2
        with:
          key: ${{ matrix.os }}-${{ matrix.rust }}

      - name: Build release binary
        run: cargo build --release --verbose

      - name: Strip binary (Unix)
        if: matrix.os != 'windows-latest'
        run: |
          strip target/release/markprompter${{ matrix.binary_ext }}

      - name: Create release archive (Unix)
        if: matrix.os != 'windows-latest'
        run: |
          cd target/release
          tar -czf ../../${{ matrix.name }} markprompter
          cd ../..

      - name: Create release archive (Windows)
        if: matrix.os == 'windows-latest'
        run: |
          cd target/release
          7z a ../../${{ matrix.name }} markprompter.exe
          cd ../..

      - name: Upload release archive
        uses: actions/upload-artifact@v4
        # Artifact storage is a GitHub feature; this forge has none, so
        # these steps have nothing to upload to. They used to run and
        # fail with `continue-on-error`, which is a red step on every
        # single run — and a build that is always a bit red teaches
        # everyone to stop reading it. Skipping says the same thing
        # honestly and leaves the badge meaningful.
        #
        # The packages are published to the forge's registries instead,
        # which is the thing anyone actually installs from.
        if: ${{ startsWith(github.server_url, 'https://github.com') }}
        with:
          name: ${{ matrix.name }}
          path: ${{ matrix.name }}

  create-release:
    name: Create Release
    runs-on: arc-runner-set
    # Makes a *GitHub* Release out of artifacts a GitHub runner uploaded.
    # Off GitHub it would download nothing and then fail, which is the
    # permanently-red build this workflow is trying to stop having.
    # Tagged builds still publish their packages — that happens in the
    # build job, which runs everywhere.
    if: >-
      startsWith(github.ref, 'refs/tags/v') &&
      startsWith(github.server_url, 'https://github.com')
    needs: release
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4

      - name: Download all artifacts
        uses: actions/download-artifact@v4
        if: ${{ startsWith(github.server_url, 'https://github.com') }}
        with:
          path: artifacts

      - name: Create release notes
        run: |
          echo "# MarkPrompter Release ${GITHUB_REF_NAME}" > release_notes.md
          echo "" >> release_notes.md
          echo "## Changes in this release" >> release_notes.md
          echo "" >> release_notes.md
          echo "## Supported Platforms" >> release_notes.md
          echo "" >> release_notes.md
          echo "- Linux (x86_64)" >> release_notes.md
          echo "- Linux (aarch64)" >> release_notes.md
          echo "- Windows (x86_64)" >> release_notes.md
          echo "- macOS (x86_64 and Apple Silicon)" >> release_notes.md
          echo "" >> release_notes.md
          echo "## Installation" >> release_notes.md
          echo "" >> release_notes.md
          echo "1. Download the appropriate archive for your platform" >> release_notes.md
          echo "2. Extract the binaries" >> release_notes.md
          echo "3. Place them in your PATH" >> release_notes.md
          echo "" >> release_notes.md

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v1
        with:
          files: artifacts/*/*.tar.gz,artifacts/*/*.zip
          body_path: release_notes.md
          draft: false
          prerelease: ${{ contains(github.ref_name, '-') }}
          generate_release_notes: true
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}