graphitepdf 0.2.0

A Rust PDF rendering engine for layout, composition, and rendering pipelines.
Documentation
name: Release

on:
  # Auto-release on every push to master (except the bump commit itself,
  # which contains [skip ci] so it doesn't re-trigger this workflow).
  push:
    branches: [master]
    paths-ignore:
      - "docs/**"        # docs-only → handled by docs.yml, not a release
      - "**.md"          # README / CHANGELOG edits aren't releases
      - ".githooks/**"
      - ".github/workflows/docs.yml"

  # Manual escape hatch — choose level and dry-run flag via the UI.
  workflow_dispatch:
    inputs:
      bump:
        description: "Version bump"
        required: true
        default: minor
        type: choice
        options: [patch, minor, major]
      dry_run:
        description: "Dry run — skip publish, push, and deploy"
        required: false
        default: "false"
        type: boolean

concurrency:
  group: release
  cancel-in-progress: false

env:
  CARGO_TERM_COLOR: always
  CARGO_INCREMENTAL: 0
  RUST_BACKTRACE: short
  # When triggered by push, inputs are empty — fall back to safe defaults.
  BUMP:    ${{ inputs.bump    || 'minor' }}
  DRY_RUN: ${{ inputs.dry_run || 'false' }}

# ── Publish order: leaves first, facade last ──────────────────────────────────
# errors → primitives → utils → svg → stylesheet → font → math → textkit →
# image → kit → layout → render → renderer → style → document → graphitepdf

jobs:
  # ═══════════════════════════════════════════════════════════════════════════
  # 1. GATE — full CI before we touch anything
  # ═══════════════════════════════════════════════════════════════════════════
  gate:
    name: CI gate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy, rustfmt
          targets: wasm32-unknown-unknown
      - uses: Swatinem/rust-cache@v2
      - run: cargo fmt --all -- --check
      - run: cargo clippy --workspace --all-targets -- -D warnings
      - run: cargo check --workspace --all-targets
      - run: cargo test --workspace --lib --bins
      - run: cargo build -p docs --target wasm32-unknown-unknown

  # ═══════════════════════════════════════════════════════════════════════════
  # 2. PUBLISH — bump, changelog, crates.io, GitHub release
  # ═══════════════════════════════════════════════════════════════════════════
  publish:
    name: Publish & Release
    needs: gate
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          token: ${{ secrets.RELEASE_TOKEN }}

      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2

      # ── Tools ───────────────────────────────────────────────────────────────
      - name: Install git-cliff
        run: cargo install git-cliff --locked

      # ── Git identity ────────────────────────────────────────────────────────
      - name: Configure git
        run: |
          git config user.name  "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

      # ── Compute next version ────────────────────────────────────────────────
      - name: Compute version
        id: ver
        run: |
          CURRENT=$(cargo metadata --no-deps --format-version 1 \
            | jq -r '.packages[] | select(.name=="graphitepdf") | .version')
          MAJOR=$(echo "$CURRENT" | cut -d. -f1)
          MINOR=$(echo "$CURRENT" | cut -d. -f2)
          PATCH=$(echo "$CURRENT" | cut -d. -f3 | sed 's/-.*//')
          case "${{ env.BUMP }}" in
            major) NEXT="$((MAJOR+1)).0.0" ;;
            minor) NEXT="${MAJOR}.$((MINOR+1)).0" ;;
            patch) NEXT="${MAJOR}.${MINOR}.$((PATCH+1))" ;;
          esac
          echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
          echo "next=$NEXT"       >> "$GITHUB_OUTPUT"
          echo "tag=v$NEXT"       >> "$GITHUB_OUTPUT"
          echo "Bumping $CURRENT → $NEXT"

      # ── Update workspace version ────────────────────────────────────────────
      - name: Bump workspace version
        if: env.DRY_RUN == 'false'
        run: |
          CURRENT="${{ steps.ver.outputs.current }}"
          NEXT="${{ steps.ver.outputs.next }}"

          # 1. Root Cargo.toml — update [workspace.package].version and all
          #    [workspace.dependencies.*].version lines (line-start matches).
          sed -i "s/^version = \"$CURRENT\"/version = \"$NEXT\"/" Cargo.toml

          # 2. Any crate Cargo.toml that still has an inline hardcoded version
          #    (e.g. inside a dep spec on one line).  Match only the exact current
          #    workspace version so third-party deps at different versions are safe.
          find . -name "Cargo.toml" -not -path "*/target/*" -not -path "./.git/*" \
            -exec sed -i "s/version = \"$CURRENT\"/version = \"$NEXT\"/g" {} +

          cargo check -p graphitepdf --quiet   # verify + refresh Cargo.lock

      # ── Generate CHANGELOG ──────────────────────────────────────────────────
      - name: Generate CHANGELOG
        run: |
          git cliff --tag "${{ steps.ver.outputs.tag }}" --output CHANGELOG.md
          echo "::group::CHANGELOG preview"
          head -80 CHANGELOG.md
          echo "::endgroup::"

      # ── Publish all crates to crates.io ────────────────────────────────────
      - name: Publish crates
        if: env.DRY_RUN == 'false'
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: |
          VERSION="${{ steps.ver.outputs.next }}"

          CRATES=(
            graphitepdf-errors
            graphitepdf-primitives
            graphitepdf-utils
            graphitepdf-svg
            graphitepdf-stylesheet
            graphitepdf-font
            graphitepdf-math
            graphitepdf-textkit
            graphitepdf-image
            graphitepdf-kit
            graphitepdf-layout
            graphitepdf-render
            graphitepdf-renderer
            graphitepdf-style
            graphitepdf-document
            graphitepdf
          )

          # ── helpers ──────────────────────────────────────────────────────────

          # Returns HTTP status for a crates.io API endpoint.
          cio_status() {
            curl -sf -o /dev/null -w "%{http_code}" \
              -H "User-Agent: graphite-pdf-release-ci" \
              "https://crates.io/api/v1/crates/$1/$2" 2>/dev/null || echo "000"
          }

          # Returns number of published versions (0 = brand-new crate).
          cio_version_count() {
            curl -sf \
              -H "User-Agent: graphite-pdf-release-ci" \
              "https://crates.io/api/v1/crates/$1" 2>/dev/null \
              | jq '.versions | length // 0' 2>/dev/null || echo "0"
          }

          publish_with_retry() {
            local crate="$1"
            local max=5 delay=60 attempt=0
            while [ $attempt -lt $max ]; do
              attempt=$((attempt+1))
              echo "  attempt $attempt/$max …"
              if cargo publish --package "$crate" --no-verify --allow-dirty 2>&1; then
                return 0
              fi
              if [ $attempt -lt $max ]; then
                echo "  rate-limited or transient error — waiting ${delay}s"
                sleep $delay
                delay=$((delay * 2))   # 60 → 120 → 240 → 480
              fi
            done
            return 1
          }

          # ── main loop ────────────────────────────────────────────────────────

          for crate in "${CRATES[@]}"; do
            echo "::group::$crate @ $VERSION"

            # Skip if this exact version is already on crates.io (idempotent re-run).
            if [ "$(cio_status "$crate" "$VERSION")" = "200" ]; then
              echo "  ✓ already published — skipping"
              echo "::endgroup::"
              continue
            fi

            publish_with_retry "$crate"
            echo "::endgroup::"

            # crates.io rate limits differ for brand-new vs existing crates:
            #   new crate  → 1 per 10 min  → wait 660 s
            #   new version → ~1 per 30 s  → wait 40 s (index propagation + margin)
            COUNT=$(cio_version_count "$crate")
            if [ "$COUNT" -le 1 ]; then
              echo "  new crate on crates.io — waiting 660 s for rate-limit window"
              sleep 660
            else
              sleep 40
            fi
          done

      - name: Dry-run summary
        if: env.DRY_RUN == 'true'
        run: |
          echo "### Dry run — nothing published or pushed" >> "$GITHUB_STEP_SUMMARY"
          echo "Would publish: **${{ steps.ver.outputs.current }} → ${{ steps.ver.outputs.next }}**" >> "$GITHUB_STEP_SUMMARY"
          echo "Crates (in order): errors → primitives → utils → svg → stylesheet → font → math → textkit → image → kit → layout → render → renderer → style → document → graphitepdf" >> "$GITHUB_STEP_SUMMARY"

      # ── Commit, tag, push ────────────────────────────────────────────────────
      - name: Commit + tag + push
        if: env.DRY_RUN == 'false'
        run: |
          TAG="${{ steps.ver.outputs.tag }}"
          git add Cargo.toml Cargo.lock CHANGELOG.md
          git commit -m "chore(release): $TAG [skip ci]"
          git tag -a "$TAG" -m "GraphitePDF $TAG"
          git push origin master
          git push origin "$TAG"

      # ── GitHub Release ───────────────────────────────────────────────────────
      - name: Create GitHub Release
        if: env.DRY_RUN == 'false'
        uses: softprops/action-gh-release@v2
        with:
          tag_name:   ${{ steps.ver.outputs.tag }}
          name:       "GraphitePDF ${{ steps.ver.outputs.tag }}"
          body_path:  CHANGELOG.md
          draft:      false
          prerelease: ${{ contains(steps.ver.outputs.next, 'alpha') || contains(steps.ver.outputs.next, 'beta') || contains(steps.ver.outputs.next, 'rc') }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}