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
10 changes: 5 additions & 5 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -58,19 +58,19 @@ 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
if-no-files-found: error

- 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

Expand All @@ -86,4 +86,4 @@ jobs:
steps:
- name: Deploy Pages artifact
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
27 changes: 17 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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

Expand All @@ -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' ||
Expand All @@ -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
Expand Down Expand Up @@ -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' ||
Expand All @@ -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
Expand All @@ -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: |
Expand All @@ -151,7 +155,7 @@ jobs:
)
)
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- name: Setup Python
uses: actions/setup-python@v5
Expand All @@ -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/
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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/
Expand All @@ -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 }}
Expand Down
81 changes: 81 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
@@ -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)
Loading