Merge pull request #24 from tomtom215/claude/project-review-modernize… #4
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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" |