Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .github/workflows/selftest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 20 additions & 3 deletions require-file-change/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
50 changes: 18 additions & 32 deletions require-file-change/action.yml
Original file line number Diff line number Diff line change
@@ -1,47 +1,33 @@
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

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"
71 changes: 71 additions & 0 deletions require-file-change/check.sh
Original file line number Diff line number Diff line change
@@ -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
105 changes: 105 additions & 0 deletions require-file-change/test.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading