name: Release
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
id-token: write
attestations: 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
- 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
- uses: taiki-e/install-action@cargo-cyclonedx
- name: SBOM
env:
GH_TOKEN: ${{ github.token }}
run: |
cargo cyclonedx -F "acme auth mimalloc gateway-api" --format json --spec-version 1.5 --override-filename "rws-$TAG.cdx"
gh release upload "$TAG" "rws-$TAG.cdx.json" --clobber
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, features: "acme auth mimalloc gateway-api wasm-plugins" }
- { target: aarch64-unknown-linux-musl, os: ubuntu-24.04-arm, features: "acme auth mimalloc gateway-api wasm-plugins" }
- { target: x86_64-apple-darwin, os: macos-latest, features: "acme auth mimalloc wasm-plugins" }
- { target: aarch64-apple-darwin, os: macos-latest, features: "acme auth mimalloc wasm-plugins" }
- { target: x86_64-pc-windows-msvc, os: windows-latest, features: "acme auth mimalloc wasm-plugins" }
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 }}
- uses: taiki-e/install-action@cargo-auditable
- name: Build
run: cargo auditable build --release --bin rws --features "${{ matrix.features }}" --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: Attest build provenance
uses: actions/attest-build-provenance@v2
with:
subject-path: ${{ env.ARCHIVE }}
- name: Upload to release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "$TAG" "$ARCHIVE" "$ARCHIVE.sha256" --clobber
- 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:
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
- 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 }}
- name: Build and push
id: push
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
- name: Attest image provenance
uses: actions/attest-build-provenance@v2
with:
subject-name: ghcr.io/${{ github.repository_owner }}/rws
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
- uses: sigstore/cosign-installer@v3
- name: Sign the image
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: |
cosign sign --yes "ghcr.io/${{ github.repository_owner }}/rws@$DIGEST"
if [ -n "$DOCKERHUB_USERNAME" ] && [ -n "$DOCKERHUB_TOKEN" ]; then
cosign sign --yes "docker.io/$DOCKERHUB_USERNAME/rws@$DIGEST"
fi
packages:
needs: [release, binaries]
runs-on: ubuntu-latest
env:
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.TAG }}
- uses: actions/download-artifact@v4
with:
pattern: docker-*-linux-musl
- name: Build
env:
KEY: ${{ secrets.PACKAGES_GPG_KEY }}
NFPM_RPM_PASSPHRASE: ${{ secrets.PACKAGES_GPG_PASSPHRASE }}
run: |
if [ ! -x packaging/build-packages.sh ]; then echo "no packaging/ in $TAG"; exit 0; fi
curl -sSfL "https://github.com/goreleaser/nfpm/releases/download/v$NFPM_VERSION/nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz" \
| sudo tar xz -C /usr/local/bin nfpm
if [ -n "$KEY" ]; then
printf '%s\n' "$KEY" > "$RUNNER_TEMP/signing.asc"
export RPM_SIGNING_KEY_FILE="$RUNNER_TEMP/signing.asc"
else
echo "::warning::PACKAGES_GPG_KEY not set: rpm packages unsigned"
fi
packaging/build-packages.sh "$TAG" amd64 docker-x86_64-unknown-linux-musl/rws packages
packaging/build-packages.sh "$TAG" arm64 docker-aarch64-unknown-linux-musl/rws packages
rm -f "$RUNNER_TEMP/signing.asc"
cd packages && for f in *.deb *.rpm; do sha256sum "$f" > "$f.sha256"; done && ls -l
- name: Attest build provenance
if: hashFiles('packages/*.deb') != ''
uses: actions/attest-build-provenance@v2
with:
subject-path: packages/*.[dr][ep][bm]
- name: Upload to release
if: hashFiles('packages/*.deb') != ''
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "$TAG" packages/* --clobber
site:
needs: [release, packages]
if: ${{ !cancelled() && needs.release.result == 'success' && needs.release.outputs.newest == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.TAG }}
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Build
working-directory: site
run: npm ci --no-audit --no-fund && npm run build
- name: Package repositories
env:
GH_TOKEN: ${{ github.token }}
KEY: ${{ secrets.PACKAGES_GPG_KEY }}
GPG_PASSPHRASE: ${{ secrets.PACKAGES_GPG_PASSPHRASE }}
run: |
if [ -z "$KEY" ]; then echo "::warning::PACKAGES_GPG_KEY not set: apt/dnf repositories not in the site archive (the server keeps its current ones)"; exit 0; fi
gh release download "$TAG" --pattern '*.deb' --pattern '*.rpm' -D "$RUNNER_TEMP/packages"
sudo apt-get update -q && sudo apt-get install -y -q apt-utils createrepo-c
printf '%s\n' "$KEY" | gpg --batch --import
export GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/ {print $10; exit}')
packaging/build-repos.sh "$RUNNER_TEMP/packages" site/dist https://rws8.tech
- uses: dtolnay/rust-toolchain@stable
- name: Docs MCP server
run: |
cargo build --release --locked --manifest-path site/mcp/Cargo.toml
mcp="rws-docs-mcp-$TAG-x86_64-unknown-linux-gnu.tar.gz"
tar -C site/mcp/target/release -czf "$mcp" rws-docs-mcp
sha256sum "$mcp" > "$mcp.sha256"
- name: Archive
run: |
tar -C site/dist --exclude=./.vite -czf "rws8-site-$TAG.tar.gz" .
sha256sum "rws8-site-$TAG.tar.gz" > "rws8-site-$TAG.tar.gz.sha256"
- name: Attest
uses: actions/attest-build-provenance@v2
with:
subject-path: |
rws8-site-${{ env.TAG }}.tar.gz
rws-docs-mcp-${{ env.TAG }}-x86_64-unknown-linux-gnu.tar.gz
- name: Upload to release
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "$TAG" rws8-site-"$TAG".tar.gz* rws-docs-mcp-"$TAG"-*.tar.gz* --clobber
chart:
needs: [release, docker]
if: needs.release.outputs.newest == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.TAG }}
- name: Publish
env:
GH_TOKEN: ${{ github.token }}
run: |
repo="oci://ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/charts"
echo "$GH_TOKEN" | helm registry login ghcr.io -u "${{ github.actor }}" --password-stdin
if helm show chart "$repo/rws" --version "$TAG" >/dev/null 2>&1; then
echo "chart $TAG already published"; exit 0
fi
helm lint --strict helm/rws
helm package helm/rws --version "$TAG" --app-version "$TAG" -d dist
helm push "dist/rws-$TAG.tgz" "$repo"
helm show chart "$repo/rws" --version "$TAG"
homebrew:
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