diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..7210e55 --- /dev/null +++ b/action.yml @@ -0,0 +1,57 @@ +name: 'cargo-semver-checks' +description: 'Ensure your Rust crate's public API follows semantic versioning' +inputs: + crate-name: + description: 'The crate whose API to check for semver' + required: false + default: '' + version-tag-prefix: + description: 'The prefix to use for the git tag for a version; the default "v" creates tags like "v1.0.0"' + required: false + default: 'v' +runs: + using: "composite" + runs-on: ubuntu-latest + steps: + - name: Install rust + uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + profile: minimal + - run: | + # Colorize output, since GitHub Actions terminals support color. + export CARGO_TERM_COLOR=always + + # Record the current git sha, so we can come back to it after generating the baseline. + export CURRENT_GIT_SHA="$(git rev-parse HEAD)" + + # Ensure this action's scripts are available to run on the path. + echo "${{ github.action_path }}" >> $GITHUB_PATH + + export PACKAGE_NAME="${{ inputs.crate-name }}" + if [[ "$PACKAGE_NAME" == '' ]]; then + export PACKAGE_NAME="$(find_workspace_crates.sh)" + fi + export PACKAGE_NAME_WITH_UNDERSCORES="$(echo $"PACKAGE_NAME" | tr '-' '_')" + + # Switch to the tag for the correct baseline version, + # then build rustdoc JSON. + # + # We *do not* want to record and reuse the target directory path + # across different git commits, since it may be at a different location + # in different commits. + git checkout "${{ inputs.version-tag-prefix }}$(find_comparison_version.sh "$PACKAGE_NAME")" + cargo +nightly rustdoc -- -Zunstable-options --output-format json + mv "$(cargo metadata --format-version 1 | jq -r .target_directory)/doc/$PACKAGE_NAME_WITH_UNDERSCORES.json" /tmp/baseline.json + + # Return to the original git sha. + git checkout "$CURRENT_GIT_SHA" + + # Build rustdoc JSON for the current version, and move it to /tmp/ + # so it doesn't get overwritten by the baseline build. + cargo +nightly rustdoc -- -Zunstable-options --output-format json + mv "$(cargo metadata --format-version 1 | jq -r .target_directory)/doc/$PACKAGE_NAME_WITH_UNDERSCORES.json" /tmp/current.json + + # Check for semver violations. + cargo install cargo-semver-checks + cargo semver-checks check-release --current /tmp/current.json --baseline /tmp/baseline.json diff --git a/find_comparison_version.sh b/find_comparison_version.sh new file mode 100644 index 0000000..8c67569 --- /dev/null +++ b/find_comparison_version.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +# Script requirements: +# - curl +# - jq +# - sort with `-V` flag, available in `coreutils-7` +# On macOS this may require `brew install coreutils`. + +# Fail on first error, on undefined variables, and on failures in pipelines. +set -euo pipefail + +# Go to the repo root directory. +cd "$(git rev-parse --show-toplevel)" + +# The first argument should be the name of a crate. +CRATE_NAME="$1" + +CURRENT_VERSION="$( \ + cargo metadata --format-version 1 | \ + jq --arg crate_name "$CRATE_NAME" --exit-status -r \ + '.packages[] | select(.name == $crate_name) | .version' \ +)" || (echo >&2 "No crate named $CRATE_NAME found in workspace."; exit 1) +echo >&2 "Crate $CRATE_NAME current version: $CURRENT_VERSION" + +# The leading whitespace is important! With it, we know that every version is both +# preceded by and followed by whitespace. We use this fact to avoid matching +# on substrings of versions. +EXISTING_VERSIONS=" +$( \ + curl 2>/dev/null "https://crates.io/api/v1/crates/$CRATE_NAME" | \ + jq --exit-status -r .versions[].num \ +)" +echo >&2 -e "Versions on crates.io:$EXISTING_VERSIONS\n" + +# Use version sort (sort -V) to get all versions in ascending order, then use grep to: +# - grab the first line that matches the current version (--max-count=1) +# - only match full lines (--line-regexp) +# - get one line of leading context (-B 1) i.e. the immediately-smaller version, if one exists +# - explicitly opt out of trailing context lines (-A 0) +# Finally, use `head` to output only the first of the up-to-two lines output. +# Now, either: +# - two lines were output, and we grabbed the immediately-smaller version, or +# - one line was output with only our version, because there was no immediately-smaller version, +# and we grabbed that one. We sort this out with the subsequent conditional. +OUTPUT="$( \ + echo -e "$CURRENT_VERSION$EXISTING_VERSIONS" | \ + sort -V | \ + grep -B 1 -A 0 --line-regexp --max-count=1 "$CURRENT_VERSION" | \ + head -n 1 \ +)" + +if [[ "$OUTPUT" == "$CURRENT_VERSION" ]]; then + echo >&2 "There is no suitable comparison version." + echo >&2 \ + "The current version $CURRENT_VERSION is smaller than any version published on crates.io" + exit 1 +fi + +echo "Comparison version: $OUTPUT" >&2 +echo "$OUTPUT" diff --git a/find_workspace_crates.sh b/find_workspace_crates.sh new file mode 100644 index 0000000..c4eff23 --- /dev/null +++ b/find_workspace_crates.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +# Script requirements: +# - jq + +# Fail on first error, on undefined variables, and on failures in pipelines. +set -euo pipefail + +# Go to the repo root directory. +cd "$(git rev-parse --show-toplevel)" + +crates="$(cargo metadata --format-version 1 | \ + jq --exit-status -r \ + '.workspace_members[] as $key | .packages[] | select(.id == $key) | .name')" +crate_count="$(echo -e "${crates}" | wc -l)" + +if [[ "$crate_count" == "1" ]]; then + echo -e "${crates}" + exit 0 +else + echo >&2 "Multiple crates in workspace, please specify a crate in the 'crate-name' setting." + exit 1 +fi