From d05b2c02847df7088ade68241fa7adc8a44c8c40 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Fri, 24 Apr 2026 02:57:07 +0200 Subject: [PATCH 1/2] Add release workflow and Dockerfile Release workflow builds cross-platform binaries (Linux x86_64/aarch64, macOS x86_64/aarch64, Windows x86_64) and multi-arch Docker images to GHCR on every push to main to catch breakage. On workflow_dispatch, it also tags the version from Cargo.toml, creates a GitHub Release with binaries attached, and pushes tagged Docker manifests. Crate publish to crates.io is scaffolded but gated off pending first manual publish and trusted-publisher setup. Dockerfile is a two-stage build: rust:1-bookworm builder -> debian bookworm-slim runtime with zlib1g (for the native-zlib feature), ca-certificates, and procps (ps is needed by Nextflow trace). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 365 ++++++++++++++++++++++++++++++++++ Dockerfile | 33 +++ 2 files changed, 398 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 Dockerfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..32d96ae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,365 @@ +name: Release + +on: + push: + branches: + - main + workflow_dispatch: {} + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + packages: write + +env: + CARGO_TERM_COLOR: always + IMAGE_NAME: ${{ github.repository }} + +jobs: + # ------------------------------------------------------------------ + # 0. Detect whether this is a release run (workflow_dispatch only) + # ------------------------------------------------------------------ + check-release: + name: Check release + runs-on: ubuntu-latest + outputs: + is_release: ${{ steps.check.outputs.is_release }} + version: ${{ steps.check.outputs.version }} + version_major: ${{ steps.check.outputs.version_major }} + version_minor: ${{ steps.check.outputs.version_minor }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 + with: + fetch-depth: 1 + fetch-tags: true + + - name: Detect release + id: check + shell: bash + run: | + if [ "${{ github.event_name }}" != "workflow_dispatch" ]; then + echo "is_release=false" >> "$GITHUB_OUTPUT" + echo "version=" >> "$GITHUB_OUTPUT" + echo "Not a release run" + exit 0 + fi + + # `grep '^version = '` (with the trailing space) so we don't also + # match `rust-version = "..."`. + RAW=$(grep '^version = ' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + VERSION="v${RAW}" + echo "is_release=true" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # Only emit short aliases (e.g. `0` and `0.12` Docker tags, `latest`) + # for plain `X.Y.Z` releases — pre-releases like `0.12.2-rc1` must + # not clobber stable tag moving-targets. + if [[ "$RAW" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + MAJOR=$(echo "$RAW" | cut -d. -f1) + MINOR="${MAJOR}.$(echo "$RAW" | cut -d. -f2)" + echo "version_major=$MAJOR" >> "$GITHUB_OUTPUT" + echo "version_minor=$MINOR" >> "$GITHUB_OUTPUT" + else + echo "version_major=" >> "$GITHUB_OUTPUT" + echo "version_minor=" >> "$GITHUB_OUTPUT" + fi + + if git tag -l "$VERSION" | grep -q .; then + echo "::error::Tag $VERSION already exists" + exit 1 + fi + + LOCK_VERSION=$(awk '/^name = "fastqc-rust"/{found=1} found && /^version =/{print; exit}' Cargo.lock | sed 's/.*"\(.*\)"/\1/') + if [ "$LOCK_VERSION" != "$RAW" ]; then + echo "::error::Cargo.lock version ($LOCK_VERSION) does not match Cargo.toml version ($RAW)" + exit 1 + fi + + echo "Detected release: $VERSION" + + # ------------------------------------------------------------------ + # 1. Build binaries for each target (always, to catch breakage) + # ------------------------------------------------------------------ + build-binaries: + name: Build ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: linux-x86_64 + os: ubuntu-latest + target: x86_64-unknown-linux-gnu + + - name: linux-aarch64 + os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + + - name: macos-x86_64 + os: macos-latest + target: x86_64-apple-darwin + + - name: macos-aarch64 + os: macos-latest + target: aarch64-apple-darwin + + - name: windows-x86_64 + os: windows-latest + target: x86_64-pc-windows-msvc + ext: .exe + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # ratchet:dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Build + shell: bash + run: cargo build --release --target ${{ matrix.target }} + + - name: Package + id: package + shell: bash + run: | + BIN="fastqc" + EXT="${{ matrix.ext }}" + ARCHIVE="${BIN}-${{ matrix.name }}" + + mkdir -p "staging/${ARCHIVE}" + cp "target/${{ matrix.target }}/release/${BIN}${EXT}" "staging/${ARCHIVE}/" + cp README.md LICENSE "staging/${ARCHIVE}/" 2>/dev/null || true + + cd staging + tar czf "../${ARCHIVE}.tar.gz" "${ARCHIVE}" + cd .. + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${ARCHIVE}.tar.gz" > "${ARCHIVE}.tar.gz.sha256" + else + shasum -a 256 "${ARCHIVE}.tar.gz" > "${ARCHIVE}.tar.gz.sha256" + fi + + echo "archive=${ARCHIVE}.tar.gz" >> "$GITHUB_OUTPUT" + echo "checksum=${ARCHIVE}.tar.gz.sha256" >> "$GITHUB_OUTPUT" + + - name: Upload build artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # ratchet:actions/upload-artifact@v7.0.0 + with: + name: binary-${{ matrix.name }} + path: | + ${{ steps.package.outputs.archive }} + ${{ steps.package.outputs.checksum }} + retention-days: 1 + + # ------------------------------------------------------------------ + # 2. Build per-platform Docker images on native runners + # ------------------------------------------------------------------ + docker-build: + name: Docker ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 + + - name: Lowercase image name + run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> "$GITHUB_ENV" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4.0.0 + + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # ratchet:docker/login-action@v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # ratchet:docker/build-push-action@v7.0.0 + with: + context: . + platforms: ${{ matrix.platform }} + outputs: type=image,name=ghcr.io/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,scope=${{ matrix.platform }},mode=max + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # ratchet:actions/upload-artifact@v7.0.0 + with: + name: digests-${{ matrix.runner }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # ------------------------------------------------------------------ + # 2b. Merge per-platform images into a multi-arch manifest + # ------------------------------------------------------------------ + docker-merge: + name: Docker merge + needs: [check-release, docker-build] + runs-on: ubuntu-latest + steps: + - name: Lowercase image name + run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> "$GITHUB_ENV" + + - name: Download digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # ratchet:actions/download-artifact@v8.0.1 + with: + pattern: digests-* + path: /tmp/digests + merge-multiple: true + + - name: Docker meta + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # ratchet:docker/metadata-action@v6.0.0 + with: + images: ghcr.io/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=${{ needs.check-release.outputs.version }},enable=${{ needs.check-release.outputs.is_release == 'true' }} + type=raw,value=${{ needs.check-release.outputs.version_minor }},enable=${{ needs.check-release.outputs.version_minor != '' }} + type=raw,value=${{ needs.check-release.outputs.version_major }},enable=${{ needs.check-release.outputs.version_major != '' }} + type=raw,value=latest,enable=${{ needs.check-release.outputs.version_minor != '' }} + type=raw,value=dev + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4.0.0 + + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # ratchet:docker/login-action@v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf 'ghcr.io/${{ env.IMAGE_NAME }}@sha256:%s ' *) + + # ------------------------------------------------------------------ + # 3. Create tag and draft release (release only, after all builds) + # ------------------------------------------------------------------ + create-tag-and-release: + name: Create tag and release + if: needs.check-release.outputs.is_release == 'true' + needs: [check-release, build-binaries, docker-merge] + runs-on: ubuntu-latest + outputs: + tag: ${{ needs.check-release.outputs.version }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 + + - name: Create tag + run: | + git tag "${{ needs.check-release.outputs.version }}" + git push origin "${{ needs.check-release.outputs.version }}" + + - name: Extract changelog section + id: notes + run: | + RAW="${{ needs.check-release.outputs.version }}" + RAW="${RAW#v}" + if [ -f CHANGELOG.md ] && awk '/^## \[Version '"$RAW"'\]/{found=1; next} /^## \[/{if(found) exit} found' CHANGELOG.md > /tmp/release-notes.md && [ -s /tmp/release-notes.md ]; then + echo "has_notes=true" >> "$GITHUB_OUTPUT" + else + echo "has_notes=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create release (with changelog notes) + if: steps.notes.outputs.has_notes == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${{ needs.check-release.outputs.version }}" \ + --target "${{ github.sha }}" \ + --title "FastQC-Rust ${{ needs.check-release.outputs.version }}" \ + --notes-file /tmp/release-notes.md + + - name: Create release (auto-generated notes) + if: steps.notes.outputs.has_notes == 'false' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${{ needs.check-release.outputs.version }}" \ + --target "${{ github.sha }}" \ + --title "FastQC-Rust ${{ needs.check-release.outputs.version }}" \ + --generate-notes + + # ------------------------------------------------------------------ + # 4. Upload binaries to the release + # ------------------------------------------------------------------ + upload-binaries: + name: Upload binaries + if: needs.check-release.outputs.is_release == 'true' + needs: [check-release, create-tag-and-release, build-binaries] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 + + - name: Download all binary artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # ratchet:actions/download-artifact@v8.0.1 + with: + pattern: binary-* + path: /tmp/binaries + merge-multiple: true + + - name: Upload to release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${{ needs.create-tag-and-release.outputs.tag }}" \ + /tmp/binaries/*.tar.gz \ + /tmp/binaries/*.sha256 + + # ------------------------------------------------------------------ + # 5. Publish to crates.io (release only, last step) + # + # TODO: `fastqc-rust` has not been published to crates.io yet. The + # crates-io-auth-action uses OIDC trusted publishers, which requires the + # crate to already exist with a trusted-publisher config. Bootstrap path: + # 1. First publish manually from a local checkout: `cargo publish` + # 2. Configure crates.io trusted publisher for this repo + workflow + # 3. Flip `if: false` below to `needs.check-release.outputs.is_release + # == 'true'` + # ------------------------------------------------------------------ + publish-crate: + name: Publish to crates.io + if: false + needs: [check-release, upload-binaries] + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 + + - name: Authenticate with crates.io + id: auth + uses: rust-lang/crates-io-auth-action@bbd81622f20ce9e2dd9622e3218b975523e45bbe # ratchet:rust-lang/crates-io-auth-action@v1.0.4 + + - name: Publish to crates.io + run: cargo publish --no-verify + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b4057fb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# ---- Build stage ---- +FROM rust:1-bookworm AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +ARG CPU_TARGET="" + +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY src/ src/ +COPY assets/ assets/ + +RUN HOST_TRIPLE=$(rustc -vV | awk '/^host:/ {print $2}') && \ + cargo build --release --target "$HOST_TRIPLE" \ + ${CPU_TARGET:+--config "target.'$HOST_TRIPLE'.rustflags=['-C', 'target-cpu=$CPU_TARGET']"} \ + && strip "target/$HOST_TRIPLE/release/fastqc" \ + && cp "target/$HOST_TRIPLE/release/fastqc" /fastqc + +# ---- Runtime stage ---- +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + procps \ + zlib1g \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /fastqc /usr/local/bin/fastqc + +CMD ["fastqc"] From 8ed8220c437693cdaeb9ded973a221c683caa975 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Fri, 24 Apr 2026 03:00:29 +0200 Subject: [PATCH 2/2] Apply cargo fmt to basic_stats.rs Reflow the %GC checked_div chain added in 79a2ae8 so `cargo fmt --check` passes. Not related to the release/Docker work on this branch, but unblocks CI on the PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/basic_stats.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/basic_stats.rs b/src/modules/basic_stats.rs index 0f41b83..85959bf 100644 --- a/src/modules/basic_stats.rs +++ b/src/modules/basic_stats.rs @@ -246,7 +246,9 @@ impl QCModule for BasicStats { // Row 7: %GC // JAVA COMPAT: Integer division: ((gCount+cCount)*100)/(aCount+tCount+gCount+cCount) let total = self.a_count + self.t_count + self.g_count + self.c_count; - let gc = ((self.g_count + self.c_count) * 100).checked_div(total).unwrap_or(0); + let gc = ((self.g_count + self.c_count) * 100) + .checked_div(total) + .unwrap_or(0); writeln!(writer, "%GC\t{}", gc)?; Ok(())