Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/scripts/next-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Usage: next-version.sh <published-version> <report-file>
# Runs cargo-semver-checks once against the published crate and prints "<version> <level> <breaking>"
# for the bump it requires. cargo-semver-checks names the required bump but not the version, so the
# number is derived here. Its full output is written to <report-file>.
set -euo pipefail

published="$1"
report="$2"

if ! [[ "$published" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "invalid published version: $published" >&2
exit 1
fi
major=${BASH_REMATCH[1]}
minor=${BASH_REMATCH[2]}
patch=${BASH_REMATCH[3]}

if cargo semver-checks --baseline-version "$published" --release-type patch > "$report" 2>&1; then
required="patch"
elif required=$(grep -oE 'semver requires new (major|minor) version' "$report" | head -1 | awk '{print $4}') \
&& [ -n "$required" ]; then
:
else
# Any other failure (build, network) must not be mistaken for a breaking change
cat "$report" >&2
exit 1
fi

# Below 1.0, Cargo shifts semver one position: in 0.Y.z, a breaking change bumps Y (0.3.2 -> 0.4.0) and
# anything else bumps z. cargo-semver-checks calls the Y bump "major". The move to 1.0.0 is never
# automatic: set it in Cargo.toml by hand (later runs never lower the version); from then on, breaking
# changes bump the first number.
if [ "$major" -eq 0 ]; then
case "$required" in
major) echo "0.$((minor + 1)).0 minor true" ;;
*) echo "0.$minor.$((patch + 1)) patch false" ;;
esac
else
case "$required" in
major) echo "$((major + 1)).0.0 major true" ;;
minor) echo "$major.$((minor + 1)).0 minor false" ;;
*) echo "$major.$minor.$((patch + 1)) patch false" ;;
esac
fi
56 changes: 56 additions & 0 deletions .github/scripts/open-release-pr.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Usage: open-release-pr.sh <report-file>
# Opens the release PR "chore(release): VERSION": creates the branch release/VERSION at BASE_SHA,
# commits the version in Cargo.toml and Cargo.lock, and opens the PR into main. GH_TOKEN is the
# dfns-sdk-semver app's token, so the branch, the commit (Verified) and the PR are the app's, and
# the PR's CI runs.
# Env: GH_TOKEN, REPO, BASE_SHA (main), VERSION, PUBLISHED, REQUIRED (what cargo-semver-checks requires),
# MODE (computed|forced). Run from a checkout of BASE_SHA.
set -euo pipefail

report="$1"
script_dir=$(cd "$(dirname "$0")" && pwd)
branch="release/$VERSION"

"$script_dir/set-version.sh" "$VERSION" Cargo.toml Cargo.lock

# Fails if the branch already exists, e.g. a release PR for this version is still open
gh api "repos/$REPO/git/refs" -f ref="refs/heads/$branch" -f sha="$BASE_SHA" > /dev/null

jq -n \
--arg repo "$REPO" --arg branch "$branch" --arg head "$BASE_SHA" \
--arg message "chore(release): $VERSION" \
--arg toml "$(base64 -w0 Cargo.toml)" --arg lock "$(base64 -w0 Cargo.lock)" \
'{query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }",
variables: {input: {
branch: {repositoryNameWithOwner: $repo, branchName: $branch},
expectedHeadOid: $head,
message: {headline: $message},
fileChanges: {additions: [{path: "Cargo.toml", contents: $toml}, {path: "Cargo.lock", contents: $lock}]}
}}}' > release-commit.json
gh api graphql --input release-commit.json --jq .data.createCommitOnBranch.commit.oid

body=$(mktemp)
{
if [ "$MODE" = "forced" ]; then
echo "Release \`$VERSION\` (version set by hand; published: \`$PUBLISHED\`)."
else
echo "Release \`$VERSION\`, the version \`cargo-semver-checks\` requires against the published \`$PUBLISHED\`."
fi
echo
if [ "$VERSION" != "$REQUIRED" ]; then
echo "For reference, \`cargo-semver-checks\` requires \`$REQUIRED\`."
echo
fi
if grep -q '^--- failure' "$report"; then
echo "Breaking changes against \`$PUBLISHED\`:"
echo
echo "~~~"
grep -E '^(--- failure| )' "$report" | head -80
echo "~~~"
echo
fi
echo "After merging, tag the merge commit to publish: \`git tag -s $VERSION <merge-sha> && git push origin $VERSION\`."
} > "$body"

gh pr create --repo "$REPO" --base main --head "$branch" --title "chore(release): $VERSION" --body-file "$body"
38 changes: 38 additions & 0 deletions .github/scripts/set-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Usage: set-version.sh <version> <Cargo.toml> <Cargo.lock>
# Sets the crate version in the [package] table of Cargo.toml and in the crate's own Cargo.lock entry.
# Fails unless exactly one line changes in each file, so nothing else can be rewritten.
set -euo pipefail

version="$1"
manifest="$2"
lockfile="$3"

if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "invalid version: $version" >&2
exit 1
fi

# Only the first version key of the [package] table
awk -v v="$version" '
/^\[/ { in_package = ($0 == "[package]") }
in_package && !done && /^version = "[^"]*"$/ { print "version = \"" v "\""; done = 1; next }
{ print }
' "$manifest" > "$manifest.new"

# Only the version line right after the crate's own name in Cargo.lock
awk -v v="$version" '
/^name = "dfns-sdk-rust"$/ { own = 1; print; next }
own && /^version = "[^"]*"$/ { print "version = \"" v "\""; own = 0; next }
{ own = 0; print }
' "$lockfile" > "$lockfile.new"

for file in "$manifest" "$lockfile"; do
changed=$(diff "$file" "$file.new" | grep -c '^>' || true)
if [ "$changed" -gt 1 ]; then
echo "expected at most one changed line in $file, got $changed" >&2
rm -f "$file.new"
exit 1
fi
mv "$file.new" "$file"
done
113 changes: 113 additions & 0 deletions .github/workflows/prepare-release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
name: Prepare Release

# Opens the release PR "chore(release): X.Y.Z" that sets the version in Cargo.toml and Cargo.lock.
# Without a version input, the version is the one cargo-semver-checks requires against the published
# crate (never lower than Cargo.toml on main). After the PR is merged, tag the merge commit
# X.Y.Z (signed) to publish.
#
# Setup: the dfns-sdk-semver GitHub App (Contents and Pull requests: read and write) opens the PR,
# so its CI runs. Its credentials live in the `sdk-semver` environment, restricted to main:
# variable SEMVER_APP_CLIENT_ID and secret SEMVER_APP_PRIVATE_KEY.

on:
workflow_dispatch:
inputs:
version:
description: "Version to release (X.Y.Z). Leave empty to use the version cargo-semver-checks requires."
required: false
type: string

permissions:
contents: read

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

env:
CARGO_SEMVER_CHECKS_VERSION: "0.51.0"

jobs:
release-pr:
if: github.ref == 'refs/heads/main'
timeout-minutes: 30
runs-on: ubuntu-latest
environment:
name: sdk-semver
deployment: false
permissions:
contents: read # the branch, commit and PR are made with the dfns-sdk-semver app's token

steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Install Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable

- name: Install cargo-semver-checks
run: cargo install --locked "cargo-semver-checks@$CARGO_SEMVER_CHECKS_VERSION"

- name: Choose the version
id: version
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
published=$(curl -fsSL -A "dfns-sdk-rust CI (https://github.com/dfns/dfns-sdk-rust)" \
https://crates.io/api/v1/crates/dfns-sdk-rust | jq -r .crate.max_stable_version)
# Informational when a version is forced: a failed check must not block the release
if .github/scripts/next-version.sh "$published" semver-report.txt > semver-result.txt; then
read -r required _ _ < semver-result.txt
elif [ -n "$INPUT_VERSION" ]; then
required=unknown
echo "::warning::cargo-semver-checks failed; continuing with the forced version"
else
exit 1
fi
current=$(awk '/^\[/ { p = ($0 == "[package]") } p && /^version = / { gsub(/"/, "", $3); print $3; exit }' Cargo.toml)

if [ -n "$INPUT_VERSION" ]; then
if ! [[ "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Version must be X.Y.Z, got: $INPUT_VERSION"
exit 1
fi
version="$INPUT_VERSION"
mode=forced
else
# Keep an unreleased bump already on main
version=$(printf '%s\n%s\n' "$required" "$current" | sort -V | tail -1)
mode=computed
fi

if [ "$version" = "$current" ]; then
echo "::error::Cargo.toml on main is already $version: tag the commit to publish it"
exit 1
fi

{
echo "version=$version"
echo "published=$published"
echo "required=$required"
echo "mode=$mode"
} >> "$GITHUB_OUTPUT"
echo "### Release \`$version\` ($mode; \`cargo-semver-checks\` requires \`$required\` over \`$published\`)" >> "$GITHUB_STEP_SUMMARY"

- name: Generate the dfns-sdk-semver app token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.SEMVER_APP_CLIENT_ID }}
private-key: ${{ secrets.SEMVER_APP_PRIVATE_KEY }}

- name: Open the release PR
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
REPO: ${{ github.repository }}
BASE_SHA: ${{ github.sha }}
VERSION: ${{ steps.version.outputs.version }}
PUBLISHED: ${{ steps.version.outputs.published }}
REQUIRED: ${{ steps.version.outputs.required }}
MODE: ${{ steps.version.outputs.mode }}
run: .github/scripts/open-release-pr.sh semver-report.txt
25 changes: 24 additions & 1 deletion .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ name: Publish to crates.io
on:
push:
tags:
- "v*"
# Release tags are the bare version (0.3.3), like the Cargo.toml version
- "[0-9]+.[0-9]+.[0-9]+"

permissions:
contents: read
Expand All @@ -13,7 +14,29 @@ concurrency:
cancel-in-progress: false

jobs:
# Runs before the publish job and its environment, so a bad tag fails without minting a crates.io token
check-tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false

- name: Check the tag matches Cargo.toml and is on main
run: |
version=$(awk '/^\[/ { p = ($0 == "[package]") } p && /^version = / { gsub(/"/, "", $3); print $3; exit }' Cargo.toml)
if [ "$GITHUB_REF_NAME" != "$version" ]; then
echo "::error::Tag $GITHUB_REF_NAME does not match the Cargo.toml version $version"
exit 1
fi
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error::Tag $GITHUB_REF_NAME points to a commit that is not on main"
exit 1
fi

publish:
needs: check-tag
runs-on: ubuntu-latest
environment:
name: crates-io
Expand Down
Loading