rust-web-server 17.113.2

Single-binary web server and reverse proxy: automatic HTTPS (Let's Encrypt), HTTP/1.1, HTTP/2 and HTTP/3, load balancing, auth and rate limiting, configured in one TOML file. Also a Rust library: routing, middleware, MCP server, ORM, background jobs. No hyper, no Tower; HTTP/2 and HTTP/3 framing via h2 and quinn.
Documentation
name: Release

# Push a version tag (same scheme as Cargo.toml, no "v" prefix — e.g. 17.111.0)
# and this:
#   1. publishes a GitHub Release whose notes list every feat/fix/perf commit
#      since the previous tag,
#   2. attaches prebuilt `rws` binaries (static musl on Linux) + .sha256 files,
#      named the way [package.metadata.binstall] in Cargo.toml expects,
#   3. smoke-tests, then pushes a multi-arch image (Dockerfile.release, built
#      from those binaries) to ghcr.io/bohdaq/rws and — when DOCKERHUB_USERNAME
#      / DOCKERHUB_TOKEN are configured — to Docker Hub, tagged <version>,
#      <major> and latest (the Docker Hub overview is synced separately by
#      dockerhub-overview.yml),
#   4. updates the Homebrew formula in bohdaq/homebrew-tap.
#
# Re-running for an existing tag (e.g. after fixing this workflow) is
# supported via workflow_dispatch; every step is idempotent.
on:
  push:
    tags: ["[0-9]+.[0-9]+.[0-9]+"]
  workflow_dispatch:
    inputs:
      tag:
        description: Existing version tag to (re)build, e.g. 17.111.0
        required: true

permissions:
  contents: write
  packages: write

env:
  TAG: ${{ inputs.tag || github.ref_name }}
  CARGO_TERM_COLOR: always

jobs:
  release:
    runs-on: ubuntu-latest
    outputs:
      newest: ${{ steps.newest.outputs.newest }}
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ env.TAG }}
          fetch-depth: 0

      - name: Check tag matches Cargo.toml
        run: |
          crate_version=$(grep -m1 '^version' Cargo.toml | cut -d '"' -f2)
          if [ "$crate_version" != "$TAG" ]; then
            echo "Tag $TAG does not match Cargo.toml version $crate_version" >&2
            exit 1
          fi

      # A patch for an older line (e.g. 17.113.1 after 18.0.0) must not become
      # the Latest release, Docker `latest` or the Homebrew formula.
      - name: Is this the highest version?
        id: newest
        run: |
          highest=$(git tag --list '[0-9]*.[0-9]*.[0-9]*' | sort -V | tail -1)
          if [ "$highest" = "$TAG" ]; then echo "newest=true" >> "$GITHUB_OUTPUT"; else echo "newest=false" >> "$GITHUB_OUTPUT"; fi
          echo "highest tag: $highest"

      - name: Build release notes
        run: |
          prev=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)
          range=${prev:+$prev..}$TAG
          {
            echo "## Install"
            echo
            echo '```bash'
            echo "brew install bohdaq/tap/rws                       # macOS / Linux"
            echo "cargo binstall rust-web-server                     # prebuilt binary, no compile"
            echo "docker run -p 7878:7878 bohdaq/rws:$TAG"
            echo '```'
            echo
            echo "Or download a binary below (Linux binaries are static musl builds)."
            echo
            breaking=$(git log --format='- %s (%h)' "$range" | grep -E '^- [a-z]+(\([^)]*\))?!:' || true)
            if [ -n "$breaking" ]; then
              printf '## ⚠️ Breaking changes\n\n%s\n\n' "$breaking"
            fi
            for kind in feat fix perf; do
              case $kind in
                feat) title="Features" ;;
                fix)  title="Fixes" ;;
                perf) title="Performance" ;;
              esac
              lines=$(git log --format='- %s (%h)' "$range" | grep -E "^- $kind(\(|:)" || true)
              if [ -n "$lines" ]; then
                printf '## %s\n\n%s\n\n' "$title" "$lines"
              fi
            done
            if [ -n "$prev" ]; then
              echo "**Full diff:** https://github.com/$GITHUB_REPOSITORY/compare/$prev...$TAG"
            fi
            echo
            echo "Crate: https://crates.io/crates/rust-web-server · Docs: https://rws8.tech"
          } > notes.md
          cat notes.md

      - name: Publish GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
          NEWEST: ${{ steps.newest.outputs.newest }}
        run: |
          if gh release view "$TAG" >/dev/null 2>&1; then
            gh release edit "$TAG" --notes-file notes.md
          else
            gh release create "$TAG" --title "$TAG" --notes-file notes.md --verify-tag --latest="$NEWEST"
          fi

  # crates.io: rws-macros first (rust-web-server depends on it), each skipped
  # when that version is already published, so re-runs are safe.
  crates:
    needs: release
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ env.TAG }}
      - uses: dtolnay/rust-toolchain@stable
      - name: Publish rws-macros, then rust-web-server
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: |
          [ -n "$CARGO_REGISTRY_TOKEN" ] || { echo "the CARGO_REGISTRY_TOKEN secret is not set" >&2; exit 1; }
          published() {
            curl -sf -A "rws-release (github.com/bohdaq/rust-web-server)" "https://crates.io/api/v1/crates/$1/$2" > /dev/null
          }
          # --locked only where the branch tracks Cargo.lock (17.113.x doesn't).
          locked=""
          [ -f Cargo.lock ] && locked="--locked"
          macros=$(grep -m1 '^version' rws-macros/Cargo.toml | cut -d '"' -f2)
          if published rws-macros "$macros"; then echo "rws-macros $macros is already on crates.io"; else cargo publish -p rws-macros $locked; fi
          if published rust-web-server "$TAG"; then echo "rust-web-server $TAG is already on crates.io"; else cargo publish -p rust-web-server $locked; fi

  binaries:
    needs: release
    name: binary (${{ matrix.target }})
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { target: x86_64-unknown-linux-musl,  os: ubuntu-latest }
          - { target: aarch64-unknown-linux-musl, os: ubuntu-24.04-arm }
          - { target: x86_64-apple-darwin,        os: macos-latest }
          - { target: aarch64-apple-darwin,       os: macos-latest }
          - { target: x86_64-pc-windows-msvc,     os: windows-latest }
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ env.TAG }}
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}
      - name: Install musl tools
        if: contains(matrix.target, 'musl')
        run: sudo apt-get update && sudo apt-get install -y musl-tools
      - name: Install NASM (aws-lc-sys on Windows)
        if: runner.os == 'Windows'
        uses: ilammy/setup-nasm@v1
      - uses: Swatinem/rust-cache@v2
        with:
          key: ${{ matrix.target }}

      - name: Build
        # acme: automatic HTTPS; auth: JWT / basic auth in config-driven routes.
        run: cargo build --release --bin rws --features "acme auth" --target ${{ matrix.target }}

      - name: Package
        shell: bash
        run: |
          name="rws-$TAG-${{ matrix.target }}"
          mkdir "$name"
          cp -R README.md LICENSE "$name/"
          if [ "${{ runner.os }}" = "Windows" ]; then
            cp "target/${{ matrix.target }}/release/rws.exe" "$name/"
            7z a "$name.zip" "$name" >/dev/null
            archive="$name.zip"
          else
            cp "target/${{ matrix.target }}/release/rws" "$name/"
            tar czf "$name.tar.gz" "$name"
            archive="$name.tar.gz"
          fi
          if command -v sha256sum >/dev/null; then
            sha256sum "$archive" > "$archive.sha256"
          else
            shasum -a 256 "$archive" > "$archive.sha256"
          fi
          echo "ARCHIVE=$archive" >> "$GITHUB_ENV"

      - name: Smoke test
        if: runner.os != 'Windows' && matrix.target != 'x86_64-apple-darwin'
        shell: bash
        run: |
          # Run from an empty directory, like a fresh install would.
          bin="$PWD/rws-$TAG-${{ matrix.target }}/rws"
          mkdir -p "$RUNNER_TEMP/smoke" && cd "$RUNNER_TEMP/smoke"
          "$bin" --port=7979 &
          pid=$!
          for i in $(seq 1 30); do
            curl -sf http://127.0.0.1:7979/healthz && break
            sleep 1
          done
          curl -sf http://127.0.0.1:7979/healthz
          kill $pid

      - name: Upload to release
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
        run: gh release upload "$TAG" "$ARCHIVE" "$ARCHIVE.sha256" --clobber

      # Dockerfile.release copies these in per platform.
      - name: Keep Linux binary for the image build
        if: contains(matrix.target, 'linux')
        uses: actions/upload-artifact@v4
        with:
          name: docker-${{ matrix.target }}
          path: target/${{ matrix.target }}/release/rws

  docker:
    needs: [release, binaries]
    runs-on: ubuntu-latest
    env:
      # Docker Hub publishing is skipped until both are configured
      # (repository variable DOCKERHUB_USERNAME, secret DOCKERHUB_TOKEN —
      # a Docker Hub personal access token with Read & Write scope).
      DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }}
      DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ env.TAG }}
      - uses: actions/download-artifact@v4
        with:
          name: docker-x86_64-unknown-linux-musl
          path: dist/amd64
      - uses: actions/download-artifact@v4
        with:
          name: docker-aarch64-unknown-linux-musl
          path: dist/arm64
      - run: chmod +x dist/*/rws
      - uses: docker/setup-qemu-action@v3
      - uses: docker/setup-buildx-action@v3

      # Smoke-test the image before publishing it: the 17.111/17.112 images
      # crashed at startup (no `whoami` in distroless) and nothing caught it.
      - name: Build amd64 image for testing
        uses: docker/build-push-action@v6
        with:
          context: .
          file: Dockerfile.release
          platforms: linux/amd64
          load: true
          tags: rws-smoke:test
      - name: Smoke-test the image
        run: |
          mkdir -p "$RUNNER_TEMP/site" && echo "smoke" > "$RUNNER_TEMP/site/index.html"
          docker run -d --name rws-smoke -p 7878:7878 -v "$RUNNER_TEMP/site:/srv:ro" rws-smoke:test
          for i in $(seq 1 30); do curl -sf http://127.0.0.1:7878/healthz && break; sleep 1; done
          curl -sf http://127.0.0.1:7878/healthz
          [ "$(curl -sf http://127.0.0.1:7878/)" = "smoke" ]
          [ "$(docker run --rm rws-smoke:test --version)" = "rws $TAG" ]
          # /data must belong to the image's nonroot user (65532) — ACME writes
          # certificates there.
          docker create --name rws-data-check rws-smoke:test >/dev/null
          docker cp rws-data-check:/data - | tar --numeric-owner -tvf - | head -1 | grep -q " 65532/65532 "
          docker rm rws-data-check >/dev/null
          docker logs rws-smoke 2>&1 | tail -5
          docker rm -f rws-smoke

      - name: Tags
        id: tags
        run: |
          major="${TAG%%.*}"
          # `latest` only for the highest version.
          latest=""
          [ "${{ needs.release.outputs.newest }}" = "true" ] && latest=1
          tags="ghcr.io/${{ github.repository_owner }}/rws:$TAG,ghcr.io/${{ github.repository_owner }}/rws:$major${latest:+,ghcr.io/${{ github.repository_owner }}/rws:latest}"
          if [ -n "$DOCKERHUB_USERNAME" ] && [ -n "$DOCKERHUB_TOKEN" ]; then
            tags="$tags,docker.io/$DOCKERHUB_USERNAME/rws:$TAG,docker.io/$DOCKERHUB_USERNAME/rws:$major${latest:+,docker.io/$DOCKERHUB_USERNAME/rws:latest}"
          else
            echo "::warning::DOCKERHUB_USERNAME / DOCKERHUB_TOKEN not set — publishing to ghcr.io only"
          fi
          echo "tags=$tags" >> "$GITHUB_OUTPUT"
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ github.token }}
      - uses: docker/login-action@v3
        if: env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != ''
        with:
          username: ${{ env.DOCKERHUB_USERNAME }}
          password: ${{ env.DOCKERHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          file: Dockerfile.release
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.tags.outputs.tags }}
          labels: |
            org.opencontainers.image.source=https://github.com/${{ github.repository }}
            org.opencontainers.image.version=${{ env.TAG }}
            org.opencontainers.image.licenses=MIT
            org.opencontainers.image.title=rws
            org.opencontainers.image.description=Single-binary web server and reverse proxy: automatic HTTPS, HTTP/3, load balancing, configured in one TOML file
            org.opencontainers.image.url=https://rws8.tech

  homebrew:
    # The formula follows the highest version only.
    needs: [release, binaries]
    if: needs.release.outputs.newest == 'true'
    runs-on: ubuntu-latest
    steps:
      - name: Fetch checksums
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern '*.tar.gz.sha256'
          for t in aarch64-apple-darwin x86_64-apple-darwin aarch64-unknown-linux-musl x86_64-unknown-linux-musl; do
            echo "SHA_$(echo $t | tr 'a-z-' 'A-Z_')=$(cut -d ' ' -f1 rws-$TAG-$t.tar.gz.sha256)" >> "$GITHUB_ENV"
          done

      - uses: actions/checkout@v4
        with:
          repository: ${{ github.repository_owner }}/homebrew-tap
          ssh-key: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
          path: tap

      - name: Write formula
        run: |
          base="https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG"
          mkdir -p tap/Formula
          cat > tap/Formula/rws.rb <<EOF
          class Rws < Formula
            desc "HTTP/1.1, HTTP/2 and HTTP/3 web server, reverse proxy and app framework"
            homepage "https://rws8.tech"
            version "$TAG"
            license "MIT"

            on_macos do
              on_arm do
                url "$base/rws-$TAG-aarch64-apple-darwin.tar.gz"
                sha256 "$SHA_AARCH64_APPLE_DARWIN"
              end
              on_intel do
                url "$base/rws-$TAG-x86_64-apple-darwin.tar.gz"
                sha256 "$SHA_X86_64_APPLE_DARWIN"
              end
            end

            on_linux do
              on_arm do
                url "$base/rws-$TAG-aarch64-unknown-linux-musl.tar.gz"
                sha256 "$SHA_AARCH64_UNKNOWN_LINUX_MUSL"
              end
              on_intel do
                url "$base/rws-$TAG-x86_64-unknown-linux-musl.tar.gz"
                sha256 "$SHA_X86_64_UNKNOWN_LINUX_MUSL"
              end
            end

            def install
              bin.install "rws"
            end

            test do
              port = free_port
              pid = spawn bin/"rws", "--port=#{port}"
              sleep 2
              assert_match "OK", shell_output("curl -s http://127.0.0.1:#{port}/healthz")
            ensure
              Process.kill("TERM", pid)
            end
          end
          EOF
          cat tap/Formula/rws.rb

      - name: Push formula
        working-directory: tap
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add Formula/rws.rb
          git diff --cached --quiet && exit 0
          git commit -m "rws $TAG"
          git push