name: cargo-echoes
description: Find duplicated Rust doctests, functions and code blocks, and review the copies a pull request adds
branding:
icon: 'copy'
color: 'orange'
inputs:
threshold:
description: Report duplicate groups with at least this many sites
required: false
default: '2'
min-tokens:
description: Skip doctest bodies with fewer tokens than this
required: false
default: '0'
functions:
description: Also check for duplicated functions
required: false
default: 'false'
fn-min-tokens:
description: Skip functions whose body counts fewer tokens than this, a path or an operator counting as one
required: false
default: ''
context-blocks:
description: Also check for duplicated context blocks
required: false
default: 'false'
context-min-tokens:
description: Skip context blocks with fewer tokens than this
required: false
default: ''
context-select:
description: Select the local contexts to check, empty keeps the whole workspace
required: false
default: ''
context-search:
description: Restrict the comparison corpus to one context scope
required: false
default: ''
context-dependency-depth:
description: Search dependency sources to this shortest-path depth, zero stays local
required: false
default: '0'
context-dev-dependencies:
description: Include development dependency edges
required: false
default: 'false'
context-build-dependencies:
description: Include build dependency edges
required: false
default: 'false'
context-all-optional-dependencies:
description: Explore optional dependency paths outside the selected feature configuration
required: false
default: 'false'
context-features:
description: Cargo features enabled for dependency discovery
required: false
default: ''
context-all-features:
description: Enable all features for dependency discovery
required: false
default: 'false'
context-no-default-features:
description: Disable default features for dependency discovery
required: false
default: 'false'
context-target:
description: Target triple used for dependency discovery
required: false
default: ''
context-cache-dir:
description: Persistent dependency index directory, empty for a run-local directory
required: false
default: ''
context-check-latest:
description: Check the latest published release of indexed dependencies
required: false
default: 'true'
context-fail-on-reuse:
description: Make dependency reuse findings fail the duplicate gate
required: false
default: 'false'
all-targets:
description: Also scan bin and example targets
required: false
default: 'false'
package:
description: Restrict the scan to one workspace member by name
required: false
default: ''
version:
description: The echoes version to install, empty for the latest
required: false
default: ''
install:
description: Install echoes, false uses the cargo echoes already on the path
required: false
default: 'true'
cwd:
description: The workspace directory to scan
required: false
default: ''
pr-number:
description: Post the findings as a pull request review on this PR instead of failing the job, in post mode outside workflow_run the PR the payload must name
required: false
default: ''
dry-run:
description: Render the review instead of posting it
required: false
default: 'false'
only-new:
description: In pull request mode report only duplicates this pull request introduces
required: false
default: 'true'
annotations:
description: Annotate each copy to remove, which needs no token
required: false
default: 'true'
mode:
description: "review posts the findings in one job, while prepare and post split it so a workflow_run job can review a fork's pull request"
required: false
default: 'review'
payload:
description: Directory for prepare output or post input, holding one prepared scan or up to 64 immediate payload subdirectories, ignoring linked subdirectories. Each payload is checked and posted independently, and any invalid payload or empty collection fails the step.
required: false
default: ''
max-comments:
description: Inline comments posted per review, the omitted copies go to the review body and the run summary
required: false
default: '50'
exclude:
description: Leave out files matching these gitignore-style globs, one per line, relative to the workspace or every repository root, or to one repository after an owner/repo prefix with a colon
required: false
default: ''
organizations:
description: Scan every repository of these GitHub organizations or users instead of the workspace, one per line
required: false
default: ''
repositories:
description: Scan these OWNER/REPO repositories instead of the workspace, together with any organizations, one per line
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Check the repository selection
if: ${{ inputs.organizations != '' || inputs.repositories != '' }}
shell: bash
env:
PR_NUMBER: ${{ inputs.pr-number }}
DRY_RUN: ${{ inputs.dry-run }}
MODE: ${{ inputs.mode }}
run: |
if [ -n "$PR_NUMBER" ] || [ "$DRY_RUN" = "true" ] || [ "$MODE" != "review" ]; then
echo "::error title=echoes::organizations and repositories only run as a gate, without pr-number, dry-run or mode"
exit 2
fi
- name: Install echoes
if: ${{ inputs.mode != 'post' && inputs.install == 'true' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
repo="LucaCappelletti94/cargo-echoes"
tag=''
[ -n "$VERSION" ] && tag="v$VERSION"
# Keep these targets in sync with the release matrix in .github/workflows/release.yml.
case "$RUNNER_OS/$RUNNER_ARCH" in
Linux/X64) target=x86_64-unknown-linux-gnu ;;
Linux/ARM64) target=aarch64-unknown-linux-gnu ;;
macOS/X64) target=x86_64-apple-darwin ;;
macOS/ARM64) target=aarch64-apple-darwin ;;
Windows/X64) target=x86_64-pc-windows-msvc ;;
*) target='' ;;
esac
if [ -n "$target" ]; then
asset="cargo-echoes-$target.tar.gz"
dest="$RUNNER_TEMP/echoes-bin"
mkdir -p "$dest"
# Action series releases carry no binaries and GitHub marks the newest one Latest,
# so resolve the newest release that actually ships the asset instead of trusting Latest.
if [ -z "$tag" ]; then
tag="$(gh api "repos/$repo/releases?per_page=30" --paginate \
--jq ".[] | select(.draft == false and .prerelease == false) | select([.assets[].name] | index(\"$asset\")) | .tag_name" 2>/dev/null | head -n1)" || true
fi
args=(--repo "$repo" --pattern "$asset" --dir "$dest" --clobber)
[ -n "$tag" ] && args=("$tag" "${args[@]}")
if err="$(gh release download "${args[@]}" 2>&1)"; then
bin=cargo-echoes
[ "$RUNNER_OS" = Windows ] && bin=cargo-echoes.exe
tar -xzf "$dest/$asset" -C "$dest"
# Cargo also finds subcommands in its own bin directory, so PATH stays untouched.
bin_dir="${CARGO_HOME:-$HOME/.cargo}/bin"
mkdir -p "$bin_dir"
cp "$dest/$bin" "$bin_dir/$bin"
chmod +x "$bin_dir/$bin" 2>/dev/null || true
echo "echoes: installed the prebuilt $asset from $tag"
exit 0
fi
echo "echoes: could not download $asset, falling back to source"
printf '%s\n' "$err" | sed 's/^/echoes: /'
else
echo "echoes: no prebuilt binary for $RUNNER_OS/$RUNNER_ARCH, falling back to source"
fi
install=(echoes)
[ -n "$tag" ] && install+=(--version "$VERSION")
cargo install "${install[@]}"
- name: Resolve the dependency index directory
id: cache-dir
if: ${{ inputs.mode != 'post' && inputs.context-dependency-depth > 0 }}
shell: bash
env:
CONTEXT_CACHE_DIR: ${{ inputs.context-cache-dir }}
run: |
dir="${CONTEXT_CACHE_DIR:-$RUNNER_TEMP/echoes-context-index}"
mkdir -p "$dir/public"
echo "dir=$dir" >> "$GITHUB_OUTPUT"
- name: Restore the dependency index
if: ${{ inputs.mode != 'post' && inputs.context-dependency-depth > 0 }}
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ steps.cache-dir.outputs.dir }}/public
key: echoes-context-index-${{ runner.os }}-${{ inputs.context-target != '' && inputs.context-target || 'any' }}-v1
restore-keys: echoes-context-index-${{ runner.os }}-
- name: Scan for duplicates
id: scan
if: ${{ inputs.mode != 'post' }}
shell: bash
working-directory: ${{ inputs.cwd }}
env:
ALL_TARGETS: ${{ inputs.all-targets }}
PACKAGE: ${{ inputs.package }}
PR_NUMBER: ${{ inputs.pr-number }}
DRY_RUN: ${{ inputs.dry-run }}
THRESHOLD: ${{ inputs.threshold }}
MIN_TOKENS: ${{ inputs.min-tokens }}
FUNCTIONS: ${{ inputs.functions }}
FN_MIN_TOKENS: ${{ inputs.fn-min-tokens }}
ANNOTATIONS: ${{ inputs.annotations }}
CONTEXT_BLOCKS: ${{ inputs.context-blocks }}
CONTEXT_MIN_TOKENS: ${{ inputs.context-min-tokens }}
CONTEXT_SELECT: ${{ inputs.context-select }}
CONTEXT_SEARCH: ${{ inputs.context-search }}
CONTEXT_DEPTH: ${{ inputs.context-dependency-depth }}
CONTEXT_DEV: ${{ inputs.context-dev-dependencies }}
CONTEXT_BUILD: ${{ inputs.context-build-dependencies }}
CONTEXT_ALL_OPT: ${{ inputs.context-all-optional-dependencies }}
CONTEXT_FEATURES: ${{ inputs.context-features }}
CONTEXT_ALL_FEATURES: ${{ inputs.context-all-features }}
CONTEXT_NO_DEFAULT: ${{ inputs.context-no-default-features }}
CONTEXT_TARGET: ${{ inputs.context-target }}
CONTEXT_CACHE_DIR: ${{ steps.cache-dir.outputs.dir }}
CONTEXT_CHECK_LATEST: ${{ inputs.context-check-latest }}
CONTEXT_FAIL: ${{ inputs.context-fail-on-reuse }}
EXCLUDE: ${{ inputs.exclude }}
ORGANIZATIONS: ${{ inputs.organizations }}
REPOSITORIES: ${{ inputs.repositories }}
WORKFLOW_TOKEN: ${{ github.token }}
run: bash "$GITHUB_ACTION_PATH/scripts/scan.sh"
- name: Scan the base commit
if: ${{ inputs.pr-number != '' && inputs.only-new == 'true' && inputs.mode != 'post' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ inputs.pr-number }}
CWD: ${{ inputs.cwd }}
MIN_TOKENS: ${{ inputs.min-tokens }}
run: bash "$GITHUB_ACTION_PATH/scripts/base-scan.sh"
- name: Review the findings on the pull request
if: ${{ inputs.mode == 'post' || (inputs.pr-number != '' && inputs.dry-run != 'true') || (steps.scan.outputs.found == 'true' && (inputs.pr-number != '' || inputs.dry-run == 'true')) }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ inputs.pr-number }}
DRY_RUN: ${{ inputs.dry-run }}
ANNOTATIONS: ${{ inputs.annotations }}
MODE: ${{ inputs.mode }}
PAYLOAD: ${{ inputs.payload }}
MAX_COMMENTS: ${{ inputs.max-comments }}
EVENT_NAME: ${{ github.event_name }}
RUN_EVENT: ${{ github.event.workflow_run.event }}
RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
RUN_HEAD_REPO_ID: ${{ github.event.workflow_run.head_repository.id }}
run: |
if [ "$MODE" = "post" ]; then
bash "$GITHUB_ACTION_PATH/scripts/post.sh"
else
bash "$GITHUB_ACTION_PATH/scripts/review.sh"
fi
- name: Save the dependency index
if: ${{ always() && !cancelled() && inputs.mode != 'post' && inputs.context-dependency-depth > 0 && steps.scan.outputs.index-ok == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ steps.cache-dir.outputs.dir }}/public
key: echoes-context-index-${{ runner.os }}-${{ inputs.context-target != '' && inputs.context-target || 'any' }}-v1-${{ github.run_id }}-${{ github.run_attempt }}