Skip to content

Merge pull request #24 from tomtom215/claude/project-review-modernize… #4

Merge pull request #24 from tomtom215/claude/project-review-modernize…

Merge pull request #24 from tomtom215/claude/project-review-modernize… #4

Workflow file for this run

# Release pipeline for Mallard Metrics
#
# Triggered by pushing a semver tag:
# git tag -a v0.2.0 -m "Release v0.2.0" && git push origin v0.2.0
#
# Job dependency graph:
#
# validate ──┬──► ci (matrix) ──┬──► build-binaries (matrix) ──┬──► docker-image ────┬──► github-release
# └──► security ────┘ └────────────────────┘
name: Release
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+" # stable: v1.2.3
- "v[0-9]+.[0-9]+.[0-9]+-*" # pre-release: v1.2.3-alpha.1
# Prevent concurrent releases for the same ref.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
# Minimal default — individual jobs escalate only what they need.
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
RUSTFLAGS: "-D warnings"
# ── Job 1: Validate tag & extract metadata ────────────────────────────────────
jobs:
validate:
name: Validate release tag
runs-on: ubuntu-latest
outputs:
version: ${{ steps.meta.outputs.version }}
is_prerelease: ${{ steps.meta.outputs.is_prerelease }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Extract version metadata
id: meta
shell: bash
run: |
TAG="${GITHUB_REF_NAME}"
echo "::group::Tag metadata"
printf 'Tag: %s\n' "$TAG"
# Validate the tag itself (not the stripped version) so that a
# non-`v`-prefixed tag like "1.2.3" is rejected even if a future
# trigger change (workflow_dispatch, etc.) bypasses the tag filter.
TAG_RE='^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?(\+[A-Za-z0-9.]+)?$'
if [[ ! "$TAG" =~ $TAG_RE ]]; then
echo "::error::Tag '$TAG' is not a valid semantic version."
echo "Expected format: vX.Y.Z or vX.Y.Z-pre.1"
exit 1
fi
VERSION="${TAG#v}"
printf 'Version: %s\n' "$VERSION"
if [[ "$VERSION" =~ - ]]; then
IS_PRE=true
else
IS_PRE=false
fi
printf 'Pre-release: %s\n' "$IS_PRE"
echo "::endgroup::"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "is_prerelease=$IS_PRE" >> "$GITHUB_OUTPUT"
- name: Verify Cargo.toml version matches tag
shell: bash
run: |
TAG_VER="${{ steps.meta.outputs.version }}"
CARGO_VER=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
echo "::group::Version consistency"
printf 'Tag version: %s\n' "$TAG_VER"
printf 'Cargo version: %s\n' "$CARGO_VER"
echo "::endgroup::"
if [[ "$CARGO_VER" != "$TAG_VER" ]]; then
echo "::error file=Cargo.toml::Cargo.toml version ($CARGO_VER) != tag ($TAG_VER)."
echo "Update Cargo.toml to match the tag before releasing."
exit 1
fi
echo "::notice::Cargo.toml version matches tag: $TAG_VER"
- name: Verify CHANGELOG entry exists
shell: bash
run: |
VERSION="${{ steps.meta.outputs.version }}"
if ! grep -qE "^## \[$VERSION\]" CHANGELOG.md; then
echo "::error file=CHANGELOG.md::No '## [$VERSION]' entry in CHANGELOG.md."
echo "Add release notes before tagging."
exit 1
fi
echo "::notice::CHANGELOG.md entry found for v$VERSION"
# ── Job 2: Full CI gate (parallel matrix) ─────────────────────────────────────
ci:
name: CI / ${{ matrix.name }}
needs: validate
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
include:
- name: "test"
command: cargo test --locked --all-targets
- name: "clippy"
components: clippy
command: cargo clippy --all-targets --all-features -- -D warnings
- name: "fmt"
components: rustfmt
command: cargo fmt -- --check
- name: "doc"
command: cargo doc --no-deps
rustdocflags: "-D warnings"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772
with:
toolchain: "1.98.0"
components: ${{ matrix.components || '' }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: ${{ matrix.name }}
run: ${{ matrix.command }}
env:
RUSTDOCFLAGS: ${{ matrix.rustdocflags || '' }}
# ── Job 3: Security audit ─────────────────────────────────────────────────────
security:
name: Security audit (cargo-deny)
needs: validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: EmbarkStudios/cargo-deny-action@82eb9f621fbc699dd0918f3ea06864c14cc84246 # v2
with:
command: check
arguments: --all-features
# ── Job 4: Build release binaries (cross-platform matrix) ─────────────────────
build-binaries:
name: Build / ${{ matrix.target }}
needs: [ci, security]
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
archive: mallard-metrics-x86_64-unknown-linux-musl.tar.gz
cross: true
docker_arch: amd64
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
archive: mallard-metrics-aarch64-unknown-linux-musl.tar.gz
cross: true
docker_arch: arm64
# Both Darwin builds run on macos-14 (Apple Silicon). The aarch64
# build is native; the x86_64 build cross-compiles using Apple's
# system clang + universal SDK, which `cc-rs` and `cargo` drive
# automatically from `--target x86_64-apple-darwin`. This replaces
# the previous `macos-13` Intel runner, which GitHub has deprecated
# (`macos-13-us-default` is no longer available).
- target: x86_64-apple-darwin
os: macos-14
archive: mallard-metrics-x86_64-apple-darwin.tar.gz
- target: aarch64-apple-darwin
os: macos-14
archive: mallard-metrics-aarch64-apple-darwin.tar.gz
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772
with:
toolchain: "1.98.0"
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: ${{ matrix.target }}
# Install `cross` for Linux targets. We use cross for BOTH musl targets
# (x86_64 and aarch64) because libduckdb-sys compiles bundled C++ via
# `cc-rs`, which requires a full `*-linux-musl-g++` toolchain — not
# provided by Ubuntu's `musl-tools` apt package. The cross Docker images
# ship a complete musl C/C++ toolchain, so a single path handles both.
- name: Install cross
if: matrix.cross
uses: taiki-e/install-action@37f7c5781271959fb65b6b35224e28652ff2b63d # v2.87.0
with:
tool: cross
# Native build (x86_64-linux, macOS targets)
- name: Build (native)
if: "!matrix.cross"
run: cargo build --locked --release --target ${{ matrix.target }}
# Cross-compiled build (aarch64-linux)
- name: Build (cross)
if: matrix.cross
run: cross build --locked --release --target ${{ matrix.target }}
- name: Create archive
shell: bash
run: |
BINARY="target/${{ matrix.target }}/release/mallard-metrics"
ARCHIVE="${{ matrix.archive }}"
echo "::group::Binary details"
ls -lh "$BINARY"
file "$BINARY"
echo "::endgroup::"
tar czf "$ARCHIVE" -C "target/${{ matrix.target }}/release" mallard-metrics
# sha256sum on Linux, shasum on macOS
if command -v sha256sum &>/dev/null; then
sha256sum "$ARCHIVE" > "${ARCHIVE}.sha256"
else
shasum -a 256 "$ARCHIVE" > "${ARCHIVE}.sha256"
fi
echo "::notice::Archive created: $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"
- name: Upload binary archive
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.archive }}
path: |
${{ matrix.archive }}
${{ matrix.archive }}.sha256
retention-days: 90
if-no-files-found: error
# Upload raw Linux binaries for Docker image assembly
- name: Upload Docker binary
if: matrix.docker_arch
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docker-bin-${{ matrix.docker_arch }}
path: target/${{ matrix.target }}/release/mallard-metrics
retention-days: 1
if-no-files-found: error
# ── Job 5: Multi-arch Docker image ────────────────────────────────────────────
docker-image:
name: Docker image (GHCR)
needs: [validate, build-binaries]
runs-on: ubuntu-latest
# Expose the immutable image digest to downstream jobs (github-release
# embeds it in release notes for verifiable pulls).
outputs:
digest: ${{ steps.push.outputs.digest }}
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download linux/amd64 binary
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: docker-bin-amd64
path: dist/amd64
- name: Download linux/arm64 binary
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: docker-bin-arm64
path: dist/arm64
- name: Prepare binaries
run: |
chmod +x dist/amd64/mallard-metrics dist/arm64/mallard-metrics
echo "::group::Binary verification"
file dist/amd64/mallard-metrics
file dist/arm64/mallard-metrics
ls -lh dist/*/mallard-metrics
echo "::endgroup::"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Set up QEMU (for multi-arch manifest)
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix=
# Inline Dockerfile using pre-built static binaries.
# No compilation happens here — just copies the correct binary for each
# target platform into a FROM scratch image.
- name: Write release Dockerfile
run: |
cat > Dockerfile.release <<'EOF'
# A `scratch` image has no trust store, and DuckDB downloads the
# `behavioral` community extension over HTTPS on first run. Alpine is
# here only to supply those roots; nothing from it ships in the final
# image except the ~200 KB bundle.
# Pinned to the build platform: the bundle is a text file with no
# architecture, so emulating an arm64 Alpine just to read it would
# cost QEMU time for nothing.
FROM --platform=$BUILDPLATFORM alpine:3.22 AS certs
RUN apk add --no-cache ca-certificates
FROM scratch
ARG TARGETARCH
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
# Mirrors the from-source Dockerfile exactly. It previously did not:
# the released image carried no OCI labels (so GHCR could not link it
# back to this repository or state its licence) and no HEALTHCHECK
# (so `docker run` and Swarm had no readiness signal, even though the
# binary ships one specifically because `scratch` has no shell).
LABEL org.opencontainers.image.title="Mallard Metrics" \
org.opencontainers.image.description="Self-hosted, privacy-focused web analytics powered by DuckDB and the behavioral extension" \
org.opencontainers.image.source="https://github.com/tomtom215/mallardmetrics" \
org.opencontainers.image.licenses="AGPL-3.0-only"
COPY dist/${TARGETARCH}/mallard-metrics /mallard-metrics
# Non-root uid; `scratch` has no /etc/passwd, so it must be numeric.
USER 65532:65532
ENV MALLARD_DATA_DIR=/data
# DuckDB's default extension directory is $HOME/.duckdb, and this
# image has no home directory; the data volume is the writable one.
ENV MALLARD_EXTENSION_DIR=/data/extensions
EXPOSE 8000
VOLUME ["/data"]
# Exec form: there is no shell in the image to parse a string form.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["/mallard-metrics", "--healthcheck"]
ENTRYPOINT ["/mallard-metrics"]
EOF
sed -i 's/^ //' Dockerfile.release
- name: Build and push multi-arch image
id: push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: Dockerfile.release
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Attest Docker image provenance (SLSA)
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
- name: Generate step summary
shell: bash
run: |
VERSION="${{ needs.validate.outputs.version }}"
{
echo "## Docker image published"
echo ""
echo "| Property | Value |"
echo "|----------|-------|"
echo "| Registry | \`ghcr.io/${{ github.repository }}\` |"
echo "| Version | \`${VERSION}\` |"
echo "| Digest | \`${{ steps.push.outputs.digest }}\` |"
echo "| Platforms | \`linux/amd64\`, \`linux/arm64\` |"
echo ""
echo "### Pull commands"
echo "\`\`\`bash"
echo "docker pull ghcr.io/${{ github.repository }}:${VERSION}"
echo "docker pull ghcr.io/${{ github.repository }}:latest"
echo "\`\`\`"
} >> "$GITHUB_STEP_SUMMARY"
# ── Job 6: Create GitHub release with all artifacts ───────────────────────────
github-release:
name: Create GitHub release
needs: [validate, build-binaries, docker-image]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download all binary archives
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: release-artifacts/
pattern: mallard-metrics-*
merge-multiple: true
- name: Generate consolidated checksums
shell: bash
run: |
cd release-artifacts
echo "::group::Release artifacts"
ls -lh
echo "::endgroup::"
# Combine per-archive checksums and verify
cat ./*.sha256 > SHA256SUMS
sha256sum -c SHA256SUMS
echo "::notice::All checksums verified"
- name: Extract CHANGELOG section for this version
id: notes
shell: bash
run: |
VERSION="${{ needs.validate.outputs.version }}"
echo "::group::Extracting release notes for v$VERSION"
awk "
/^## \[$VERSION\]/ { found=1; next }
found && /^## \[/ { exit }
found { print }
" CHANGELOG.md > release_notes.md
if [[ ! -s release_notes.md ]]; then
echo "::error::Empty release notes extracted for $VERSION"
echo "Ensure CHANGELOG.md has a non-empty ## [$VERSION] section."
exit 1
fi
echo "Release notes preview:"
cat release_notes.md
echo "::endgroup::"
- name: Create or update GitHub release
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
VERSION="${{ needs.validate.outputs.version }}"
IS_PRE="${{ needs.validate.outputs.is_prerelease }}"
TAG="v${VERSION}"
DIGEST="${{ needs.docker-image.outputs.digest }}"
[[ -z "$DIGEST" ]] && DIGEST="n/a"
PRE_FLAG=""
[[ "$IS_PRE" == "true" ]] && PRE_FLAG="--prerelease"
RELEASE_URL="https://github.com/${{ github.repository }}/releases/tag/${TAG}"
# Append Docker pull instructions to release notes
{
echo ""
echo "---"
echo ""
echo "### Docker"
echo ""
echo "\`\`\`bash"
echo "docker pull ghcr.io/${{ github.repository }}:${VERSION}"
echo "\`\`\`"
echo ""
echo "Platforms: \`linux/amd64\`, \`linux/arm64\`"
echo ""
echo "Immutable digest (pin this in production):"
echo ""
echo "\`\`\`"
echo "ghcr.io/${{ github.repository }}@${DIGEST}"
echo "\`\`\`"
echo ""
echo "### Checksums"
echo ""
echo "\`\`\`"
cat release-artifacts/SHA256SUMS
echo "\`\`\`"
} >> release_notes.md
# Idempotent: if a release already exists, update it in place
if gh release view "$TAG" &>/dev/null; then
echo "::warning::Release ${TAG} already exists — updating it."
echo "::group::Updating GitHub release ${TAG}"
gh release edit "$TAG" \
$PRE_FLAG \
--title "Mallard Metrics ${TAG}" \
--notes-file release_notes.md
gh release upload "$TAG" \
--clobber \
release-artifacts/mallard-metrics-*.tar.gz \
release-artifacts/SHA256SUMS
echo "::endgroup::"
echo "::notice::GitHub release updated: $RELEASE_URL"
else
echo "::group::Creating GitHub release ${TAG}"
gh release create "$TAG" \
$PRE_FLAG \
--title "Mallard Metrics ${TAG}" \
--notes-file release_notes.md \
release-artifacts/mallard-metrics-*.tar.gz \
release-artifacts/SHA256SUMS
echo "::endgroup::"
echo "::notice::GitHub release created: $RELEASE_URL"
fi
# ── Step summary ──────────────────────────────────────────────────
{
echo "## Release v${VERSION}"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| Version | \`${VERSION}\` |"
echo "| Pre-release | \`${IS_PRE}\` |"
echo "| GitHub Release | [v${VERSION}](${RELEASE_URL}) |"
echo "| Docker | \`ghcr.io/${{ github.repository }}:${VERSION}\` |"
echo "| Digest | \`${DIGEST}\` |"
echo ""
echo "### Artifacts"
echo "\`\`\`"
ls -lh release-artifacts/mallard-metrics-*.tar.gz
echo "\`\`\`"
} >> "$GITHUB_STEP_SUMMARY"