diff --git a/.github/actions/validate-requirements/action.yml b/.github/actions/validate-requirements/action.yml new file mode 100644 index 00000000..37c2ee4a --- /dev/null +++ b/.github/actions/validate-requirements/action.yml @@ -0,0 +1,33 @@ +name: "Validate requirements" + +description: | + Reject direct edits to `requirements.txt` by humans: the action fails if + any commit touching the file in the compare range appears to be authored by a + human or otherwise not from an allowed bot (unless the commit message exactly + matches the canonical bot commit message). + +inputs: + allowed_bots: + description: "Comma-separated list of allowed bot author names" + required: false + default: "github-actions[bot],dependabot[bot]" + commit_message_file: + description: "Path to file that contains canonical commit message (exact match)" + required: false + default: ".github/commit-messages/requirements_update.txt" + +runs: + using: "composite" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run requirements check + shell: bash + env: + ALLOWED_BOTS: ${{ inputs.allowed_bots }} + COMMIT_MSG_FILE: ${{ inputs.commit_message_file }} + run: | + bash ./.github/actions/validate-requirements/check.sh \ No newline at end of file diff --git a/.github/actions/validate-requirements/check.sh b/.github/actions/validate-requirements/check.sh new file mode 100644 index 00000000..ac5dabcd --- /dev/null +++ b/.github/actions/validate-requirements/check.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +# Mode: "validate" (default, exits non-zero on human edit) or "detect" (outputs to GITHUB_OUTPUT) +MODE="${MODE:-validate}" +ALLOWED_BOTS="${ALLOWED_BOTS:-github-actions[bot],dependabot[bot]}" + +# Determine the comparison range +is_pr="" +if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ]; then + is_pr=1 +fi +has_base_ref="" +if [ -n "${GITHUB_BASE_REF:-}" ]; then + has_base_ref=1 +fi +origin_base_ref_exists="" +if [ -n "${GITHUB_BASE_REF:-}" ] && git rev-parse --verify "origin/${GITHUB_BASE_REF}" >/dev/null 2>&1; then + origin_base_ref_exists=1 +fi +if [ -n "$is_pr" ] && [ -n "$has_base_ref" ] && [ -n "$origin_base_ref_exists" ]; then + BASE_REF="$(git rev-parse "origin/${GITHUB_BASE_REF}")" + COMPARE_RANGE="$BASE_REF...HEAD" +else + COMPARE_RANGE="HEAD~1..HEAD" +fi + +# Check if requirements.txt changed +if ! git diff --name-only $COMPARE_RANGE | grep -q "^requirements.txt$"; then + echo "'requirements.txt' unchanged" + if [ "$MODE" = "detect" ]; then + echo "is_human_edit=false" >> "${GITHUB_OUTPUT:-/dev/stdout}" + fi + exit 0 +fi + +# Check if requirements.txt differs from base (net change across all commits in range) +if [ -n "$is_pr" ] && [ -n "$has_base_ref" ] && [ -n "$origin_base_ref_exists" ]; then + BASE_REF_PARSED="origin/${GITHUB_BASE_REF}" + if git diff --quiet "$BASE_REF_PARSED" HEAD -- requirements.txt; then + echo "requirements.txt touched but matches base branch (likely reverted): OK" + if [ "$MODE" = "detect" ]; then + echo "is_human_edit=false" >> "${GITHUB_OUTPUT:-/dev/stdout}" + fi + exit 0 + fi +fi + +# Get latest commit that touched requirements.txt +latest_sha=$(git log -1 --pretty=format:'%H' $COMPARE_RANGE -- requirements.txt || true) + +if [ -z "$latest_sha" ]; then + echo "::error::No commits found touching requirements.txt in range $COMPARE_RANGE" + if [ "$MODE" = "detect" ]; then + echo "is_human_edit=false" >> "${GITHUB_OUTPUT:-/dev/stdout}" + fi + exit 1 +fi + +latest_author=$(git show -s --format='%an' "$latest_sha") +latest_committer=$(git show -s --format='%cn' "$latest_sha") +latest_message=$(git show -s --format='%B' "$latest_sha") +latest_subject=$(echo "$latest_message" | head -n1 | sed -e 's/[[:space:]]*$//') + +# Build a grep-friendly regex from comma-separated allowed bots +allowed_regex=$(echo "$ALLOWED_BOTS" | sed 's/,/\\|/g') + +# Check 1: author or committer is allowed bot +if echo "$latest_author" | grep -qE "^($allowed_regex)$" || echo "$latest_committer" | grep -qE "^($allowed_regex)$"; then + echo "Latest change by allowed bot: OK" + if [ "$MODE" = "detect" ]; then + echo "is_human_edit=false" >> "${GITHUB_OUTPUT:-/dev/stdout}" + fi + exit 0 +fi + +# Check 2: commit message exactly matches canonical message +if [ -n "${COMMIT_MSG_FILE:-}" ] && [ -f "$COMMIT_MSG_FILE" ]; then + canonical_msg=$(sed -n '1p' "$COMMIT_MSG_FILE" | tr -d '\r') + if [ "$latest_subject" = "$canonical_msg" ]; then + echo "Latest commit message exactly matches canonical bot message: OK" + if [ "$MODE" = "detect" ]; then + echo "is_human_edit=false" >> "${GITHUB_OUTPUT:-/dev/stdout}" + fi + exit 0 + fi +fi + +# Human edit detected +if [ "$MODE" = "detect" ]; then + echo "is_human_edit=true" >> "${GITHUB_OUTPUT:-/dev/stdout}" + echo "offender_author=$latest_author" >> "${GITHUB_OUTPUT:-/dev/stdout}" + echo "offender_subject=$latest_subject" >> "${GITHUB_OUTPUT:-/dev/stdout}" + echo "Human edit detected" + exit 0 +else + echo "::error::You may NOT edit 'requirements.txt'" + echo "::warning::Undo your changes to requirements.txt, so robot can maintain it." + echo "::notice::To pin dependencies, use 'poetry add '." + echo "Latest commit: $latest_sha" + echo "Latest author: $latest_author" + echo "Latest committer: $latest_committer" + echo "Latest message: \"$latest_subject\"" + exit 1 +fi diff --git a/.github/commit-messages/requirements_update.txt b/.github/commit-messages/requirements_update.txt new file mode 100644 index 00000000..322db8b8 --- /dev/null +++ b/.github/commit-messages/requirements_update.txt @@ -0,0 +1 @@ +chore: auto-update requirements.txt [bot] diff --git a/.github/workflows/requirements-validate.yml b/.github/workflows/requirements-validate.yml index de140da0..3f62791d 100644 --- a/.github/workflows/requirements-validate.yml +++ b/.github/workflows/requirements-validate.yml @@ -9,6 +9,8 @@ on: jobs: reject-requirements-drift: runs-on: ubuntu-latest + env: + COMMIT_MSG_FILE: .github/commit-messages/requirements_update.txt # Skip if the last commit was from the bot (prevent unnecessary check) if: github.event.head_commit.author.name != 'github-actions[bot]' @@ -19,23 +21,9 @@ jobs: with: fetch-depth: 0 # full history - - name: Check if requirements.txt was modified unexpectedly - run: | - # For PRs, check against base branch - # For pushes, check last commit - if [ "${{ github.event_name }}" = "pull_request" ]; then - BASE_REF="${{ github.event.pull_request.base.sha }}" - COMPARE_RANGE="$BASE_REF...HEAD" - else - COMPARE_RANGE="HEAD~1..HEAD" - fi - - # If requirements.txt modified in that range - if git diff --name-only $COMPARE_RANGE | grep -q "^requirements.txt$"; then - echo "::error::You may NOT edit 'requirements.txt'" - echo "::warning::Undo your changes to requirements.txt, so robot can maintain it." - echo "::notice::To pin dependencies, use 'poetry add '." - exit 1 - fi + - name: Validate requirements + uses: ./.github/actions/validate-requirements + with: + allowed_bots: 'github-actions[bot],dependabot[bot]' + commit_message_file: ${{ env.COMMIT_MSG_FILE }} - echo "'requirements.txt' unchanged (or only changed by bot)" diff --git a/.github/workflows/requirments-sync.yml b/.github/workflows/requirments-sync.yml index e9cab15a..58d1401c 100644 --- a/.github/workflows/requirments-sync.yml +++ b/.github/workflows/requirments-sync.yml @@ -49,10 +49,37 @@ jobs: echo "has_change=true" >> $GITHUB_OUTPUT fi - commit-requirements-delta: + prevent-bot-commit-if-human-edit: runs-on: ubuntu-latest needs: detect-requirements-delta if: needs.detect-requirements-delta.outputs.has_change == 'true' + outputs: + is_human_edit: ${{ steps.check.outputs.is_human_edit }} + offender_author: ${{ steps.check.outputs.offender_author }} + offender_subject: ${{ steps.check.outputs.offender_subject }} + env: + ALLOWED_BOTS: 'github-actions[bot],dependabot[bot]' + COMMIT_MSG_FILE: .github/commit-messages/requirements_update.txt + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref || github.ref_name }} + fetch-depth: 0 + + - name: Check for human edits + id: check + env: + MODE: detect + run: bash ./.github/actions/validate-requirements/check.sh + + commit-requirements-delta: + runs-on: ubuntu-latest + needs: [detect-requirements-delta, prevent-bot-commit-if-human-edit] + if: needs.detect-requirements-delta.outputs.has_change == 'true' && needs.prevent-bot-commit-if-human-edit.outputs.is_human_edit == 'false' + env: + COMMIT_MSG_FILE: .github/commit-messages/requirements_update.txt steps: - name: Checkout code @@ -83,6 +110,11 @@ jobs: if git diff --staged --quiet; then echo "No changes to requirements.txt" else - git commit -m "chore: auto-update requirements.txt [bot]" + commit_msg=$(sed -n '1p' "$COMMIT_MSG_FILE" 2>/dev/null | tr -d '\r') + if [ -z "$commit_msg" ]; then + echo "::error::Missing or empty canonical commit message file: $COMMIT_MSG_FILE" + exit 1 + fi + git commit -m "$commit_msg" git push fi diff --git a/.github/workflows/validate-requirements.yml b/.github/workflows/validate-requirements.yml index 420c6133..3c43dc16 100644 --- a/.github/workflows/validate-requirements.yml +++ b/.github/workflows/validate-requirements.yml @@ -7,26 +7,18 @@ on: jobs: check-requirements: runs-on: ubuntu-latest + env: + COMMIT_MSG_FILE: .github/commit-messages/requirements_update.txt steps: - name: Checkout code uses: actions/checkout@v4 with: - fetch-depth: 2 + fetch-depth: 0 - - name: Check if requirements.txt was modified unexpectedly - run: | - # Get author of last commit - AUTHOR=$(git log -1 --pretty=format:'%an') - - # Check if requirements.txt was modified in last commit - if git diff --name-only HEAD~1 HEAD | grep -q "^requirements.txt$"; then - if [ "$AUTHOR" != "github-actions[bot]" ]; then - echo "❌ ERROR: You may NOT edit `requirements.txt`" - echo "To pin dependencies, use `poetry add `." - echo "Please remove your changes to requirements.txt, so the robot can maintain it." - exit 1 - fi - fi + - name: Validate requirements + uses: ./.github/actions/validate-requirements + with: + allowed_bots: 'github-actions[bot],dependabot[bot]' + commit_message_file: ${{ env.COMMIT_MSG_FILE }} - echo "✅ SUCCESS: `requirements.txt` not modified unexpectedly" diff --git a/poetry.lock b/poetry.lock index 3d87dd73..4e744a0b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,6 @@ # This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + [[package]] name = "babel" version = "2.17.0" diff --git a/requirements.txt b/requirements.txt index df45f168..9bb7d560 100644 --- a/requirements.txt +++ b/requirements.txt @@ -115,9 +115,9 @@ mkdocs-exclude-search==0.6.6 ; python_version >= "3.10" and python_version < "3. mkdocs-include-markdown-plugin==5.1.0 ; python_version >= "3.10" and python_version < "3.13" \ --hash=sha256:4a1b8d79a0e1b6fd357ca8013a6d1701c755ada4acb74ee97b0642d1afe6756e \ --hash=sha256:e9ca188ab1d86f5fc4a6b96ce8c85acf6e25f114897868041056ec7945f29f65 -mkdocs-tacc==1.0.0 ; python_version >= "3.10" and python_version < "3.13" \ - --hash=sha256:5d9f1d4a4b871526f74e92bda8eb52584ece817d1eef5d4064ef40fe6adcf99d \ - --hash=sha256:cbd107eab1ff1659bc164c84f17055f367097a0b3dfe2ec3b41ef34850f7181c +mkdocs-tacc==1.0.1 ; python_version >= "3.10" and python_version < "3.13" \ + --hash=sha256:08b8e0b1ab5bdcdfea493c2f18077723b36569afdd71a23a5d097fb7942799ea \ + --hash=sha256:f14ac8d3833bb43447cdb91210f6179e85fe5bef92f7d948ac9c50e41ec8e6c0 mkdocs==1.4.3 ; python_version >= "3.10" and python_version < "3.13" \ --hash=sha256:5955093bbd4dd2e9403c5afaf57324ad8b04f16886512a3ee6ef828956481c57 \ --hash=sha256:6ee46d309bda331aac915cd24aab882c179a933bd9e77b80ce7d2eaaa3f689dd