diff --git a/.github/scripts/set-workspace-version.sh b/.github/scripts/set-workspace-version.sh new file mode 100755 index 0000000..e2eb27e --- /dev/null +++ b/.github/scripts/set-workspace-version.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Rewrites the workspace version and refreshes Cargo.lock, then asserts the +# result. Called twice by the publish workflow: once on the tagged checkout +# before publishing, once on main to record the released version. +# +# Usage: set-workspace-version.sh +set -euo pipefail + +VERSION="${1:?usage: set-workspace-version.sh }" + +# Version is centralized in [workspace.package]; both crates inherit it via +# `version.workspace = true`. Written as `sed > tmp && mv` rather than `sed -i`, +# whose syntax differs between GNU and BSD, so the command exercised locally on +# macOS is the one that runs on the Ubuntu runner. +sed -E '/^\[workspace\.package\]/,/^\[workspace\.dependencies\]/ s/^version = "[^"]*"/version = "'"$VERSION"'"/' \ + Cargo.toml > Cargo.toml.tmp && mv Cargo.toml.tmp Cargo.toml + +# The internal pin must move in lockstep: sqlx-gen depends on sqlx-gen-macros by +# version and both crates are always released together, so leaving it behind +# would publish a sqlx-gen requiring a macros version that never shipped. +sed -E 's|^(sqlx-gen-macros = \{ path = "crates/sqlx_gen_macros", version = )"[^"]*"|\1"'"$VERSION"'"|' \ + Cargo.toml > Cargo.toml.tmp && mv Cargo.toml.tmp Cargo.toml + +cargo update --workspace + +# Neither sed fails loudly if its pattern stops matching, so assert on the +# resolved metadata: without this, a drifted manifest layout would silently +# republish the previous version. +failed=0 +for pkg in sqlx-gen sqlx-gen-macros; do + got=$(cargo metadata --no-deps --format-version 1 \ + | jq -r --arg p "$pkg" '.packages[] | select(.name==$p) | .version') + echo "$pkg: $got" + if [ "$got" != "$VERSION" ]; then + echo "::error::$pkg is at $got, expected $VERSION" + failed=1 + fi +done + +# Both package versions above are inherited from [workspace.package], so they +# would still read correct if only the internal pin stopped being rewritten. +# That drift is invisible to the loop and must be asserted separately. +req=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[] | select(.name=="sqlx-gen") | .dependencies[] + | select(.name=="sqlx-gen-macros") | .req') +echo "sqlx-gen -> sqlx-gen-macros: $req" +if [ "$req" != "^$VERSION" ]; then + echo "::error::sqlx-gen requires sqlx-gen-macros $req, expected ^$VERSION" + failed=1 +fi + +exit $failed diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0e515d0..d0a68ef 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,30 +8,62 @@ on: jobs: publish: runs-on: ubuntu-latest + # Needed by the final step, which records the released version on main. + permissions: + contents: write steps: + # fetch-depth: 0 so main is available locally for the commit-back step — + # a tag push otherwise checks out a detached, shallow history. - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: dtolnay/rust-toolchain@stable - - name: Verify tag matches workspace version - run: | - # Version is centralized in the root [workspace.package] block; - # individual crates inherit via `version.workspace = true`. - CARGO_VERSION=$(cargo metadata --no-deps --format-version 1 \ - | jq -r '.packages[] | select(.name=="sqlx-gen") | .version') - TAG_VERSION=${GITHUB_REF#refs/tags/} - echo "Cargo workspace version: $CARGO_VERSION" - echo "Tag version: $TAG_VERSION" - if [ "$CARGO_VERSION" != "$TAG_VERSION" ]; then - echo "::error::Tag $TAG_VERSION does not match workspace version $CARGO_VERSION" - exit 1 - fi + # The tag is the single source of truth for a release: the manifest is + # rewritten from it rather than bumped by hand beforehand, so a release + # can no longer fail on a tag/manifest mismatch. + - name: Apply tag version to workspace + run: .github/scripts/set-workspace-version.sh "${GITHUB_REF#refs/tags/}" + # --allow-dirty: the version above is applied in the runner and is not + # committed until the release has actually succeeded. - name: Publish sqlx-gen-macros - run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} --package sqlx-gen-macros + run: cargo publish --allow-dirty --token ${{ secrets.CARGO_REGISTRY_TOKEN }} --package sqlx-gen-macros - name: Wait for crates.io index update run: sleep 30 - name: Publish sqlx-gen - run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} --package sqlx-gen + run: cargo publish --allow-dirty --token ${{ secrets.CARGO_REGISTRY_TOKEN }} --package sqlx-gen + + # Deliberately last: a commit on main must never announce a release that + # failed to publish. Pushes made with GITHUB_TOKEN do not trigger further + # workflow runs, so this cannot loop back into a publish. + - name: Record the released version on main + run: | + TAG_VERSION=${GITHUB_REF#refs/tags/} + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # The tag checkout is detached and may sit behind main, so re-apply the + # version on top of main's current state instead of reusing the dirty + # tree left by the publish steps (-f discards it). + git fetch origin main + git checkout -f -B main origin/main + .github/scripts/set-workspace-version.sh "$TAG_VERSION" + + # Re-running a release, or tagging a commit whose version already + # matches, must not fail on an empty commit. + if git diff --quiet -- Cargo.toml Cargo.lock; then + echo "main already records $TAG_VERSION, nothing to commit" + exit 0 + fi + + git add Cargo.toml Cargo.lock + git commit -m "deploy: release $TAG_VERSION" + + # If this push loses a race with a concurrent push to main, the crates + # are already published: re-run this step, or bump main by hand. + git push origin main