name: Release
on:
push:
branches: [master]
paths-ignore:
- "docs/**" - "**.md" - ".githooks/**"
- ".github/workflows/docs.yml"
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
BUMP: ${{ inputs.bump || 'minor' }}
DRY_RUN: ${{ inputs.dry_run || 'false' }}
jobs:
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
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
- name: Install git-cliff
run: cargo install git-cliff --locked
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- 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"
- 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
- name: Generate CHANGELOG
run: |
git cliff --tag "${{ steps.ver.outputs.tag }}" --output CHANGELOG.md
echo "::group::CHANGELOG preview"
head -80 CHANGELOG.md
echo "::endgroup::"
- 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"
- 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"
- 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 }}