Chapter Introduction

In previous chapters, we meticulously built the core components of our strict Mermaid code analyzer and fixer: the lexer, parser, AST, validator, rule engine, and CLI interface. We’ve ensured that our tool is robust, deterministic, and adheres strictly to Mermaid syntax specifications. Now, it’s time to take our production-grade tool to the next level by automating its build, test, and release process, and making it easily deployable and accessible to users.

This chapter will guide you through setting up a comprehensive Continuous Integration (CI) and Continuous Deployment (CD) pipeline using GitHub Actions. We’ll implement workflows that automatically compile, test, lint, and format our Rust project on every code change, ensuring code quality and correctness. Furthermore, we’ll establish a robust release process that builds platform-specific binaries and packages our tool into a Docker image, facilitating easy distribution and deployment across various environments. We will also delve into security best practices for CI/CD and deployment, ensuring the integrity and trustworthiness of our released artifacts.

By the end of this chapter, you will have a fully automated system that validates, builds, and deploys our mermaid-tool with minimal manual intervention, ready for real-world usage and integration into development workflows.

Planning & Design

Automating our build and release process requires careful planning. We need a CI/CD pipeline that:

  1. Validates Code Quality: Runs cargo check, cargo test, cargo clippy, and cargo fmt.
  2. Builds Binaries: Compiles the tool for different target platforms (Linux, macOS, Windows).
  3. Creates Releases: On specific events (e.g., tag pushes), creates a GitHub Release and attaches compiled binaries.
  4. Containerizes: Builds a Docker image for easy consumption in containerized environments.
  5. Ensures Security: Incorporates best practices for artifact signing, dependency scanning, and secure build environments.

We will use GitHub Actions due to its tight integration with GitHub repositories and its popularity in the open-source and professional Rust ecosystem.

CI/CD Workflow Architecture

The following Mermaid diagram illustrates our planned CI/CD workflow:

flowchart TD A[Code Push/Pull Request] --> B{GitHub Actions Trigger} B --> C[Setup Rust Toolchain] C --> D[Run cargo check] D --> E[Run cargo test] E --> F[Run cargo clippy] F --> G[Run cargo fmt] G --> H{All Checks Pass?} H -->|No| I[Fail CI] H -->|Yes| J[Build Release Binaries] J --> J_Linux["Linux x64 (Musl)"] J --> J_MacOS_X64["macOS x64"] J --> J_MacOS_ARM["macOS ARM64"] J --> J_Windows["Windows x64"] J_Linux & J_MacOS_X64 & J_MacOS_ARM & J_Windows --> K{On Tag Push?} K -->|No| O[End CI] K -->|Yes| L[Create GitHub Release] L --> M[Upload Binaries to Release] L --> N[Build & Publish Docker Image] N --> O

Explanation of the Workflow:

  • Trigger: The workflow will activate on pushes to main (for CI) and on tag pushes (for CD/releases).
  • Code Quality Checks: Standard Rust checks (check, test, clippy, fmt) will run first. Any failure here stops the pipeline.
  • Release Binaries: If all checks pass, jobs will run in parallel to build the mermaid-tool for various operating systems and architectures. We’ll prioritize static linking where possible (e.g., Linux Musl) for maximum portability.
  • GitHub Release: When a new Git tag (e.g., v1.0.0) is pushed, a dedicated job will create a new GitHub Release, attach the pre-compiled binaries, and generate release notes.
  • Docker Image: A Docker image containing our tool will be built and pushed to a container registry (e.g., GitHub Container Registry or Docker Hub).

Step-by-Step Implementation

We’ll start by setting up the basic CI workflow, then extend it for multi-platform builds, and finally integrate the release and Docker publishing steps.

1. Setup Basic CI Workflow

First, let’s create the GitHub Actions workflow file for continuous integration. This workflow will run on every push and pull request to ensure code quality.

a) Setup/Configuration Create a new directory .github/workflows/ in the root of your project, and inside it, create a file named ci.yml.

b) Core Implementation Add the following content to .github/workflows/ci.yml:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

env:
  CARGO_TERM_COLOR: always

jobs:
  build:
    name: Build & Test
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Install Rust toolchain
      uses: dtolnay/rust-toolchain@stable
      with:
        toolchain: stable
        components: rustfmt, clippy

    - name: Cache Cargo dependencies
      uses: actions/cache@v4
      with:
        path: |
          ~/.cargo/registry
          ~/.cargo/git
          target
        key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
        restore-keys: |
          ${{ runner.os }}-cargo-

    - name: Run cargo check
      run: cargo check --verbose

    - name: Run cargo test
      run: cargo test --verbose

    - name: Run cargo clippy
      run: cargo clippy --verbose -- -D warnings

    - name: Run cargo fmt
      run: cargo fmt --all --check

Explanation:

  • name: CI: The name of our workflow.
  • on: push, pull_request: Specifies when the workflow should run.
  • env: CARGO_TERM_COLOR: always: Ensures colored output in logs for better readability.
  • jobs: build: Defines a single job named “Build & Test”.
  • runs-on: ubuntu-latest: The job will run on a fresh Ubuntu virtual machine provided by GitHub Actions.
  • actions/checkout@v4: A standard action to check out your repository code.
  • dtolnay/rust-toolchain@stable: Installs the latest stable Rust toolchain, including rustfmt and clippy.
  • actions/cache@v4: This is crucial for performance. It caches ~/.cargo/registry, ~/.cargo/git, and target directories, significantly speeding up subsequent builds by reusing downloaded crates and compiled artifacts. The cache key depends on Cargo.lock to ensure cache invalidation when dependencies change.
  • cargo check: Verifies that the code compiles without actually building the executable.
  • cargo test: Runs all unit and integration tests.
  • cargo clippy: Runs the linter. -- -D warnings treats all linter warnings as errors, enforcing strict code quality.
  • cargo fmt --all --check: Checks if the code is formatted according to rustfmt rules. --check prevents it from modifying files, just reports deviations.

c) Testing This Component Commit this file to your main branch or open a pull request. GitHub Actions will automatically detect the ci.yml file and start a new workflow run. You can monitor its progress and logs in the “Actions” tab of your GitHub repository. Ensure all steps pass.

2. Multi-Platform Release Builds

Next, we’ll extend our workflow to build release binaries for different operating systems. We’ll use a matrix strategy for efficiency.

a) Setup/Configuration We will modify the existing ci.yml file.

b) Core Implementation Add a new job named release-build to .github/workflows/ci.yml, after the build job. We’ll use cross for simplified cross-compilation, especially for musl targets, which creates statically linked binaries.

First, ensure you have cross installed locally if you want to test cross-compilation outside CI:

cargo install cross --git https://github.com/cross-rs/cross

Now, update .github/workflows/ci.yml:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

env:
  CARGO_TERM_COLOR: always

jobs:
  build:
    name: Build & Test
    runs-on: ubuntu-latest
    # ... (existing steps for checkout, toolchain, cache, check, test, clippy, fmt) ...
    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Install Rust toolchain
      uses: dtolnay/rust-toolchain@stable
      with:
        toolchain: stable
        components: rustfmt, clippy

    - name: Cache Cargo dependencies
      uses: actions/cache@v4
      with:
        path: |
          ~/.cargo/registry
          ~/.cargo/git
          target
        key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
        restore-keys: |
          ${{ runner.os }}-cargo-

    - name: Run cargo check
      run: cargo check --verbose

    - name: Run cargo test
      run: cargo test --verbose

    - name: Run cargo clippy
      run: cargo clippy --verbose -- -D warnings

    - name: Run cargo fmt
      run: cargo fmt --all --check

  release-build:
    name: Build for ${{ matrix.os }}-${{ matrix.target }}
    runs-on: ${{ matrix.os }}
    needs: build # This job depends on the 'build' job passing
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            target: x86_64-unknown-linux-musl # Static Linux binary
            toolchain: stable
            bin_name: mermaid-tool
            asset_name: mermaid-tool-x86_64-unknown-linux-musl
          - os: macos-latest
            target: x86_64-apple-darwin
            toolchain: stable
            bin_name: mermaid-tool
            asset_name: mermaid-tool-x86_64-apple-darwin
          - os: macos-latest
            target: aarch64-apple-darwin
            toolchain: stable
            bin_name: mermaid-tool
            asset_name: mermaid-tool-aarch64-apple-darwin
          - os: windows-latest
            target: x86_64-pc-windows-msvc
            toolchain: stable
            bin_name: mermaid-tool.exe
            asset_name: mermaid-tool-x86_64-pc-windows-msvc.exe

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Install Rust toolchain
      uses: dtolnay/rust-toolchain@stable
      with:
        toolchain: ${{ matrix.toolchain }}
        target: ${{ matrix.target }} # Install target for cross-compilation

    - name: Cache Cargo dependencies
      uses: actions/cache@v4
      with:
        path: |
          ~/.cargo/registry
          ~/.cargo/git
          target
        key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
        restore-keys: |
          ${{ runner.os }}-${{ matrix.target }}-cargo-

    - name: Install cross (for musl target)
      if: contains(matrix.target, 'musl')
      run: cargo install cross --git https://github.com/cross-rs/cross

    - name: Build release binary
      run: |
        if contains(matrix.target, 'musl'); then
          cross build --target ${{ matrix.target }} --release
        else
          cargo build --target ${{ matrix.target }} --release
        fi

    - name: Strip debug symbols (Linux/macOS)
      if: contains(matrix.os, 'ubuntu') || contains(matrix.os, 'macos')
      run: strip target/${{ matrix.target }}/release/${{ matrix.bin_name }} || true # `|| true` to prevent failure if strip isn't available or fails

    - name: Upload artifact
      uses: actions/upload-artifact@v4
      with:
        name: ${{ matrix.asset_name }}
        path: target/${{ matrix.target }}/release/${{ matrix.bin_name }}

Explanation of release-build job:

  • needs: build: This ensures that the release-build job only starts after the build (CI checks) job has successfully completed.
  • strategy: matrix: Allows us to run multiple jobs in parallel for different combinations of os and target.
    • fail-fast: false: Ensures that if one target build fails, others can still complete.
    • include: Defines the specific os and target combinations we want to build for.
      • x86_64-unknown-linux-musl: For a statically linked Linux executable. Requires cross.
      • x86_64-apple-darwin, aarch64-apple-darwin: For macOS Intel and Apple Silicon.
      • x86_64-pc-windows-msvc: For Windows.
  • Install Rust toolchain: Now includes target: ${{ matrix.target }} to ensure the correct target toolchain components are installed.
  • Install cross: Conditionally installs cross if the target is musl.
  • Build release binary: Uses cross build for musl targets and cargo build for others. --release flag is critical for optimized binaries.
  • Strip debug symbols: Reduces binary size by removing debug information. This is optional but good for production.
  • Upload artifact: Uses actions/upload-artifact@v4 to save the compiled binary as a workflow artifact. These artifacts can be downloaded from the GitHub Actions run summary page.

c) Testing This Component Commit the updated ci.yml and push it to your main branch. Observe the GitHub Actions run. You should see the build job run first, followed by multiple release-build jobs running in parallel for each specified target. After completion, you can download the artifacts from the workflow run summary.

3. GitHub Release Automation

Now, let’s set up a dedicated workflow to create a GitHub Release when a new Git tag is pushed. This workflow will collect the artifacts from the release-build jobs and attach them to the release.

a) Setup/Configuration Create a new workflow file named release.yml in .github/workflows/.

b) Core Implementation Add the following content to .github/workflows/release.yml:

# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+*' # Trigger on tags like v1.0.0, v1.0.0-beta.1

env:
  CARGO_TERM_COLOR: always

jobs:
  create-release:
    name: Create GitHub Release
    runs-on: ubuntu-latest
    outputs:
      upload_url: ${{ steps.create_release.outputs.upload_url }}
      version: ${{ steps.get_version.outputs.version }}

    steps:
    - name: Get version from tag
      id: get_version
      run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT

    - name: Create Release
      id: create_release
      uses: softprops/action-gh-release@v1
      with:
        tag_name: ${{ github.ref }}
        name: Release ${{ github.ref }}
        body: |
          ## What's Changed
          - Full changelog: https://github.com/${{ github.repository }}/compare/${{ github.event.repository.releases_url_prefix }}/${{ steps.get_version.outputs.version }}...${{ github.ref }}
        draft: false
        prerelease: false
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  build-and-upload-assets:
    name: Build & Upload Assets
    runs-on: ${{ matrix.os }}
    needs: create-release # This job depends on the 'create-release' job
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            target: x86_64-unknown-linux-musl
            toolchain: stable
            bin_name: mermaid-tool
            asset_name: mermaid-tool-x86_64-unknown-linux-musl
          - os: macos-latest
            target: x86_64-apple-darwin
            toolchain: stable
            bin_name: mermaid-tool
            asset_name: mermaid-tool-x86_64-apple-darwin
          - os: macos-latest
            target: aarch64-apple-darwin
            toolchain: stable
            bin_name: mermaid-tool
            asset_name: mermaid-tool-aarch64-apple-darwin
          - os: windows-latest
            target: x86_64-pc-windows-msvc
            toolchain: stable
            bin_name: mermaid-tool.exe
            asset_name: mermaid-tool-x86_64-pc-windows-msvc.exe

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Install Rust toolchain
      uses: dtolnay/rust-toolchain@stable
      with:
        toolchain: ${{ matrix.toolchain }}
        target: ${{ matrix.target }}

    - name: Cache Cargo dependencies
      uses: actions/cache@v4
      with:
        path: |
          ~/.cargo/registry
          ~/.cargo/git
          target
        key: ${{ runner.os }}-${{ matrix.target }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
        restore-keys: |
          ${{ runner.os }}-${{ matrix.target }}-cargo-release-

    - name: Install cross (for musl target)
      if: contains(matrix.target, 'musl')
      run: cargo install cross --git https://github.com/cross-rs/cross

    - name: Build release binary
      run: |
        if contains(matrix.target, 'musl'); then
          cross build --target ${{ matrix.target }} --release
        else
          cargo build --target ${{ matrix.target }} --release
        fi

    - name: Strip debug symbols (Linux/macOS)
      if: contains(matrix.os, 'ubuntu') || contains(matrix.os, 'macos')
      run: strip target/${{ matrix.target }}/release/${{ matrix.bin_name }} || true

    - name: Rename binary for consistency (Windows)
      if: contains(matrix.os, 'windows')
      run: mv target/${{ matrix.target }}/release/${{ matrix.bin_name }} target/${{ matrix.target }}/release/${{ matrix.asset_name }}

    - name: Upload Release Asset
      id: upload-release-asset
      uses: softprops/action-gh-release@v1
      with:
        files: target/${{ matrix.target }}/release/${{ matrix.asset_name }}
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Automatically provided by GitHub Actions

Explanation of release.yml:

  • on: push: tags: This workflow is triggered only when a new Git tag matching the pattern v[0-9]+.[0-9]+.[0-9]+* is pushed.
  • create-release job:
    • softprops/action-gh-release@v1: A popular action to create and manage GitHub Releases.
    • tag_name, name, body: Configured to use the pushed tag name and generate a simple release body.
    • GITHUB_TOKEN: A special token automatically provided by GitHub Actions with permissions to create releases.
  • build-and-upload-assets job:
    • needs: create-release: This job will only run after the create-release job has successfully created a release.
    • The build steps are identical to the release-build job in ci.yml, ensuring that the binaries are freshly built for the release.
    • Rename binary for consistency (Windows): Windows executables might have different names after strip. This step ensures the asset name matches matrix.asset_name.
    • Upload Release Asset: Uses softprops/action-gh-release@v1 again, but this time to upload a specific file (files) to the existing release created by the create-release job.

c) Testing This Component

  1. Commit release.yml to your main branch.
  2. Create a Git tag (e.g., v0.1.0) and push it:
    git tag v0.1.0
    git push origin v0.1.0
    
  3. Go to the “Actions” tab on GitHub. You should see the “Release” workflow running.
  4. Once completed, navigate to the “Releases” section of your GitHub repository. You should see a new release v0.1.0 with the compiled binaries attached.

4. Containerization with Docker

For users who prefer containerized applications or want to integrate mermaid-tool into Docker-based CI/CD pipelines, providing a Docker image is essential.

a) Setup/Configuration Create a Dockerfile in the root of your project.

b) Core Implementation Add the following content to Dockerfile:

# Dockerfile
# Stage 1: Build the Rust application
FROM rust:1.76-slim-bookworm AS builder

WORKDIR /app

# Copy only Cargo.toml and Cargo.lock to leverage Docker cache for dependencies
COPY Cargo.toml Cargo.lock ./

# Build dependencies first (dummy build)
# This layer will be cached unless Cargo.toml or Cargo.lock changes
RUN mkdir src && echo "fn main() {}" > src/main.rs && \
    cargo build --release && rm -rf src

# Copy the actual source code
COPY src ./src

# Build the release binary
RUN cargo build --release

# Stage 2: Create a minimal runtime image
FROM debian:bookworm-slim

# Install ca-certificates for HTTPS communication if needed (e.g., fetching external data)
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \
    rm -rf /var/lib/apt/lists/*

# Set the working directory
WORKDIR /usr/local/bin

# Copy the built binary from the builder stage
COPY --from=builder /app/target/release/mermaid-tool .

# Set entrypoint to our tool
ENTRYPOINT ["/usr/local/bin/mermaid-tool"]

# Metadata
LABEL org.opencontainers.image.authors="Your Name/Org"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.source="https://github.com/your-org/mermaid-tool"
LABEL org.opencontainers.image.description="A strict Mermaid code analyzer and fixer written in Rust."
LABEL org.opencontainers.image.url="https://github.com/your-org/mermaid-tool"

Explanation:

  • Multi-stage build: This is a best practice for Dockerizing Rust applications.
    • builder stage: Uses a rust image to compile our application. We copy Cargo.toml and Cargo.lock first, perform a dummy build, and then copy the source code. This optimizes Docker’s layer caching, so rebuilding is fast if only source code changes, but dependencies remain the same.
    • Minimal runtime stage: Uses debian:bookworm-slim (a very small base image) to run the compiled binary. This drastically reduces the final image size.
    • ca-certificates: Essential for secure HTTPS connections if your tool ever needs to fetch resources from the internet.
  • WORKDIR /usr/local/bin: Sets the working directory where the binary will reside.
  • COPY --from=builder: Copies the mermaid-tool binary from the builder stage into the final slim image.
  • ENTRYPOINT ["/usr/local/bin/mermaid-tool"]: Defines the default command to execute when the container starts. This means running docker run <image-name> --help will directly execute our tool’s help command.
  • LABEL: Adds useful metadata to the Docker image, adhering to OCI (Open Container Initiative) specifications.

Now, let’s integrate Docker image building and publishing into our release.yml workflow. We’ll add a new job that depends on the create-release job.

c) Core Implementation - Update release.yml for Docker Add a new job publish-docker-image to .github/workflows/release.yml, after build-and-upload-assets.

# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+*'

env:
  CARGO_TERM_COLOR: always

jobs:
  # ... (existing create-release job) ...
  create-release:
    name: Create GitHub Release
    runs-on: ubuntu-latest
    outputs:
      upload_url: ${{ steps.create_release.outputs.upload_url }}
      version: ${{ steps.get_version.outputs.version }}

    steps:
    - name: Get version from tag
      id: get_version
      run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT

    - name: Create Release
      id: create_release
      uses: softprops/action-gh-release@v1
      with:
        tag_name: ${{ github.ref }}
        name: Release ${{ github.ref }}
        body: |
          ## What's Changed
          - Full changelog: https://github.com/${{ github.repository }}/compare/${{ github.event.repository.releases_url_prefix }}/${{ steps.get_version.outputs.version }}...${{ github.ref }}
        draft: false
        prerelease: false
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  # ... (existing build-and-upload-assets job) ...
  build-and-upload-assets:
    name: Build & Upload Assets
    runs-on: ${{ matrix.os }}
    needs: create-release
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            target: x86_64-unknown-linux-musl
            toolchain: stable
            bin_name: mermaid-tool
            asset_name: mermaid-tool-x86_64-unknown-linux-musl
          - os: macos-latest
            target: x86_64-apple-darwin
            toolchain: stable
            bin_name: mermaid-tool
            asset_name: mermaid-tool-x86_64-apple-darwin
          - os: macos-latest
            target: aarch64-apple-darwin
            toolchain: stable
            bin_name: mermaid-tool
            asset_name: mermaid-tool-aarch64-apple-darwin
          - os: windows-latest
            target: x86_64-pc-windows-msvc
            toolchain: stable
            bin_name: mermaid-tool.exe
            asset_name: mermaid-tool-x86_64-pc-windows-msvc.exe

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Install Rust toolchain
      uses: dtolnay/rust-toolchain@stable
      with:
        toolchain: ${{ matrix.toolchain }}
        target: ${{ matrix.target }}

    - name: Cache Cargo dependencies
      uses: actions/cache@v4
      with:
        path: |
          ~/.cargo/registry
          ~/.cargo/git
          target
        key: ${{ runner.os }}-${{ matrix.target }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
        restore-keys: |
          ${{ runner.os }}-${{ matrix.target }}-cargo-release-

    - name: Install cross (for musl target)
      if: contains(matrix.target, 'musl')
      run: cargo install cross --git https://github.com/cross-rs/cross

    - name: Build release binary
      run: |
        if contains(matrix.target, 'musl'); then
          cross build --target ${{ matrix.target }} --release
        else
          cargo build --target ${{ matrix.target }} --release
        fi

    - name: Strip debug symbols (Linux/macOS)
      if: contains(matrix.os, 'ubuntu') || contains(matrix.os, 'macos')
      run: strip target/${{ matrix.target }}/release/${{ matrix.bin_name }} || true

    - name: Rename binary for consistency (Windows)
      if: contains(matrix.os, 'windows')
      run: mv target/${{ matrix.target }}/release/${{ matrix.bin_name }} target/${{ matrix.target }}/release/${{ matrix.asset_name }}

    - name: Upload Release Asset
      id: upload-release-asset
      uses: softprops/action-gh-release@v1
      with:
        files: target/${{ matrix.target }}/release/${{ matrix.asset_name }}
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  publish-docker-image:
    name: Publish Docker Image
    runs-on: ubuntu-latest
    needs: create-release # Depends on the release being created
    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Get version from release job
      id: get_version
      run: echo "VERSION=${{ needs.create-release.outputs.version }}" >> $GITHUB_ENV

    - name: Log in to GitHub Container Registry
      uses: docker/login-action@v3
      with:
        registry: ghcr.io
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}

    - name: Build and push Docker image
      uses: docker/build-push-action@v5
      with:
        context: .
        push: true
        tags: |
          ghcr.io/${{ github.repository }}:latest
          ghcr.io/${{ github.repository }}:${{ env.VERSION }}
        cache-from: type=gha
        cache-to: type=gha,mode=max
        platforms: linux/amd64 # Specify platform for cross-platform image build if needed. For Rust, this might be simpler than multi-arch builds for now.

Explanation of publish-docker-image job:

  • needs: create-release: Ensures the Docker image is only built and published after a release has been successfully created.
  • Get version from release job: Retrieves the version tag from the output of the create-release job.
  • docker/login-action@v3: Logs into a Docker registry. We’re using GitHub Container Registry (ghcr.io) here, which is integrated with your GitHub account. GITHUB_TOKEN is used for authentication.
  • docker/build-push-action@v5: Builds the Docker image from Dockerfile in the current context (.) and pushes it to the specified tags.
    • tags: We push two tags: latest (for the most recent stable release) and a version-specific tag (e.g., v0.1.0).
    • cache-from, cache-to: Uses GitHub Actions’ built-in cache for Docker layers, significantly speeding up subsequent Docker builds.
    • platforms: linux/amd64: Explicitly builds for linux/amd64. For a single-architecture Rust binary, this is sufficient. For true multi-arch images, buildx with QEMU could be configured, but it adds complexity beyond this chapter’s scope.

c) Testing This Component

  1. Commit the Dockerfile and the updated release.yml to your main branch.
  2. Create a new Git tag (e.g., v0.1.1) and push it:
    git tag v0.1.1
    git push origin v0.1.1
    
  3. Monitor the “Release” workflow. After it completes, check:
    • The “Releases” section for v0.1.1 with attached binaries.
    • Your GitHub Container Registry (ghcr.io/<your-username>/<your-repo-name>) for the newly pushed Docker image with latest and v0.1.1 tags.
  4. Test the Docker image locally:
    docker pull ghcr.io/<your-username>/<your-repo-name>:latest
    docker run ghcr.io/<your-username>/<your-repo-name>:latest --help
    
    This should output the help message of your mermaid-tool.

Production Considerations

1. Security Best Practices in CI/CD & Deployment:

  • Secrets Management: Never hardcode sensitive information. GitHub Secrets are used for GITHUB_TOKEN, which is automatically provided. For other secrets (e.g., private registry credentials), use GitHub Encrypted Secrets.
  • Least Privilege: Ensure that the GITHUB_TOKEN (or any other token) used in CI/CD has only the minimum necessary permissions. GitHub’s default GITHUB_TOKEN is usually sufficient for read/write on the repository and package registry.
  • Dependency Scanning: Integrate tools like cargo audit to scan for known vulnerabilities in your Rust dependencies. You can add a step in your build job:
    - name: Install cargo-audit
      run: cargo install cargo-audit
    
    - name: Run cargo audit
      run: cargo audit --deny warnings --json # --json for machine-readable output if integrating with other tools
    
  • Reproducible Builds: Using Cargo.lock and fixed toolchain versions (rust:1.76-slim-bookworm) helps ensure that builds are reproducible. cross also contributes to this by providing consistent build environments.
  • Image Scanning: For Docker images, integrate vulnerability scanners (e.g., Trivy, Snyk) into your publish-docker-image job. This can be done after the image is built but before it’s pushed, or as a separate job.
    # Example step for Trivy scan (add before push)
    - name: Scan Docker image for vulnerabilities
      uses: aquasecurity/trivy-action@master
      with:
        image-ref: ghcr.io/${{ github.repository }}:${{ env.VERSION }}
        format: 'table'
        exit-code: '1' # Fail if vulnerabilities are found
        severity: 'CRITICAL,HIGH'
    
  • Code Signing: For highly sensitive applications, consider signing your binaries and Docker images. This typically involves generating GPG keys and integrating signing steps into the release workflow, but adds significant complexity. For a CLI tool, this might be overkill unless required by specific distribution channels.

2. Performance Optimization:

  • Cargo Cache: We’ve already implemented robust caching for Cargo dependencies, which is the most significant performance gain for Rust projects in CI.
  • Parallel Jobs: The matrix strategy for release-build and build-and-upload-assets runs jobs in parallel, speeding up the overall workflow.
  • Minimal Docker Image: Using multi-stage builds and a debian-slim base image keeps the Docker image size small, leading to faster pulls and reduced storage.
  • Stripping Binaries: strip command reduces binary size, making artifacts smaller and faster to download.

3. Logging and Monitoring:

  • GitHub Actions provides detailed logs for each step. Ensure your run commands use --verbose where appropriate to get useful diagnostic output.
  • Consider adding custom logging within your Rust application (as discussed in previous chapters) to help diagnose issues in deployed environments.

Code Review Checkpoint

At this point, you should have the following new files and modifications:

  • .github/workflows/ci.yml: Configures continuous integration for pushes and pull requests, running cargo check, test, clippy, and fmt. It also includes parallel jobs to build release binaries for various platforms and uploads them as artifacts.
  • .github/workflows/release.yml: Configures the release workflow, triggered by Git tags. It creates a GitHub Release, re-builds and uploads platform-specific binaries as release assets, and builds/publishes a Docker image to GitHub Container Registry.
  • Dockerfile: Defines the multi-stage build process for containerizing the mermaid-tool into a minimal Docker image.

These additions establish a professional, automated CI/CD pipeline, significantly improving the maintainability, reliability, and distribution of our mermaid-tool.

Common Issues & Solutions

  1. “Permission denied” during strip or binary execution:

    • Issue: Often happens on Linux or macOS if the binary doesn’t have execute permissions after being copied or downloaded.
    • Solution: Ensure the strip command or subsequent steps handle permissions. In CI, chmod +x might be necessary if manually moving files, but actions/upload-artifact usually preserves this. For Docker, ensure the binary copied into the image has execute permissions (which it generally does when copied from target/release).
    • Prevention: Always test locally after building.
  2. cargo audit fails due to cargo-audit not found:

    • Issue: The cargo audit command might not be in the PATH or cargo install failed.
    • Solution: Ensure cargo install cargo-audit runs successfully. Sometimes, the $HOME/.cargo/bin directory needs to be explicitly added to the PATH for the current shell session in a CI step. GitHub Actions usually handles this for cargo install.
    • Prevention: Always run cargo audit locally first to ensure it works in your development environment.
  3. Docker image build fails with “no such file or directory” for src or Cargo.toml:

    • Issue: The COPY commands in the Dockerfile are relative to the Docker build context. If the Dockerfile is not in the project root, or if the context in docker/build-push-action is incorrect, files won’t be found.
    • Solution: Ensure your Dockerfile is in the project root and docker/build-push-action has context: .. Double-check the paths in COPY commands (COPY Cargo.toml Cargo.lock ./ and COPY src ./src).
    • Prevention: Test your Dockerfile locally with docker build -t test-image . before pushing to CI.
  4. GitHub Release action fails to upload assets:

    • Issue: The files path specified in softprops/action-gh-release@v1 for uploading assets is incorrect, or the binary isn’t found at that path.
    • Solution: Carefully check the exact path where the binary is built (e.g., target/${{ matrix.target }}/release/${{ matrix.bin_name }}). Verify that the bin_name and asset_name variables in the matrix are correct, especially for Windows executables which have a .exe suffix.
    • Prevention: Use ls -R target/ in a prior CI step to confirm the exact location and name of the generated binary.

Testing & Verification

To verify that all CI/CD components are working as expected:

  1. Push a minor code change to your main branch (e.g., update a comment).
    • Verify that the CI workflow runs.
    • Check that the Build & Test job passes.
    • Check that all Build for ... jobs complete and upload artifacts successfully. You can download these artifacts from the workflow run summary to manually inspect the binaries.
  2. Create and push a new Git tag (e.g., v0.1.2):
    git tag v0.1.2
    git push origin v0.1.2
    
    • Verify that the Release workflow runs.
    • Check that the Create GitHub Release job successfully creates a new release on your GitHub repository.
    • Check that all Build & Upload Assets jobs complete and the binaries are attached to the GitHub Release.
    • Verify that the Publish Docker Image job completes and that latest and v0.1.2 tagged images appear in your GitHub Container Registry.
  3. Test the deployed artifacts:
    • Download a binary for your operating system from the GitHub Release and run mermaid-tool --help.
    • Pull the Docker image and run docker run ghcr.io/<your-username>/<your-repo-name>:v0.1.2 --help.
    • Verify that the tool functions correctly in both scenarios.

Summary & Next Steps

Congratulations! You’ve successfully implemented a robust CI/CD pipeline for your mermaid-tool. We’ve covered:

  • Setting up GitHub Actions for continuous integration (build, test, lint, format).
  • Configuring multi-platform release builds for Linux (musl), macOS (x64, ARM64), and Windows.
  • Automating GitHub Releases to publish binaries on tag pushes.
  • Containerizing the application using a multi-stage Dockerfile and publishing it to GitHub Container Registry.
  • Discussing crucial production considerations like security, performance, logging, and monitoring.

This comprehensive setup ensures that your mermaid-tool is always tested, well-maintained, and easily distributable to a wide audience.

In the next chapter, we will explore advanced extensibility options for our mermaid-tool, such as a plugin system for custom rules, WASM compilation for browser usage, and VS Code extension integration. This will further enhance the utility and reach of our powerful Mermaid analyzer and fixer.