tw-cli 0.1.0

One command for a Markdoc corpus
# Release for tw — https://github.com/markdoc-rs/tw
#
# One tag publishes one binary per platform, all four from GitHub-hosted
# runners:
#
#   tw-linux-x86_64          CI runners, and Linux authoring
#   tw-windows-x86_64.exe    where most writers are
#   tw-macos-aarch64         Apple Silicon
#   tw-macos-x86_64          Intel Macs
#
# ## One workflow
#
# tw is hosted here, as markdoc-lint, markdoc-ls and markdoc-prose are, so
# one workflow covers every platform it ships, Windows builds natively
# rather than through cargo-xwin, and there is one place to download from.
#
# It is also the shape `tw update` already assumed: setup.rs asks
# api.github.com for this repository's latest release and looks for an
# asset named `tw-<slug>`.
#
# ## Why a bare binary and not a tar.gz
#
# markdoc-tools, the fork-era monorepo, shipped an archive because it had
# three binaries and a VERSION file to keep together. tw is one file, and
# `tw update` hands it to self_replace, which replaces the running
# executable in place. An archive would mean unpacking to a temporary
# directory before a swap that is already delicate on Windows.
name: Release

on:
  push:
    tags:
      - "[0-9]+.[0-9]+.[0-9]+"
      - "[0-9]+.[0-9]+.[0-9]+-*"
  workflow_dispatch:

permissions:
  contents: write

jobs:
  # ────────────────────────────────────────────────────────────────────
  # Nothing is published until this passes. Same check.sh ci.yml runs, so
  # a release cannot hold a build the ordinary gate would have rejected.
  # ────────────────────────────────────────────────────────────────────
  check:
    name: Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          path: tw

      - name: The tag and Cargo.toml have to agree
        working-directory: tw
        run: |
          set -eu
          case "${GITHUB_REF}" in
            refs/tags/*) ;;
            *) echo "::error::${GITHUB_REF} is not a tag." >&2; exit 1 ;;
          esac
          # `tw --version` is what a writer reports in a bug, so a release
          # whose binary disagrees with its tag is one nobody can reason
          # about afterwards.
          tag="${GITHUB_REF_NAME%%-*}"
          version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)"
          [ "$version" = "$tag" ] || {
            echo "::error::Cargo.toml says ${version}, tag says ${tag}" >&2; exit 1; }
          echo "tw ${tag}"

      - uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt, clippy

      - uses: Swatinem/rust-cache@v2
        with:
          workspaces: tw

      - name: check.sh
        run: bash tw/.github/scripts/check.sh tw

  # ────────────────────────────────────────────────────────────────────
  # The binary, per platform. One matrix, three runner images, one release.
  # ────────────────────────────────────────────────────────────────────
  build:
    name: ${{ matrix.slug }}
    needs: check
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          # musl and statically linked, so one Linux binary runs on a CI
          # image older than the runner that built it.
          - { slug: linux-x86_64,   os: ubuntu-latest,  target: x86_64-unknown-linux-musl, exe: "" }
          - { slug: windows-x86_64, os: windows-latest, target: x86_64-pc-windows-msvc,    exe: ".exe" }
          # macos-latest is Apple Silicon, and x86_64-apple-darwin
          # cross-compiles from it because Xcode ships both slices of the
          # SDK. Safe here: what tw links that is not Rust, ring's C and psm's
          # assembly (markdoc's stack growth), Xcode's clang builds for
          # either slice.
          - { slug: macos-aarch64,  os: macos-latest,   target: aarch64-apple-darwin,      exe: "" }
          - { slug: macos-x86_64,   os: macos-latest,   target: x86_64-apple-darwin,       exe: "" }

    steps:
      - uses: actions/checkout@v4
        with:
          path: tw

      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}

      - uses: Swatinem/rust-cache@v2
        with:
          workspaces: tw
          key: ${{ matrix.slug }}

      # tw links ureq, whose TLS is ring, which compiles C, and markdoc's
      # stack growth (stacker, through psm) assembles a few lines with the C
      # compiler too. On musl that needs a musl-targeting one; the other
      # three targets have one.
      - name: musl toolchain
        if: matrix.target == 'x86_64-unknown-linux-musl'
        run: sudo apt-get update && sudo apt-get install -y musl-tools

      - name: Build
        shell: bash
        working-directory: tw
        run: |
          set -eu
          if [ "${{ matrix.target }}" = "x86_64-unknown-linux-musl" ]; then
            export CC_x86_64_unknown_linux_musl=musl-gcc
            export RUSTFLAGS="-C target-feature=+crt-static"
          fi
          cargo build --release --target "${{ matrix.target }}"

      - name: Name it for the platform
        shell: bash
        run: |
          set -eu
          asset="tw-${{ matrix.slug }}${{ matrix.exe }}"
          cp "tw/target/${{ matrix.target }}/release/tw${{ matrix.exe }}" "$asset"

          # No pipeline. `set -e` does not fire on a non-final command in
          # one, so a hasher that failed would leave an EMPTY .sha256 and
          # this step would report success -- and tw refuses a download it
          # cannot verify, so every consumer would break on a release that
          # looked fine here. The asset is already named for the platform,
          # so neither hasher needs its output rewritten.
          # sha256sum on Linux and Git Bash, shasum on macOS.
          if command -v sha256sum >/dev/null 2>&1; then
            sha256sum "$asset" > "$asset.sha256"
          else
            shasum -a 256 "$asset" > "$asset.sha256"
          fi
          # The step checks its own work: a published checksum that does
          # not check out is worse than none, because a consumer that
          # verifies refuses the release and one that does not never
          # finds out.
          if command -v sha256sum >/dev/null 2>&1; then
            sha256sum -c "$asset.sha256"
          else
            shasum -a 256 -c "$asset.sha256"
          fi
          echo "--- $asset: $(wc -c < "$asset") bytes"

      # A binary that will not start is worse than one that was never built:
      # the consumer caches it and reports a tool that is "installed". Every
      # target but the Intel Mac cross-build can run on the runner that
      # produced it.
      - name: It runs, and it finds things
        if: matrix.slug != 'macos-x86_64'
        shell: bash
        run: |
          set -eu
          asset="./tw-${{ matrix.slug }}${{ matrix.exe }}"
          chmod +x "$asset"
          "$asset" --version
          mkdir -p corpus
          : > corpus/markdoc-lint.toml
          printf '# Not fine\n\n{%% callout %%}\nbody\n' > corpus/bad.md
          set +e
          "$asset" check --no-prose --json --root corpus > report.json
          code=$?
          set -e
          cat report.json
          # Exit 1 is "found things", and it is what a hook and a CI step
          # both read. A green run on a corpus with a broken tag in it is
          # the failure this step exists to catch.
          [ "$code" = 1 ] || { echo "::error::tw check exited ${code} on a corpus with an unclosed tag" >&2; exit 1; }
          grep -q '"rule": "unclosed-tag"' report.json || {
              echo "::error::tw check did not report the unclosed tag" >&2; exit 1; }

      - name: Publish to the release
        uses: softprops/action-gh-release@v2
        with:
          files: |
            tw-${{ matrix.slug }}${{ matrix.exe }}
            tw-${{ matrix.slug }}${{ matrix.exe }}.sha256
          prerelease: ${{ contains(github.ref_name, '-') }}