diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..964513b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 + +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c004a82..27554c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,9 @@ jobs: name: shellcheck + syntax runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false # shellcheck is preinstalled on ubuntu-latest runners. - name: shellcheck run: shellcheck -S warning zc tests/run.sh @@ -25,17 +27,33 @@ jobs: test: name: integration tests runs-on: ubuntu-latest + env: + ZC_TOOLCHAIN: nightly-2026-07-18 steps: - - uses: actions/checkout@v4 - # cargo-public-api builds rustdoc JSON, which needs a nightly toolchain. - - name: Install Rust (stable + nightly) + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create an action fixture + id: fixture run: | - rustup toolchain install stable --profile minimal --no-self-update - rustup toolchain install nightly --profile minimal --no-self-update - rustup default stable - - uses: Swatinem/rust-cache@v2 - - name: Install cargo-public-api - run: cargo install cargo-public-api --locked - # jq is preinstalled on ubuntu-latest runners. + mkdir -p action-fixture/src + cd action-fixture + printf '[package]\nname = "zc_action_fixture"\nversion = "0.1.0"\nedition = "2021"\n' > Cargo.toml + printf 'version = 4\n\n[[package]]\nname = "zc_action_fixture"\nversion = "0.1.0"\n' > Cargo.lock + git init + git config user.name "zc CI" + git config user.email "zc-ci@users.noreply.github.com" + printf 'pub fn existing() {}\n' > src/lib.rs + git add . + git commit -m "fixture baseline" + echo "baseline=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + printf 'pub fn existing() {}\npub fn added() {}\n' > src/lib.rs + git add src/lib.rs + git commit -m "fixture head" + - name: Run the local action + uses: ./ + with: + baseline: ${{ steps.fixture.outputs.baseline }} + working-directory: action-fixture - name: Run integration tests run: tests/run.sh diff --git a/README.md b/README.md index ee6b51f..71bef18 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,29 @@ under a per-run temp directory. Public API builds use target dirs in that temp directory, so the invoking checkout's HEAD, branch, index, working tree, and `Cargo.lock` are not checked out over or built in. The worktrees are removed on exit. +## GitHub Actions + +Use the action in a pull request workflow after checking out the pull request head with complete history: + +```yaml +permissions: + contents: read + +steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: ZcashFoundation/zc@v0.3.0 + with: + baseline: origin/${{ github.base_ref }} +``` + +The action installs the `cargo-public-api` and Rust nightly versions tested with that `zc` release. It compares the pull request head with its branch point from the baseline, then fails with the same exit-code contract as the CLI. Set `head` when both refs must be compared literally, or enable `with-lock` and `with-values` for the optional analyses. + +Pin the action to a full commit SHA when the workflow requires an immutable dependency. + ## Cache zc stores persistent cache files under `target/zc-cache/`, or under @@ -54,27 +77,21 @@ state for a checkout. First install the prerequisites (see [Requirements](#requirements)): ```sh -cargo install cargo-public-api # required; jq is also required +cargo install cargo-public-api --version 0.52.0 --locked +rustup toolchain install nightly-2026-07-18 --profile minimal ``` Then get `zc` itself — it's a single Bash script. Install it onto your `PATH`: ```sh -curl -fsSL https://raw.githubusercontent.com/ZcashFoundation/zc/main/zc -o ~/.local/bin/zc && chmod +x ~/.local/bin/zc +curl -fsSL https://raw.githubusercontent.com/ZcashFoundation/zc/v0.3.0/zc -o ~/.local/bin/zc +chmod +x ~/.local/bin/zc ``` …or run a one-off without installing: ```sh -curl -fsSL https://raw.githubusercontent.com/ZcashFoundation/zc/main/zc | bash -s -- main -``` - -In CI, the download-then-run form is the most robust: - -```sh -curl -fsSL https://raw.githubusercontent.com/ZcashFoundation/zc/main/zc -o zc -chmod +x zc -./zc "$BASE".."$HEAD" --json +curl -fsSL https://raw.githubusercontent.com/ZcashFoundation/zc/v0.3.0/zc | bash -s -- main ``` > When run via `curl … | bash`, `--help` shows only a short usage (the script @@ -91,7 +108,7 @@ zc --with-lock # include the transitive Cargo.lock diff zc --with-values main # also flag const/static value + doc changes zc --json main # machine-readable output for CI zc --changelog main # draft a librustzcash-style changelog (markdown) -zc --version # version, current commit, and whether it's up to date with origin/main +zc --version # print the installed version ``` Run `zc --help` for the full option and output reference. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..3a279ca --- /dev/null +++ b/action.yml @@ -0,0 +1,93 @@ +name: zc Rust API check +description: Check a Rust workspace for breaking public API and dependency changes +author: Zcash Foundation + +branding: + icon: check-circle + color: blue + +inputs: + baseline: + description: Git ref used as the comparison baseline + required: true + head: + description: Optional exact head ref; omit it to compare the baseline branch point with HEAD + required: false + default: "" + working-directory: + description: Directory containing the Rust workspace + required: false + default: . + with-lock: + description: Include transitive Cargo.lock changes + required: false + default: "false" + with-values: + description: Include public const, static value, and documentation changes + required: false + default: "false" + +runs: + using: composite + steps: + - name: Check system dependencies + shell: bash + run: | + if ! command -v jq >/dev/null 2>&1; then + echo "::error title=Missing zc dependency::Install jq on the runner before using this action." + exit 64 + fi + + - name: Install the Rust nightly used by zc + shell: bash + env: + ZC_TOOLCHAIN: nightly-2026-07-18 + run: rustup toolchain install "$ZC_TOOLCHAIN" --profile minimal --no-self-update + + - name: Install cargo-public-api + uses: taiki-e/install-action@7572810d7dd469b651bb7793945692cf78da5dd7 # v2.85.0 + with: + tool: cargo-public-api@0.52.0 + checksum: true + env: + RUSTUP_TOOLCHAIN: nightly-2026-07-18 + + - name: Check the public API and dependencies + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + INPUT_BASELINE: ${{ inputs.baseline }} + INPUT_HEAD: ${{ inputs.head }} + INPUT_WITH_LOCK: ${{ inputs.with-lock }} + INPUT_WITH_VALUES: ${{ inputs.with-values }} + RUSTUP_TOOLCHAIN: nightly-2026-07-18 + ZC_TOOLCHAIN: nightly-2026-07-18 + run: | + set -euo pipefail + + arguments=() + + case "$INPUT_WITH_LOCK" in + true) arguments+=(--with-lock) ;; + false) ;; + *) + echo "::error title=Invalid with-lock input::Expected true or false." + exit 64 + ;; + esac + + case "$INPUT_WITH_VALUES" in + true) arguments+=(--with-values) ;; + false) ;; + *) + echo "::error title=Invalid with-values input::Expected true or false." + exit 64 + ;; + esac + + arguments+=("$INPUT_BASELINE") + if [[ -n "$INPUT_HEAD" ]]; then + arguments+=("$INPUT_HEAD") + fi + + "$GITHUB_ACTION_PATH/zc" "${arguments[@]}" diff --git a/tests/run.sh b/tests/run.sh index bb6f245..f2ffc88 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -693,19 +693,14 @@ else fi rm -rf "$repo" -# 11) --version prints the version and commit, and honors ZC_NO_UPDATE_CHECK. +# 11) --version prints the release version without requiring analysis tools. ver=$(grep -m1 '^ZC_VERSION=' "$ZC" | cut -d= -f2) -out=$( ZC_NO_UPDATE_CHECK=1 "$ZC" --version 2>&1 ); rc=$? +out=$("$ZC" --version 2>&1); rc=$? assert_eq "$rc" 0 "--version: exit 0" -assert_contains "$out" "zc $ver" "--version: prints 'zc '" -assert_contains "$out" "commit:" "--version: prints a commit line" -case "$out" in - *update:*) bad "--version: ZC_NO_UPDATE_CHECK should suppress the update line" ;; - *) ok "--version: ZC_NO_UPDATE_CHECK suppresses the update line" ;; -esac -out=$( ZC_NO_UPDATE_CHECK=1 "$ZC" -V 2>&1 ); rc=$? +assert_eq "$out" "zc $ver" "--version: prints 'zc '" +out=$("$ZC" -V 2>&1); rc=$? assert_eq "$rc" 0 "-V alias: exit 0" -assert_contains "$out" "zc $ver" "-V alias: prints the version line" +assert_eq "$out" "zc $ver" "-V alias: prints the version line" # 12) Public-dependency semver join: a foreign type re-exposed in a crate's # public API whose crate takes a major bump is flagged, attributed to that @@ -735,7 +730,8 @@ new_pubdep_repo() { # $1=foo dependency line $2=foo/src/lib.rs -> repo dir } bump_pubdep_head() { # $1=repo $2=new foo dependency line -> head sha local d=$1 dep_line=$2 - sed -i 's/^version = "0.1.0"/version = "0.2.0"/' "$d/bar/Cargo.toml" + sed -i.bak 's/^version = "0.1.0"/version = "0.2.0"/' "$d/bar/Cargo.toml" + rm "$d/bar/Cargo.toml.bak" printf '[package]\nname = "foo"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n%s\n' "$dep_line" >"$d/foo/Cargo.toml" ( cd "$d" && cargo generate-lockfile -q ) >/dev/null 2>&1 git -C "$d" add -A diff --git a/zc b/zc index 9eb641f..d8d0cf5 100755 --- a/zc +++ b/zc @@ -26,7 +26,7 @@ # "no change". This section catches those via rustdoc JSON. # # PREREQUISITES -# cargo install cargo-public-api +# cargo install cargo-public-api --version 0.52.0 --locked # jq # a nightly toolchain (builds rustdoc JSON) # @@ -59,9 +59,7 @@ # # OPTIONS # -h, --help Print this help message and exit. -# -V, --version Print the version, the commit this script sits at, and a -# best-effort check of whether it is current with origin/main, -# then exit. Set ZC_NO_UPDATE_CHECK to skip the remote check. +# -V, --version Print the installed version and exit. # --with-lock Also diff Cargo.lock for transitive dep changes. # --by-type By default, per-crate API items are grouped into a # module > type > member hierarchy: a module header, then a @@ -228,7 +226,7 @@ set -euo pipefail # it whenever the dep-dump / metadata logic changes so piped runs don't reuse a # stale cache. When run from a real file, the file's content hash is used # instead (see SCRIPT_HASH below), so this only matters for piped execution. -ZC_VERSION=0.2.0 +ZC_VERSION=0.3.0 # ── tunables ────────────────────────────────────────────────────────── # Regex (jq-syntax) matching crate names whose deps should be excluded @@ -315,92 +313,14 @@ Usage: zc [options] [ []] Options: --with-lock --with-values --by-type --flat --changelog --json -V/--version -h/--help Piped execution shows only this short help. For the full reference, grab a copy: - curl -fsSL https://raw.githubusercontent.com/ZcashFoundation/zc/main/zc -o zc + curl -fsSL https://raw.githubusercontent.com/ZcashFoundation/zc/v0.3.0/zc -o zc chmod +x zc && ./zc --help EOF } -# ── version / update check (--version) ───────────────────────────────── -# Run a command under a wall-clock timeout when `timeout`/`gtimeout` exists; -# otherwise run it directly (git's own connect timeouts still bound it). -run_with_timeout() { # $1=seconds $2..=command - local secs=$1; shift - if command -v timeout >/dev/null 2>&1; then - timeout "$secs" "$@" - elif command -v gtimeout >/dev/null 2>&1; then - gtimeout "$secs" "$@" - else - "$@" - fi -} - -# Best-effort freshness check against the checkout's `origin` (or the canonical -# repo when run standalone). Reads the remote's `main` head without fetching, -# never blocks more than a few seconds, and degrades to a diagnostic line when -# offline. Skipped entirely when ZC_NO_UPDATE_CHECK is set. -version_update_check() { # $1=repo dir ("" when not a checkout) - [ -n "${ZC_NO_UPDATE_CHECK:-}" ] && return 0 - local dir=$1 in_tree=false url remote short - [ -n "$dir" ] && git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 && in_tree=true - - if $in_tree; then - url=$(git -C "$dir" remote get-url origin 2>/dev/null || true) - fi - [ -n "${url:-}" ] || url="https://github.com/ZcashFoundation/zc.git" - - remote=$(run_with_timeout 8 \ - git -c core.sshCommand='ssh -o ConnectTimeout=5 -o BatchMode=yes' \ - ls-remote "$url" refs/heads/main 2>/dev/null | - awk 'NR==1 {print $1}') || remote="" - if [ -z "$remote" ]; then - printf 'update: %scould not reach %s (offline?)%s\n' "$DIM" "$url" "$RESET" - return 0 - fi - short=${remote:0:12} - - if $in_tree && git -C "$dir" cat-file -e "${remote}^{commit}" 2>/dev/null; then - if git -C "$dir" merge-base --is-ancestor "$remote" HEAD 2>/dev/null; then - printf 'update: %sup to date%s with origin/main (%s)\n' "$GREEN" "$RESET" "$short" - else - printf 'update: %sbehind%s origin/main (%s) — run: git -C %s pull\n' "$YELLOW" "$RESET" "$short" "$dir" - fi - elif $in_tree; then - printf 'update: %sbehind%s origin/main (%s) — run: git -C %s fetch && git -C %s pull\n' \ - "$YELLOW" "$RESET" "$short" "$dir" "$dir" - else - printf 'update: latest origin/main is %s\n' "$short" - fi -} - -# `--version` output: the static version, the commit this script sits at (when -# run from a git checkout), and the update check above. Runs before any tool -# prereq check, so it works without cargo-public-api / nightly / jq installed. +# `--version` runs before any tool prerequisite check. show_version() { printf 'zc %s\n' "$ZC_VERSION" - - local path dir="" commit cdate dirty="" - path=$0 - command -v readlink >/dev/null 2>&1 && path=$(readlink -f "$0" 2>/dev/null || echo "$0") - if [ -f "$path" ]; then - dir=$(dirname "$path") - if git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - commit=$(git -C "$dir" rev-parse --short HEAD 2>/dev/null || echo unknown) - cdate=$(git -C "$dir" show -s --format=%cs HEAD 2>/dev/null || echo "") - git -C "$dir" diff --quiet HEAD -- "$path" 2>/dev/null || dirty=" ${YELLOW}(modified)${RESET}" - if [ -n "$cdate" ]; then - printf 'commit: %s (%s)%s\n' "$commit" "$cdate" "$dirty" - else - printf 'commit: %s%s\n' "$commit" "$dirty" - fi - printf 'source: %s\n' "$path" - else - printf 'commit: unknown (source not in a git checkout)\n' - fi - else - printf 'commit: unknown (piped execution; no source file)\n' - fi - - version_update_check "$dir" } with_lock=false @@ -1650,8 +1570,9 @@ compute_pubdep_breaks() { # $1=crate # care about, so we need the whole surface, not the diff). List mode builds # from the head worktree, reusing the already-populated target dir so this is # a rustdoc-only pass, and only runs for crates with a major external bump. - api_text=$(CARGO_TARGET_DIR="$api_head_target" cargo public-api "${feature_args[@]}" \ - --manifest-path "$api_head_worktree/Cargo.toml" -p "$crate" -ss 2>/dev/null || true) + api_text=$(CARGO_TARGET_DIR="$api_head_target" cargo +"$nightly_toolchain" public-api \ + "${feature_args[@]}" --manifest-path "$api_head_worktree/Cargo.toml" \ + -p "$crate" -ss 2>/dev/null || true) [ -n "$api_text" ] || return 0 for dep in "${majors[@]}"; do local pkg=${pubdep_pkg["$crate|$dep"]:-$dep}