Post-release checks #26
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
| # POST-RELEASE WATCHDOG. | |
| # | |
| # Everything in ci.yaml builds the WORKING TREE with whatever dependency resolution the | |
| # runner computes that day. A consumer installs the PUBLISHED ARTIFACT with their own | |
| # resolution — an existing workspace lockfile, an older toolchain, a different day's | |
| # registry. 0.11.0/0.11.1 shipped uninstallable exactly through that gap (#279): lopdf | |
| # 0.44's `time` feature only compiles against `time` >= 0.3.48, CI's fresh resolution | |
| # on a current toolchain picked 0.3.53 and stayed green, and every consumer whose | |
| # lockfile or MSRV-aware resolver landed on time <= 0.3.47 got a compile error in lopdf. | |
| # | |
| # This workflow closes the gap AFTER the fact: it installs the *published crates.io | |
| # artifact* into a pristine project, with no checkout and no lockfile, the way a consumer | |
| # does. It runs: | |
| # | |
| # - on every push to master: verifies the version currently named in Cargo.toml, i.e. | |
| # the release commit for 0.11.x goes RED within minutes if that release is broken. | |
| # For commits whose message starts with "release", it polls crates.io for up to | |
| # 20 minutes (publish and push order is not fixed) and FAILS if the version never | |
| # appears — a release commit asserts a release exists. | |
| # - daily (cron): verifies the latest published version against the CURRENT dependency | |
| # ecosystem. A new release of any dependency in our semver ranges can break | |
| # resolution without any commit here; this is the only job that notices. | |
| # - manually (workflow_dispatch), optionally for an explicit version. | |
| name: Post-release checks | |
| on: | |
| push: | |
| branches: [master] | |
| schedule: | |
| # Daily. Failures show up in the Actions tab and trigger GitHub's failure emails. | |
| - cron: "43 5 * * *" | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: "printpdf version to verify (default: Cargo.toml version)" | |
| required: false | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: post-release-${{ github.event_name }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'push' }} | |
| env: | |
| # Sparse-index entry for printpdf: one JSON line per published version, with `yanked`. | |
| # Unauthenticated, uncached-enough, and much friendlier than the crates.io API. | |
| INDEX_URL: https://index.crates.io/pr/in/printpdf | |
| jobs: | |
| resolve: | |
| name: Resolve release version | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| outputs: | |
| version: ${{ steps.pick.outputs.version }} | |
| published: ${{ steps.pick.outputs.published }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Pick the version to verify and confirm it is on crates.io | |
| id: pick | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| INPUT_VERSION: ${{ inputs.version }} | |
| # Via env, not inline interpolation: commit messages are attacker-ish input | |
| # (quotes, backticks) and must not be spliced into the script text. | |
| HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} | |
| run: | | |
| set -euo pipefail | |
| manifest_version() { | |
| cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version' | |
| } | |
| case "$EVENT_NAME" in | |
| workflow_dispatch) | |
| VERSION="${INPUT_VERSION:-$(manifest_version)}" | |
| ;; | |
| schedule) | |
| # Latest published, not Cargo.toml: between releases these are the same, | |
| # and right after a publish the cron should test what consumers get. | |
| VERSION=$(curl -fsSL "$INDEX_URL" | jq -r 'select(.yanked | not) | .vers' | sort -V | tail -1) | |
| ;; | |
| *) | |
| VERSION=$(manifest_version) | |
| ;; | |
| esac | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| is_published() { | |
| # -e: nonzero exit when the version is absent (or only present yanked). | |
| curl -fsSL "$INDEX_URL" \ | |
| | jq -e --arg v "$VERSION" 'select(.vers == $v and (.yanked | not))' > /dev/null | |
| } | |
| # A push whose head commit message starts with "release" claims this version | |
| # is being published right now — wait for the index instead of skipping. | |
| RELEASE_COMMIT=false | |
| if [ "$EVENT_NAME" = "push" ] \ | |
| && printf '%s' "$HEAD_COMMIT_MESSAGE" | head -n1 | grep -qi '^release'; then | |
| RELEASE_COMMIT=true | |
| fi | |
| ATTEMPTS=1 | |
| if [ "$RELEASE_COMMIT" = true ]; then | |
| ATTEMPTS=40 # x 30s = 20 minutes | |
| fi | |
| PUBLISHED=false | |
| for i in $(seq 1 "$ATTEMPTS"); do | |
| if is_published; then | |
| PUBLISHED=true | |
| break | |
| fi | |
| [ "$i" -lt "$ATTEMPTS" ] && sleep 30 | |
| done | |
| echo "published=$PUBLISHED" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "### Post-release check: printpdf \`$VERSION\`" | |
| echo "" | |
| echo "- on crates.io (not yanked): **$PUBLISHED**" | |
| echo "- trigger: \`$EVENT_NAME\`, release commit: \`$RELEASE_COMMIT\`" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| if [ "$RELEASE_COMMIT" = true ] && [ "$PUBLISHED" = false ]; then | |
| echo "::error::Commit message says 'release', but printpdf $VERSION never appeared on crates.io (waited 20 minutes). Publish failed or the version was not bumped." | |
| exit 1 | |
| fi | |
| if [ "$PUBLISHED" = false ]; then | |
| echo "::notice::printpdf $VERSION is not on crates.io — nothing to verify (normal for pre-release pushes)." | |
| fi | |
| # Install the published crate the way a consumer does: pristine project, no checkout, | |
| # no lockfile, dependencies resolved fresh from the registry at check time. | |
| installable: | |
| name: "consumer install: ${{ matrix.name }}" | |
| needs: resolve | |
| if: needs.resolve.outputs.published == 'true' | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 45 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - { name: "default features, linux", os: ubuntu-latest, args: "" } | |
| - { name: "default features, windows", os: windows-latest, args: "" } | |
| - { name: "default features, macos", os: macos-latest, args: "" } | |
| - { name: "no default features", os: ubuntu-latest, args: "--no-default-features" } | |
| # The exact repro from #279, plus a regression step below that re-checks it | |
| # with an old `time` pinned in the consumer's lockfile. | |
| - { name: "jpeg only (issue #279)", os: ubuntu-latest, args: "--no-default-features --features jpeg", regression279: true } | |
| - { name: "all image formats", os: ubuntu-latest, args: "--no-default-features --features images,png,jpeg,gif,tiff,bmp,webp,ico,tga,hdr,dds,pnm" } | |
| - { name: "svg", os: ubuntu-latest, args: "--no-default-features --features svg" } | |
| - { name: "html_multithreaded", os: ubuntu-latest, args: "--no-default-features --features html_multithreaded" } | |
| - { name: "default features, wasm32-unknown-unknown", os: ubuntu-latest, args: "", target: wasm32-unknown-unknown } | |
| steps: | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ matrix.target || '' }} | |
| - name: cargo add printpdf@=${{ needs.resolve.outputs.version }} in a fresh project | |
| shell: bash | |
| env: | |
| VERSION: ${{ needs.resolve.outputs.version }} | |
| ADD_ARGS: ${{ matrix.args }} | |
| TARGET: ${{ matrix.target || '' }} | |
| run: | | |
| set -euo pipefail | |
| cargo new "$RUNNER_TEMP/consumer" | |
| cd "$RUNNER_TEMP/consumer" | |
| # $ADD_ARGS is intentionally word-split. | |
| cargo add "printpdf@=$VERSION" $ADD_ARGS | |
| if [ -n "$TARGET" ]; then | |
| cargo check --target "$TARGET" | |
| else | |
| cargo check | |
| fi | |
| - name: "Regression: known-bad old pins in the consumer lockfile must still build" | |
| if: matrix.regression279 == true | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| cd "$RUNNER_TEMP/consumer" | |
| # Consumers rarely have a fresh lockfile, so the published crate must build | |
| # at its declared dependency FLOORS — a floor that doesn't compile is exactly | |
| # how #279 shipped (every time <= 0.3.47 broke inside lopdf's `time` feature). | |
| # time 0.3.36 and image 0.25.2 are printpdf's declared floors. | |
| # Unpinnable (floors moved) = skip; the scenario is then inexpressible. | |
| PINNED=false | |
| if cargo update -p time --precise 0.3.36; then PINNED=true; else | |
| echo "::notice::time 0.3.36 no longer pinnable in this graph — skipping that scenario." | |
| fi | |
| if cargo update -p image --precise 0.25.2; then PINNED=true; else | |
| echo "::notice::image 0.25.2 no longer pinnable in this graph — skipping that scenario." | |
| fi | |
| if [ "$PINNED" = true ]; then | |
| cargo check | |
| fi |