diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index 689a4b8..9be2fb3 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -9,6 +9,15 @@ permissions: contents: read jobs: + require-file-change: + name: require-file-change + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v3 + - name: Run self-test + run: require-file-change/test.sh + check-diff: name: check-diff runs-on: ubuntu-latest diff --git a/require-file-change/README.md b/require-file-change/README.md index f79c6a9..4987f90 100644 --- a/require-file-change/README.md +++ b/require-file-change/README.md @@ -1,10 +1,12 @@ # require-file-change GitHub Action -This is a GitHub Action that fails unless a PR has changed a specific file or (optionally) has a specific label. +This is a GitHub Action that fails unless a PR has changed a specific file, changed a file whose path matches a regular expression, or (optionally) has a specific label. Exactly one of `path` or `path-regex` must be set. ## Usage -For example, to check that PRs either update `docs/release-notes.md` or set the `skip-notes` label: +### Exact path + +To check that PRs either update `docs/release-notes.md` or set the `skip-notes` label: ```yaml name: Release notes @@ -27,9 +29,24 @@ jobs: with: # Optional: if the PR has this label, skip the check override-label: skip-notes - # Required: path to the file we're interested in + # Exact path to the file we're interested in path: docs/release-notes.md # Optional: use this Personal Access Token to query GitHub # Default: ${{ github.token }} token: ${{ github.token }} ``` + +### Path regular expression + +To accept any newly added Markdown file directly under `docs/release-notes/`: + +```yaml +- name: Require a release note + uses: coreos/actions-lib/require-file-change@main + with: + path-regex: '^docs/release-notes/[^/]+\.md$' + status: added +``` + +`path-regex` uses jq regular expression syntax. Set either `path` or `path-regex`, but not both. +The optional `status` input also requires the matching file to have that GitHub pull-files API status, such as `added`, `modified`, or `removed`. If `status` is omitted, files of any status match as before. diff --git a/require-file-change/action.yml b/require-file-change/action.yml index e954558..ad2929f 100644 --- a/require-file-change/action.yml +++ b/require-file-change/action.yml @@ -1,13 +1,19 @@ name: Require file change -description: Fail unless a specified file is changed by a PR +description: Fail unless a specified file or matching path is changed by a PR inputs: override-label: description: Skip check if the PR has this label required: false path: - description: Path to file - required: true + description: Exact path to file (mutually exclusive with path-regex) + required: false + path-regex: + description: Regular expression matching file paths (mutually exclusive with path) + required: false + status: + description: Optional pull-request file status to match + required: false token: description: Personal access token required: false @@ -15,33 +21,13 @@ inputs: runs: using: composite steps: - - name: Check for ${{ inputs.path }} + - name: Check for required file change shell: bash - run: | - set -euo pipefail - if [ -n "${{ inputs.override-label }}" ]; then - # Don't trust the label list in the event metadata, since runs - # can be scheduled out of order and the list might be stale. - label=$(curl --no-progress-meter \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: token ${{ inputs.token || github.token }}" \ - "${{ github.event.pull_request.url }}" | - jq '.labels[] | select(.name == "${{ inputs.override-label }}")') - if [ -n "${label}" ]; then - echo "PR has ${{ inputs.override-label }} label; skipping" - exit 0 - fi - fi - diffinfo=$(curl --no-progress-meter \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: token ${{ inputs.token || github.token }}" \ - "https://api.github.com/repos/${{ github.repository }}/compare/${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}" | - jq '.files[] | select(.filename == "${{ inputs.path }}")') - if [ -z "${diffinfo}" ]; then - echo "Found no changes to ${{ inputs.path }}." - if [ -n "${{ inputs.override-label }}" ]; then - echo "To ignore, add ${{ inputs.override-label }} label to PR." - fi - exit 1 - fi - echo "Found change to ${{ inputs.path }}." + env: + FILE_PATH: ${{ inputs.path }} + PATH_REGEX: ${{ inputs.path-regex }} + FILE_STATUS: ${{ inputs.status }} + OVERRIDE_LABEL: ${{ inputs.override-label }} + TOKEN: ${{ inputs.token || github.token }} + PR_URL: ${{ github.event.pull_request.url }} + run: "${{ github.action_path }}/check.sh" diff --git a/require-file-change/check.sh b/require-file-change/check.sh new file mode 100755 index 0000000..00ac8b8 --- /dev/null +++ b/require-file-change/check.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -n "${FILE_PATH}" && -n "${PATH_REGEX}" ]]; then + echo "path and path-regex are mutually exclusive." >&2 + exit 1 +fi +if [[ -z "${FILE_PATH}" && -z "${PATH_REGEX}" ]]; then + echo "One of path or path-regex must be set." >&2 + exit 1 +fi + +if [[ -n "${OVERRIDE_LABEL}" ]]; then + # Don't trust the label list in the event metadata, since runs can be + # scheduled out of order and the list might be stale. + label=$(curl --fail --no-progress-meter \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: token ${TOKEN}" \ + "${PR_URL}" | + jq --arg override_label "${OVERRIDE_LABEL}" \ + '.labels[] | select(.name == $override_label)') + if [[ -n "${label}" ]]; then + echo "PR has ${OVERRIDE_LABEL} label; skipping" + exit 0 + fi +fi + +if [[ -n "${FILE_PATH}" ]]; then + description="${FILE_PATH}" +else + description="paths matching ${PATH_REGEX}" +fi +if [[ -n "${FILE_STATUS}" ]]; then + description="${description} with status ${FILE_STATUS}" +fi + +page=1 +while true; do + files=$(curl --fail --no-progress-meter \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: token ${TOKEN}" \ + "${PR_URL}/files?per_page=100&page=${page}") + + if [[ -n "${FILE_PATH}" ]]; then + diffinfo=$(jq --arg path "${FILE_PATH}" --arg status "${FILE_STATUS}" \ + '.[] | select(.filename == $path and ($status == "" or .status == $status))' \ + <<< "${files}") + else + diffinfo=$(jq --arg path_regex "${PATH_REGEX}" --arg status "${FILE_STATUS}" \ + '.[] | select((.filename | test($path_regex)) and + ($status == "" or .status == $status))' <<< "${files}") + fi + + if [[ -n "${diffinfo}" ]]; then + echo "Found change to ${description}." + exit 0 + fi + + file_count=$(jq 'length' <<< "${files}") + if (( file_count < 100 )); then + break + fi + ((page += 1)) +done + +echo "Found no changes to ${description}." >&2 +if [[ -n "${OVERRIDE_LABEL}" ]]; then + echo "To ignore, add ${OVERRIDE_LABEL} label to PR." >&2 +fi +exit 1 diff --git a/require-file-change/test.sh b/require-file-change/test.sh new file mode 100755 index 0000000..1afb948 --- /dev/null +++ b/require-file-change/test.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +set -euo pipefail + +action_dir=$(dirname "$(readlink -f "$0")") +tmpdir=$(mktemp -d) +trap 'rm -rf "${tmpdir}"' EXIT + +cat > "${tmpdir}/curl" <<'EOF' +#!/usr/bin/env bash + +url="${!#}" +case "${url}" in + https://api.example.test/pulls/1) + jq -n --arg label "${MOCK_LABEL:-}" \ + '{labels: (if $label == "" then [] else [{name: $label}] end)}' + ;; + 'https://api.example.test/pulls/1/files?per_page=100&page=1') + jq -n '[ + {filename: "docs/release-notes.md", status: "modified"}, + {filename: "docs/release-notes/modified-note.md", status: "modified"}, + {filename: "docs/\" | error(\"injected\") | \".md", status: "modified"} + ] + [range(97) | {filename: ("filler/" + tostring), status: "modified"}]' + ;; + 'https://api.example.test/pulls/1/files?per_page=100&page=2') + jq -n '[ + {filename: "docs/page-two.md", status: "added"}, + {filename: "docs/release-notes/added-note.md", status: "added"}, + {filename: "notes/v1.2.3.md", status: "modified"} + ]' + ;; + *) + echo "Unexpected URL: ${url}" >&2 + exit 22 + ;; +esac +EOF +chmod +x "${tmpdir}/curl" + +run_check() { + env \ + PATH="${tmpdir}:${PATH}" \ + FILE_PATH="${FILE_PATH:-}" \ + PATH_REGEX="${PATH_REGEX:-}" \ + FILE_STATUS="${FILE_STATUS:-}" \ + OVERRIDE_LABEL="${OVERRIDE_LABEL:-}" \ + TOKEN=test-token \ + PR_URL=https://api.example.test/pulls/1 \ + MOCK_LABEL="${MOCK_LABEL:-}" \ + "${action_dir}/check.sh" +} + +expect_success() { + if ! "$@"; then + echo "Expected success: $*" >&2 + exit 1 + fi +} + +expect_failure() { + if "$@"; then + echo "Expected failure: $*" >&2 + exit 1 + fi +} + +expect_failure_message() { + expected=$1 + shift + if output=$("$@" 2>&1); then + echo "Expected failure: $*" >&2 + exit 1 + fi + if [[ "${output}" != *"${expected}"* ]]; then + echo "Expected failure output to contain: ${expected}" >&2 + echo "Actual output: ${output}" >&2 + exit 1 + fi +} + +FILE_PATH=docs/release-notes.md PATH_REGEX= expect_success run_check +FILE_PATH='docs/" | error("injected") | ".md' PATH_REGEX= expect_success run_check +FILE_PATH=docs/page-two.md PATH_REGEX= expect_success run_check +FILE_PATH= PATH_REGEX='^notes/v[0-9]+\.[0-9]+\.[0-9]+\.md$' expect_success run_check +FILE_PATH=docs/page-two.md PATH_REGEX= FILE_STATUS=added expect_success run_check +FILE_PATH=docs/release-notes.md PATH_REGEX= FILE_STATUS=added \ + expect_failure_message \ + 'Found no changes to docs/release-notes.md with status added.' run_check +FILE_PATH= PATH_REGEX='^docs/release-notes/[^/]+\.md$' FILE_STATUS=added \ + expect_success run_check +FILE_PATH= PATH_REGEX='^docs/release-notes/modified-[^/]+\.md$' FILE_STATUS=added \ + expect_failure_message \ + 'Found no changes to paths matching ^docs/release-notes/modified-[^/]+\.md$ with status added.' \ + run_check +FILE_PATH=missing.md PATH_REGEX= \ + expect_failure_message 'Found no changes to missing.md.' run_check +FILE_PATH= PATH_REGEX='^changelog/' expect_failure run_check +FILE_PATH= PATH_REGEX= \ + expect_failure_message 'One of path or path-regex must be set.' run_check +FILE_PATH=docs/release-notes.md PATH_REGEX='.*' \ + expect_failure_message 'path and path-regex are mutually exclusive.' run_check +FILE_PATH=missing.md PATH_REGEX= OVERRIDE_LABEL=skip-notes MOCK_LABEL=skip-notes \ + expect_success run_check + +echo "require-file-change tests passed"