diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 586db83..2bf418b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -41,7 +41,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v5 @@ -58,7 +58,7 @@ jobs: run: sphinx-build -W --keep-going -b html docs _site - name: Upload build artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: docs-site path: _site @@ -66,11 +66,11 @@ jobs: - name: Configure GitHub Pages if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Upload GitHub Pages artifact if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: _site @@ -86,4 +86,4 @@ jobs: steps: - name: Deploy Pages artifact id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8999ba..a16927b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,13 +23,14 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: # === DETECT CHANGES - determines which jobs should run === detect-changes: name: Detect Changes runs-on: ubuntu-latest + timeout-minutes: 5 if: github.event_name != 'workflow_dispatch' outputs: py-changed: ${{ steps.changes.outputs.py-changed }} @@ -39,7 +40,7 @@ jobs: workflow-changed: ${{ steps.changes.outputs.workflow-changed }} any-code-changed: ${{ steps.changes.outputs.any-code-changed }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -65,6 +66,7 @@ jobs: lint: name: Lint and Format Check runs-on: ubuntu-latest + timeout-minutes: 20 needs: [detect-changes] if: | github.event_name == 'push' || @@ -75,7 +77,7 @@ jobs: needs.detect-changes.outputs.package-changed == 'true' || needs.detect-changes.outputs.workflow-changed == 'true' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v5 @@ -104,6 +106,7 @@ jobs: test: name: Test (Python 3.13) runs-on: ubuntu-latest + timeout-minutes: 30 needs: [detect-changes] if: | github.event_name == 'push' || @@ -113,7 +116,7 @@ jobs: needs.detect-changes.outputs.package-changed == 'true' || needs.detect-changes.outputs.workflow-changed == 'true' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v5 @@ -139,6 +142,7 @@ jobs: build: name: Build Package runs-on: ubuntu-latest + timeout-minutes: 20 needs: [detect-changes, lint, test] # Run if: push/dispatch event, OR lint/test succeeded, OR lint/test were skipped (docs-only PR) if: | @@ -151,7 +155,7 @@ jobs: ) ) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v5 @@ -170,7 +174,7 @@ jobs: run: twine check dist/* - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: dist path: dist/ @@ -180,10 +184,11 @@ jobs: changelog: name: Changelog Fragment Check runs-on: ubuntu-latest + timeout-minutes: 10 needs: [detect-changes] if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -230,11 +235,12 @@ jobs: needs: [lint, test, build] if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: write id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -266,7 +272,7 @@ jobs: - name: Download artifacts if: steps.version_check.outputs.should_release == 'true' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: dist path: dist/ @@ -290,11 +296,12 @@ jobs: needs: [lint, test, build] if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: write id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 0000000..60a3bc7 --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,81 @@ +"""Regression tests for GitHub Actions workflow policy.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / ".github" / "workflows" + + +def read_workflow(name: str) -> str: + """Read a workflow file by name.""" + return (WORKFLOWS / name).read_text(encoding="utf-8") + + +def workflow_job_block(workflow: str, job_name: str) -> str: + """Return the YAML text block for one top-level workflow job.""" + lines = workflow.splitlines() + start = next(index for index, line in enumerate(lines) if line == f" {job_name}:") + end = next( + ( + index + for index, line in enumerate(lines[start + 1 :], start + 1) + if re.match(r"^ [A-Za-z0-9_-]+:$", line) + ), + len(lines), + ) + return "\n".join(lines[start:end]) + + +def assert_action_pin_count( + workflow: str, action: str, version: str, count: int +) -> None: + """Assert every expected action reference is pinned to the requested version.""" + pattern = rf"uses:\s+{re.escape(action)}@{re.escape(version)}\b" + assert len(re.findall(pattern, workflow)) == count + + +def test_release_workflow_keeps_main_releases_running() -> None: + """Main release runs must not be cancelled by follow-up pushes.""" + workflow = read_workflow("release.yml") + + assert "cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}" in workflow + assert "cancel-in-progress: true" not in workflow + + +def test_release_workflow_jobs_have_explicit_timeouts() -> None: + """Release workflow jobs should fail fast instead of using the six-hour default.""" + workflow = read_workflow("release.yml") + + expected_timeouts = { + "detect-changes": 5, + "lint": 20, + "test": 30, + "build": 20, + "changelog": 10, + "auto-release": 30, + "manual-release": 30, + } + + for job_name, timeout in expected_timeouts.items(): + block = workflow_job_block(workflow, job_name) + assert f"timeout-minutes: {timeout}" in block + + +def test_workflow_action_versions_are_current() -> None: + """Workflow actions should use the current major versions.""" + release_workflow = read_workflow("release.yml") + docs_workflow = read_workflow("docs.yml") + + assert_action_pin_count(release_workflow, "actions/checkout", "v6", 7) + assert_action_pin_count(release_workflow, "actions/upload-artifact", "v7", 1) + assert_action_pin_count(release_workflow, "actions/download-artifact", "v7", 1) + + assert_action_pin_count(docs_workflow, "actions/checkout", "v6", 1) + assert_action_pin_count(docs_workflow, "actions/upload-artifact", "v7", 1) + assert_action_pin_count(docs_workflow, "actions/configure-pages", "v6", 1) + assert_action_pin_count(docs_workflow, "actions/upload-pages-artifact", "v5", 1) + assert_action_pin_count(docs_workflow, "actions/deploy-pages", "v5", 1)