diff --git a/.github/workflows/check-requirements-deterministic.yml b/.github/workflows/check-requirements-deterministic.yml index 0c1fd4700221..7ec964e9dc7e 100644 --- a/.github/workflows/check-requirements-deterministic.yml +++ b/.github/workflows/check-requirements-deterministic.yml @@ -12,6 +12,7 @@ on: types: [opened, synchronize, reopened] paths: - "requirements*.txt" + - "**/requirements*.txt" - "homeassistant/package_constraints.txt" workflow_dispatch: inputs: @@ -58,6 +59,7 @@ jobs: echo "head_sha=${HEAD_SHA}" >> "${GITHUB_OUTPUT}" - name: Run deterministic checks env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }} HEAD_SHA: ${{ steps.pr.outputs.head_sha }} run: | diff --git a/.github/workflows/check-requirements.lock.yml b/.github/workflows/check-requirements.lock.yml index 1c00db9e71c2..bdb527a83828 100644 --- a/.github/workflows/check-requirements.lock.yml +++ b/.github/workflows/check-requirements.lock.yml @@ -1,5 +1,5 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"7b142e96e0f8b454cdcc9c0c25070cf9a52c44d83a6b1fbc3ad6725b6567337c","body_hash":"3894ded07d5934ac5f29d160ffb1f9115cf72b6da8a7e453a4d4f69e8641a48e","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"v0.79.6","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"36a7fc263a2ce868d74a266f23eb7772d82fd397806464384fe087479ddd4a70","body_hash":"bba8c011f2b82bb4d9847a359f43f0e7d91245b280678c20e5112b3c9e77d5cd","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"5c2fe865bb4dc46e1450f6ee0d0541d759aea73a","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -31,12 +31,12 @@ # - GITHUB_TOKEN # # Custom actions used: -# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 +# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - github/gh-aw-actions/setup@v0.79.6 +# - github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6 # # Container images used: # - ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6 @@ -92,7 +92,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@v0.79.6 + uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -155,7 +155,7 @@ jobs: env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Checkout .github and .agents folders - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false sparse-checkout: | @@ -344,9 +344,8 @@ jobs: agent: needs: - activation - - extract_pr_number - - gate - if: needs.activation.outputs.daily_effective_workflow_exceeded != 'true' + - prepare + if: (needs.prepare.outputs.skip != 'true') && (needs.activation.outputs.daily_effective_workflow_exceeded != 'true') runs-on: ubuntu-latest permissions: actions: read @@ -383,7 +382,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@v0.79.6 + uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -404,7 +403,7 @@ jobs: echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" } >> "$GITHUB_OUTPUT" - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Create gh-aw temp directory @@ -489,15 +488,15 @@ jobs: mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_f496a449c5dccca1_EOF' - {"add_comment":{"max":1,"target":"${{ needs.extract_pr_number.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} - GH_AW_SAFE_OUTPUTS_CONFIG_f496a449c5dccca1_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_823c5547a5e52957_EOF' + {"add_comment":{"max":1,"target":"${{ needs.prepare.outputs.pr_number }}"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_823c5547a5e52957_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | { "description_suffixes": { - "add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: ${{ needs.extract_pr_number.outputs.pr_number }}. Supports reply_to_id for discussion threading." + "add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: ${{ needs.prepare.outputs.pr_number }}. Supports reply_to_id for discussion threading." }, "repo_params": {}, "dynamic_tools": [] @@ -994,8 +993,7 @@ jobs: - activation - agent - detection - - extract_pr_number - - gate + - prepare - safe_outputs if: > always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || @@ -1018,7 +1016,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@v0.79.6 + uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1208,7 +1206,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@v0.79.6 + uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1236,7 +1234,7 @@ jobs: echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" - name: Checkout repository for patch context if: needs.agent.outputs.has_patch == 'true' - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false # --- Threat Detection --- @@ -1429,111 +1427,6 @@ jobs: } } - extract_pr_number: - needs: gate - if: needs.gate.outputs.skip != 'true' && github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - permissions: - actions: read - - outputs: - pr_number: ${{ steps.extract.outputs.pr_number }} - steps: - - name: Configure GH_HOST for enterprise compatibility - id: ghes-host-config - shell: bash - # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input. - run: | - # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct - # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. - GH_HOST="${GITHUB_SERVER_URL#https://}" - GH_HOST="${GH_HOST#http://}" - echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" - - name: Download deterministic-results artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - name: check-requirements-deterministic - path: /tmp/deterministic - run-id: ${{ github.event.workflow_run.id }} - - name: Extract PR number from artifact - id: extract - run: | - PR=$(jq -r '.pr_number' /tmp/deterministic/results.json) - echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}" - - gate: - needs: activation - if: github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - pull-requests: read - - outputs: - skip: ${{ steps.gate.outputs.skip }} - steps: - - name: Configure GH_HOST for enterprise compatibility - id: ghes-host-config - shell: bash - # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input. - run: | - # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct - # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. - GH_HOST="${GITHUB_SERVER_URL#https://}" - GH_HOST="${GH_HOST#http://}" - echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" - - name: Download deterministic-results artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - name: check-requirements-deterministic - path: /tmp/gate - run-id: ${{ github.event.workflow_run.id }} - - name: Decide whether requirements changed since the last comment - id: gate - run: | - PR=$(jq -r '.pr_number' /tmp/gate/results.json) - HEAD=$(jq -r '.head_sha // empty' /tmp/gate/results.json) - if [ -z "${HEAD}" ]; then - echo "Artifact has no head_sha; running the agent." - exit 0 - fi - # Recover the commit recorded in the most recent requirements-check - # comment from the "Checked at commit" link - PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \ - --jq '.[] | select(.body | contains("")) | .body' \ - | grep -oiE '/commit/[0-9a-f]{40}' \ - | grep -oiE '[0-9a-f]{40}' | tail -1 || true) - if [ -z "${PRIOR}" ]; then - echo "No previous comment with a recorded commit; running the agent." - exit 0 - fi - if [ "${PRIOR}" = "${HEAD}" ]; then - echo "Head ${HEAD} unchanged since the last comment; skipping the agent." - echo "skip=true" >> "${GITHUB_OUTPUT}" - exit 0 - fi - # List files changed between the recorded commit and the current head. - # Tracked patterns mirror script/check_requirements/diff.py TRACKED_PATTERNS. - CHANGED=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${PRIOR}...${HEAD}" \ - --jq '.files[].filename' 2>/dev/null) || { - echo "Could not compare ${PRIOR}...${HEAD}; running the agent." - exit 0 - } - TRACKED=$(printf '%s\n' "${CHANGED}" \ - | grep -Ex 'requirements.*\.txt|homeassistant/package_constraints\.txt' || true) - if [ -z "${TRACKED}" ]; then - echo "No tracked requirement files changed since ${PRIOR}; skipping the agent." - echo "skip=true" >> "${GITHUB_OUTPUT}" - else - echo "Tracked requirement files changed since ${PRIOR}; running the agent:" - printf '%s\n' "${TRACKED}" - fi - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - pre_activation: runs-on: ubuntu-slim outputs: @@ -1545,7 +1438,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@v0.79.6 + uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1568,12 +1461,48 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs'); await main(); + prepare: + needs: activation + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + + outputs: + pr_number: ${{ steps.prepare.outputs.pr_number }} + skip: ${{ steps.prepare.outputs.skip }} + steps: + - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config + shell: bash + # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input. + run: | + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Download deterministic-results artifact + id: download + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + name: check-requirements-deterministic + path: /tmp/deterministic + run-id: ${{ github.event.workflow_run.id }} + - name: Resolve skip and PR number from the artifact + id: prepare + run: | + echo "skip=$(jq -r '.skip_aw' /tmp/deterministic/results.json)" >> "${GITHUB_OUTPUT}" + echo "pr_number=$(jq -r '.pr_number' /tmp/deterministic/results.json)" >> "${GITHUB_OUTPUT}" + safe_outputs: needs: - activation - agent - detection - - extract_pr_number + - prepare if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success' runs-on: ubuntu-slim permissions: @@ -1609,7 +1538,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@v0.79.6 + uses: github/gh-aw-actions/setup@5c2fe865bb4dc46e1450f6ee0d0541d759aea73a # v0.79.6 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1654,7 +1583,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "*.pythonhosted.org,anaconda.org,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,binstar.org,bootstrap.pypa.io,conda.anaconda.org,conda.binstar.org,files.pythonhosted.org,github.com,host.docker.internal,pip.pypa.io,pypi.org,pypi.python.org,raw.githubusercontent.com,registry.npmjs.org,repo.anaconda.com,repo.continuum.io,telemetry.enterprise.githubcopilot.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"target\":\"${{ needs.extract_pr_number.outputs.pr_number }}\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"target\":\"${{ needs.prepare.outputs.pr_number }}\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/check-requirements.md b/.github/workflows/check-requirements.md index 0931f6604877..6d7e788264b0 100644 --- a/.github/workflows/check-requirements.md +++ b/.github/workflows/check-requirements.md @@ -15,94 +15,41 @@ tools: github: toolsets: [repos, pull_requests] min-integrity: unapproved +if: needs.prepare.outputs.skip != 'true' safe-outputs: add-comment: max: 1 - target: "${{ needs.extract_pr_number.outputs.pr_number }}" + target: "${{ needs.prepare.outputs.pr_number }}" needs: - - extract_pr_number + - prepare jobs: - gate: - # Skip the (token-spending) agent when no tracked requirement file changed + prepare: + # The deterministic stage always uploads an artifact; its `skip_aw` flag is + # true when no tracked requirement file changed since the last comment, + # which is our cue to skip the (token-spending) agent. Recover the PR number + # to comment on either way. if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest permissions: actions: read contents: read - pull-requests: read outputs: - skip: ${{ steps.gate.outputs.skip }} - steps: - - name: Download deterministic-results artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: check-requirements-deterministic - path: /tmp/gate - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Decide whether requirements changed since the last comment - id: gate - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - PR=$(jq -r '.pr_number' /tmp/gate/results.json) - HEAD=$(jq -r '.head_sha // empty' /tmp/gate/results.json) - if [ -z "${HEAD}" ]; then - echo "Artifact has no head_sha; running the agent." - exit 0 - fi - # Recover the commit recorded in the most recent requirements-check - # comment from the "Checked at commit" link - PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \ - --jq '.[] | select(.body | contains("")) | .body' \ - | grep -oiE '/commit/[0-9a-f]{40}' \ - | grep -oiE '[0-9a-f]{40}' | tail -1 || true) - if [ -z "${PRIOR}" ]; then - echo "No previous comment with a recorded commit; running the agent." - exit 0 - fi - if [ "${PRIOR}" = "${HEAD}" ]; then - echo "Head ${HEAD} unchanged since the last comment; skipping the agent." - echo "skip=true" >> "${GITHUB_OUTPUT}" - exit 0 - fi - # List files changed between the recorded commit and the current head. - # Tracked patterns mirror script/check_requirements/diff.py TRACKED_PATTERNS. - CHANGED=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${PRIOR}...${HEAD}" \ - --jq '.files[].filename' 2>/dev/null) || { - echo "Could not compare ${PRIOR}...${HEAD}; running the agent." - exit 0 - } - TRACKED=$(printf '%s\n' "${CHANGED}" \ - | grep -Ex 'requirements.*\.txt|homeassistant/package_constraints\.txt' || true) - if [ -z "${TRACKED}" ]; then - echo "No tracked requirement files changed since ${PRIOR}; skipping the agent." - echo "skip=true" >> "${GITHUB_OUTPUT}" - else - echo "Tracked requirement files changed since ${PRIOR}; running the agent:" - printf '%s\n' "${TRACKED}" - fi - extract_pr_number: - needs: gate - if: needs.gate.outputs.skip != 'true' && github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - permissions: - actions: read - outputs: - pr_number: ${{ steps.extract.outputs.pr_number }} + skip: ${{ steps.prepare.outputs.skip }} + pr_number: ${{ steps.prepare.outputs.pr_number }} steps: - name: Download deterministic-results artifact + id: download uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: check-requirements-deterministic path: /tmp/deterministic run-id: ${{ github.event.workflow_run.id }} github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Extract PR number from artifact - id: extract + - name: Resolve skip and PR number from the artifact + id: prepare run: | - PR=$(jq -r '.pr_number' /tmp/deterministic/results.json) - echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}" + echo "skip=$(jq -r '.skip_aw' /tmp/deterministic/results.json)" >> "${GITHUB_OUTPUT}" + echo "pr_number=$(jq -r '.pr_number' /tmp/deterministic/results.json)" >> "${GITHUB_OUTPUT}" concurrency: group: ${{ github.workflow }}-${{ github.event.workflow_run.id }} cancel-in-progress: true diff --git a/requirements_test.txt b/requirements_test.txt index b978d4572121..68df430b6c97 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -19,6 +19,7 @@ mock-open==1.4.0 mypy==2.1.0 prek==0.2.28 pydantic==2.13.4 +PyGithub==2.9.1 pylint==4.0.6 pylint-per-file-ignores==3.2.1 pipdeptree==2.26.1 diff --git a/script/check_requirements/__main__.py b/script/check_requirements/__main__.py index 2f18423f34b2..27856d837485 100644 --- a/script/check_requirements/__main__.py +++ b/script/check_requirements/__main__.py @@ -2,12 +2,28 @@ import argparse import json +import os from pathlib import Path import sys +from .gate import GateDecision, decide_skip +from .models import CheckRunResult from .runner import run_checks +def _resolve_skip(pr_number: int, head_sha: str | None) -> GateDecision: + """Decide whether this run can skip re-checking the PR. + + Needs the repo and a token (from the Actions environment) to read prior + comments; without them it falls open and runs the checks. + """ + repo = os.environ.get("GITHUB_REPOSITORY") + token = os.environ.get("GITHUB_TOKEN") + if not head_sha or not repo or not token: + return GateDecision(False, "Gate inputs unavailable; running checks.") + return decide_skip(pr_number, head_sha, repo, token) + + def main(argv: list[str] | None = None) -> int: """Run the deterministic check_requirements stage and write its artifact.""" parser = argparse.ArgumentParser(prog="python -m script.check_requirements") @@ -31,24 +47,32 @@ def main(argv: list[str] | None = None) -> int: ) args = parser.parse_args(argv) - try: - diff_text = args.diff.read_text(encoding="utf-8") - except FileNotFoundError: - parser.error(f"input file {args.diff} not found") - result = run_checks( - pr_number=args.pr_number, - diff_text=diff_text, - head_sha=args.head_sha, - ) + decision = _resolve_skip(args.pr_number, args.head_sha) + print(decision.reason, file=sys.stderr) + if decision.skip: + result = CheckRunResult( + pr_number=args.pr_number, head_sha=args.head_sha, skip_aw=True + ) + else: + try: + diff_text = args.diff.read_text(encoding="utf-8") + except FileNotFoundError: + parser.error(f"input file {args.diff} not found") + result = run_checks( + pr_number=args.pr_number, + diff_text=diff_text, + head_sha=args.head_sha, + ) + print( + f"check_requirements: {len(result.packages)} package change(s); " + f"needs_agent={result.needs_agent}", + file=sys.stderr, + ) + args.output.write_text( json.dumps(result.to_dict(), indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) - print( - f"check_requirements: {len(result.packages)} package change(s); " - f"needs_agent={result.needs_agent}", - file=sys.stderr, - ) return 0 diff --git a/script/check_requirements/diff.py b/script/check_requirements/diff.py index f626b46de1ce..4f6cf9932dee 100644 --- a/script/check_requirements/diff.py +++ b/script/check_requirements/diff.py @@ -17,11 +17,13 @@ from .models import PackageChange # of truth for pinned package changes. TRACKED_PATTERNS = ( "requirements*.txt", + "**/requirements*.txt", "homeassistant/package_constraints.txt", ) -def _is_tracked(path: str) -> bool: +def is_tracked(path: str) -> bool: + """Return True if `path` is a requirement file the checks care about.""" return any(fnmatchcase(path, pattern) for pattern in TRACKED_PATTERNS) @@ -61,7 +63,7 @@ def parse_diff(diff_text: str) -> list[PackageChange]: added: dict[str, _Pin] = {} removed: dict[str, _Pin] = {} for patched_file in PatchSet(diff_text): - if not _is_tracked(patched_file.path): + if not is_tracked(patched_file.path): continue for hunk in patched_file: for line in hunk: diff --git a/script/check_requirements/gate.py b/script/check_requirements/gate.py new file mode 100644 index 000000000000..812a81e5bc28 --- /dev/null +++ b/script/check_requirements/gate.py @@ -0,0 +1,107 @@ +"""Decide whether the deterministic stage can skip re-checking a PR. + +The deterministic stage re-runs on every `synchronize` where the PR touches a +tracked requirement file, even when the latest push changed only unrelated +files. This module answers "did a tracked requirement file actually change +since we last commented?" so the stage can skip the PyPI work and flag the +uploaded artifact as skipped, telling the agentic stage to no-op. +""" + +from dataclasses import dataclass +import logging +import os +import re + +from github import Auth, Github, GithubException +from github.IssueComment import IssueComment + +from .diff import is_tracked +from .render import COMMIT_PATH + +_LOGGER = logging.getLogger(__name__) + +# The "Checked at commit [`abc1234`](...COMMIT_PATH<40-hex>)." link rendered by +# render._intro is the only place the head SHA is recorded in the comment. +_COMMIT_SHA_RE = re.compile(re.escape(COMMIT_PATH) + r"([0-9a-f]{40})", re.IGNORECASE) +_TRUSTED_AUTHOR = "github-actions[bot]" + + +def _is_trusted_author(comment: IssueComment) -> bool: + """True only for the github-actions bot that posts the check comment.""" + return comment.user is not None and comment.user.login == _TRUSTED_AUTHOR + + +@dataclass(slots=True, frozen=True) +class GateDecision: + """Whether to skip the deterministic checks, with a human-readable reason.""" + + skip: bool + reason: str + + +def _client(token: str) -> Github: + """A lazy GitHub client on the configured (possibly GHES) API base.""" + base_url = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/") + return Github(auth=Auth.Token(token), base_url=base_url, lazy=True) + + +def fetch_marker_comment_bodies(pr_number: int, repo: str, token: str) -> list[str]: + """Return the trusted requirements-check comment bodies, oldest-first.""" + try: + comments = _client(token).get_repo(repo).get_issue(pr_number).get_comments() + return [comment.body for comment in comments if _is_trusted_author(comment)] + except GithubException as err: + _LOGGER.warning("Could not read comments for PR #%s: %s", pr_number, err) + return [] + + +def extract_prior_sha(bodies: list[str]) -> str | None: + """Return the head SHA recorded in the most recent marker comment.""" + shas = [ + match.group(1).lower() + for body in bodies + for match in _COMMIT_SHA_RE.finditer(body) + ] + return shas[-1] if shas else None + + +def compare_changed_files( + base: str, head: str, repo: str, token: str +) -> list[str] | None: + """Return filenames changed between two commits, or None if unavailable.""" + try: + comparison = _client(token).get_repo(repo).compare(base, head) + return [changed.filename for changed in comparison.files] + except GithubException as err: + _LOGGER.warning("Could not compare %s...%s: %s", base, head, err) + return None + + +def decide_skip(pr_number: int, head_sha: str, repo: str, token: str) -> GateDecision: + """Decide whether requirements changed since the last comment.""" + if not head_sha: + return GateDecision(False, "No head SHA available; running checks.") + prior = extract_prior_sha(fetch_marker_comment_bodies(pr_number, repo, token)) + if prior is None: + return GateDecision( + False, "No previous requirements-check comment; running checks." + ) + if prior == head_sha.lower(): + return GateDecision( + True, f"Head {head_sha} unchanged since the last comment; skipping." + ) + changed = compare_changed_files(prior, head_sha, repo, token) + if changed is None: + return GateDecision( + False, f"Could not compare {prior}...{head_sha}; running checks." + ) + tracked = [path for path in changed if is_tracked(path)] + if tracked: + return GateDecision( + False, + f"Tracked requirement files changed since {prior}; running checks: " + + ", ".join(tracked), + ) + return GateDecision( + True, f"No tracked requirement files changed since {prior}; skipping." + ) diff --git a/script/check_requirements/models.py b/script/check_requirements/models.py index 5dced6ebf1b6..6d75c6c1413b 100644 --- a/script/check_requirements/models.py +++ b/script/check_requirements/models.py @@ -90,6 +90,7 @@ class CheckRunResult: head_sha: str | None = None packages: list[PackageChange] = field(default_factory=list) rendered_comment: str = "" + skip_aw: bool = False @property def needs_agent(self) -> bool: @@ -101,6 +102,7 @@ class CheckRunResult: return { "version": 1, "pr_number": self.pr_number, + "skip_aw": self.skip_aw, "head_sha": self.head_sha, "needs_agent": self.needs_agent, "packages": [p.to_dict() for p in self.packages], diff --git a/script/check_requirements/render.py b/script/check_requirements/render.py index a1637d8520f8..21443524b921 100644 --- a/script/check_requirements/render.py +++ b/script/check_requirements/render.py @@ -14,6 +14,7 @@ from .models import CheckKind, CheckRunResult, CheckStatus, PackageChange MARKER = "" HEADER = "## Check requirements" REPO_URL = "https://github.com/home-assistant/core" +COMMIT_PATH = "/commit/" # Column / bullet labels per check kind, in display order. _CHECK_DISPLAY: tuple[tuple[CheckKind, str], ...] = ( @@ -127,7 +128,7 @@ def _intro(result: CheckRunResult) -> str: """Marker, header, and the optional commit line the gate reads back.""" parts: list[str] = [] if result.head_sha: - commit = f"[`{result.head_sha[:7]}`]({REPO_URL}/commit/{result.head_sha})" + commit = f"[`{result.head_sha[:7]}`]({REPO_URL}{COMMIT_PATH}{result.head_sha})" parts.append(f"Checked at commit {commit}.") return "\n\n".join([f"{MARKER}\n{HEADER}", *parts]) diff --git a/script/check_requirements/requirements.txt b/script/check_requirements/requirements.txt index 619c96d5cf85..ab805860ad81 100644 --- a/script/check_requirements/requirements.txt +++ b/script/check_requirements/requirements.txt @@ -1,2 +1,3 @@ +PyGithub==2.9.1 requests==2.34.2 unidiff==0.7.5 diff --git a/tests/scripts/check_requirements/test_gate.py b/tests/scripts/check_requirements/test_gate.py new file mode 100644 index 000000000000..03738a0dbe32 --- /dev/null +++ b/tests/scripts/check_requirements/test_gate.py @@ -0,0 +1,223 @@ +"""Tests for script.check_requirements.gate.""" + +from collections.abc import Callable +from types import SimpleNamespace +from unittest.mock import MagicMock + +from github import GithubException +import pytest + +from script.check_requirements import gate +from script.check_requirements.gate import ( + decide_skip, + extract_prior_sha, + fetch_marker_comment_bodies, +) +from script.check_requirements.models import CheckRunResult +from script.check_requirements.render import render_comment + +_REPO = "home-assistant/core" +_TOKEN = "test-token" +_PRIOR = "1234567890abcdef1234567890abcdef12345678" +_HEAD = "fedcba0987654321fedcba0987654321fedcba09" + +InstallGithub = Callable[..., MagicMock] + + +def _body(sha: str | None) -> str: + body = "\n## Check requirements\n" + if sha is not None: + body += ( + f"\nChecked at commit " + f"[`{sha[:7]}`](https://github.com/home-assistant/core/commit/{sha})." + ) + return body + + +def _comment( + sha: str | None, *, author: str = "github-actions[bot]" +) -> SimpleNamespace: + """A PyGithub-like IssueComment with a body and an author login.""" + return SimpleNamespace(body=_body(sha), user=SimpleNamespace(login=author)) + + +def _file(filename: str) -> SimpleNamespace: + """A PyGithub-like File entry from a commit comparison.""" + return SimpleNamespace(filename=filename) + + +@pytest.fixture +def install_github(monkeypatch: pytest.MonkeyPatch) -> InstallGithub: + """Install a fake PyGithub client and return the installer for assertions.""" + + def _install( + *, + comments: list[SimpleNamespace] | None = None, + files: list[SimpleNamespace] | None = None, + comments_exc: Exception | None = None, + compare_exc: Exception | None = None, + ) -> MagicMock: + issue = MagicMock() + if comments_exc is not None: + issue.get_comments.side_effect = comments_exc + else: + issue.get_comments.return_value = comments or [] + repo = MagicMock() + repo.get_issue.return_value = issue + if compare_exc is not None: + repo.compare.side_effect = compare_exc + else: + repo.compare.return_value = SimpleNamespace(files=files or []) + client = MagicMock() + client.get_repo.return_value = repo + monkeypatch.setattr(gate, "Github", lambda **_: client) + return client + + return _install + + +@pytest.mark.parametrize( + ("bodies", "expected"), + [ + pytest.param([], None, id="no-comments"), + pytest.param( + ["\nNo commit link here."], + None, + id="marker-without-link", + ), + pytest.param([_body(_PRIOR)], _PRIOR, id="single-comment"), + pytest.param([_body(_PRIOR), _body(_HEAD)], _HEAD, id="most-recent-wins"), + ], +) +def test_extract_prior_sha(bodies: list[str], expected: str | None) -> None: + """The last recorded commit SHA across marker comments is returned.""" + assert extract_prior_sha(bodies) == expected + + +def test_extract_prior_sha_normalizes_case() -> None: + """An upper-case SHA in a link is returned lower-cased.""" + assert extract_prior_sha([_body(_PRIOR.upper())]) == _PRIOR + + +def test_extract_prior_sha_round_trips_rendered_comment() -> None: + """The gate reads back exactly the SHA the renderer wrote, keeping them in sync.""" + body = render_comment(CheckRunResult(pr_number=1, head_sha=_HEAD)) + assert extract_prior_sha([body]) == _HEAD + + +def test_fetch_marker_comment_bodies_returns_all_bot_comments( + install_github: InstallGithub, +) -> None: + """Every bot comment body is returned in API order; the marker is not filtered on.""" + install_github( + comments=[ + _comment(None), # no SHA recorded yet + _comment(_PRIOR), + SimpleNamespace( + body="chatter", user=SimpleNamespace(login="github-actions[bot]") + ), + ] + ) + bodies = fetch_marker_comment_bodies(7, _REPO, _TOKEN) + assert bodies == [_body(None), _body(_PRIOR), "chatter"] + + +@pytest.mark.parametrize( + "author", + [ + pytest.param("attacker", id="drive-by-commenter"), + pytest.param("dependabot[bot]", id="other-bot"), + pytest.param("maintainer", id="maintainer-account"), + ], +) +def test_fetch_marker_comment_bodies_ignores_non_actions_author( + install_github: InstallGithub, + author: str, +) -> None: + """A forged marker comment from anyone but github-actions is ignored.""" + install_github(comments=[_comment(_HEAD, author=author)]) + assert fetch_marker_comment_bodies(7, _REPO, _TOKEN) == [] + + +def test_fetch_marker_comment_bodies_handles_api_error( + install_github: InstallGithub, +) -> None: + """A GitHub API error yields no bodies (fails open) instead of raising.""" + install_github(comments_exc=GithubException(500, {}, {})) + assert fetch_marker_comment_bodies(7, _REPO, _TOKEN) == [] + + +def test_decide_skip_no_head_sha(install_github: InstallGithub) -> None: + """An empty head SHA never skips and makes no API calls.""" + client = install_github() + assert decide_skip(7, "", _REPO, _TOKEN).skip is False + client.get_repo.assert_not_called() + + +def test_decide_skip_no_prior_comment(install_github: InstallGithub) -> None: + """The first run (no prior comment) runs the checks.""" + install_github( + comments=[SimpleNamespace(body="hi", user=SimpleNamespace(login="x"))] + ) + assert decide_skip(7, _HEAD, _REPO, _TOKEN).skip is False + + +def test_decide_skip_head_unchanged(install_github: InstallGithub) -> None: + """When head matches the last comment's SHA, skip without comparing.""" + client = install_github(comments=[_comment(_HEAD)]) + assert decide_skip(7, _HEAD, _REPO, _TOKEN).skip is True + client.get_repo.return_value.compare.assert_not_called() + + +def test_decide_skip_tracked_files_changed(install_github: InstallGithub) -> None: + """A requirement file changed since the comment runs the checks.""" + install_github( + comments=[_comment(_PRIOR)], + files=[_file("homeassistant/foo.py"), _file("requirements_all.txt")], + ) + decision = decide_skip(7, _HEAD, _REPO, _TOKEN) + assert decision.skip is False + # The untracked file must not be reported as the reason to run. + assert "requirements_all.txt" in decision.reason + assert "homeassistant/foo.py" not in decision.reason + + +def test_decide_skip_no_tracked_files_changed(install_github: InstallGithub) -> None: + """Only non-requirement files changed since the comment, so skip.""" + install_github( + comments=[_comment(_PRIOR)], + files=[_file("homeassistant/components/demo/light.py")], + ) + assert decide_skip(7, _HEAD, _REPO, _TOKEN).skip is True + + +def test_decide_skip_compare_unavailable_runs(install_github: InstallGithub) -> None: + """A failed compare falls back to running the checks.""" + install_github( + comments=[_comment(_PRIOR)], compare_exc=GithubException(404, {}, {}) + ) + assert decide_skip(7, _HEAD, _REPO, _TOKEN).skip is False + + +def test_decide_skip_comments_error_runs(install_github: InstallGithub) -> None: + """A failed comments fetch fails open (runs the checks), never skips.""" + install_github(comments_exc=GithubException(500, {}, {})) + assert decide_skip(7, _HEAD, _REPO, _TOKEN).skip is False + + +def test_client_uses_github_api_url(monkeypatch: pytest.MonkeyPatch) -> None: + """GHES is supported by passing GITHUB_API_URL (trailing slash stripped).""" + captured: dict[str, object] = {} + monkeypatch.setenv("GITHUB_API_URL", "https://ghe.example.com/api/v3/") + + def _fake_github(**kwargs: object) -> MagicMock: + captured.update(kwargs) + client = MagicMock() + client.get_repo.return_value.get_issue.return_value.get_comments.return_value = [ + _comment(_HEAD) + ] + return client + + monkeypatch.setattr(gate, "Github", _fake_github) + assert decide_skip(7, _HEAD, _REPO, _TOKEN).skip is True + assert captured["base_url"] == "https://ghe.example.com/api/v3" diff --git a/tests/scripts/check_requirements/test_main.py b/tests/scripts/check_requirements/test_main.py index 01c76507cb21..c36c2d95f88c 100644 --- a/tests/scripts/check_requirements/test_main.py +++ b/tests/scripts/check_requirements/test_main.py @@ -6,17 +6,14 @@ from pathlib import Path import pytest from script.check_requirements import __main__ as main_mod +from script.check_requirements.gate import GateDecision from script.check_requirements.pypi import ProvenanceResult, PypiPackageInfo +_SHA = "abc1234def5678abc1234def5678abc1234def56" -def test_main_writes_artifact( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - """The CLI parses args, runs checks, and writes a JSON artifact.""" - diff_file = tmp_path / "diff.patch" - diff_file.write_text( + +def _write_bump_diff(path: Path) -> None: + path.write_text( "diff --git a/requirements_all.txt b/requirements_all.txt\n" "--- a/requirements_all.txt\n" "+++ b/requirements_all.txt\n" @@ -25,8 +22,9 @@ def test_main_writes_artifact( "+pkg==1.1.0\n", encoding="utf-8", ) - output_file = tmp_path / "results.json" + +def _mock_pypi(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "script.check_requirements.runner.fetch_package_info", lambda name, version: PypiPackageInfo( @@ -46,13 +44,27 @@ def test_main_writes_artifact( ), ) - sha = "abc1234def5678abc1234def5678abc1234def56" + +def test_main_writes_artifact( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """When the gate runs the checks, the CLI writes a non-skipped artifact.""" + diff_file = tmp_path / "diff.patch" + _write_bump_diff(diff_file) + output_file = tmp_path / "results.json" + monkeypatch.setattr( + main_mod, "_resolve_skip", lambda pr, sha: GateDecision(False, "running checks") + ) + _mock_pypi(monkeypatch) + exit_code = main_mod.main( [ "--pr-number", "42", "--head-sha", - sha, + _SHA, "--diff", str(diff_file), "--output", @@ -62,16 +74,70 @@ def test_main_writes_artifact( assert exit_code == 0 payload = json.loads(output_file.read_text(encoding="utf-8")) + assert payload["skip_aw"] is False assert payload["pr_number"] == 42 - assert payload["head_sha"] == sha + assert payload["head_sha"] == _SHA assert payload["packages"][0]["name"] == "pkg" assert ( - f"https://github.com/home-assistant/core/commit/{sha}" + f"https://github.com/home-assistant/core/commit/{_SHA}" in payload["rendered_comment"] ) + assert "check_requirements: 1 package change(s)" in capsys.readouterr().err - captured = capsys.readouterr() - assert "check_requirements: 1 package change(s)" in captured.err + +def test_main_skips_but_still_writes_artifact( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When the gate skips, no checks run but a skip-flagged artifact is written.""" + diff_file = tmp_path / "diff.patch" + _write_bump_diff(diff_file) + output_file = tmp_path / "results.json" + monkeypatch.setattr( + main_mod, "_resolve_skip", lambda pr, sha: GateDecision(True, "nothing changed") + ) + + # The checks must not run when skipping; make them explode if they do. + def _boom(name: str, version: str) -> None: + raise AssertionError("checks must not run when the gate skips") + + monkeypatch.setattr("script.check_requirements.runner.fetch_package_info", _boom) + + exit_code = main_mod.main( + [ + "--pr-number", + "42", + "--head-sha", + _SHA, + "--diff", + str(diff_file), + "--output", + str(output_file), + ] + ) + assert exit_code == 0 + payload = json.loads(output_file.read_text(encoding="utf-8")) + assert payload == { + "version": 1, + "pr_number": 42, + "skip_aw": True, + "head_sha": _SHA, + "needs_agent": False, + "packages": [], + "rendered_comment": "", + } + + +def test_resolve_skip_without_credentials_runs(monkeypatch: pytest.MonkeyPatch) -> None: + """Missing repo/token falls open (runs) without ever calling the gate.""" + monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + + def _boom(*args: object, **kwargs: object) -> None: + raise AssertionError("decide_skip must not be called without credentials") + + monkeypatch.setattr(main_mod, "decide_skip", _boom) + assert main_mod._resolve_skip(42, _SHA).skip is False def test_main_missing_diff_file_exits(