Skip to content

Commit f89f0eb

Browse files
WilliamBerryiiiBill Berry
andauthored
feat(workflows): add Python linting CI workflow with Ruff (#951)
## Description Add a reusable GitHub Actions workflow for Python linting with Ruff and integrate it into the `pr-validation.yml` hub workflow. ### What changed - **New `python-lint.yml` reusable workflow** (113 lines) — 9-step pipeline performing Ruff check and format verification with JSON artifact upload, changed-file-only detection, and configurable soft-fail mode. - **Hub integration in `pr-validation.yml`** — added `python-lint` job (lines 53-62) targeting `.github/skills/experimental/powerpoint` with `soft-fail: false` and `changed-files-only: true`. ### How it works 1. Caller passes `working-directory`, `soft-fail`, and `changed-files-only` inputs via `workflow_call`. 2. Workflow checks out code, installs uv and Python 3.11, syncs dependencies from the target directory. 3. When `changed-files-only` is true, detects modified `.py` files via `git diff` against the base branch — skips linting when none are found. 4. Runs `ruff check` producing both human-readable output and a JSON artifact (`ruff-results.json`), then runs `ruff format --check`. 5. Uploads JSON results as a workflow artifact and gates the job on lint/format outcomes (respecting `soft-fail`). All third-party actions are SHA-pinned (`checkout v4.2.2`, `setup-uv v5.4.1`, `setup-python v6.2.0`, `upload-artifact v4.4.3`). Permissions follow least-privilege with `contents: read` at both workflow and job levels. ## Related Issue(s) Closes #889 ## Type of Change Select all that apply: **Code & Documentation:** * [ ] Bug fix (non-breaking change fixing an issue) * [x] New feature (non-breaking change adding functionality) * [ ] Breaking change (fix or feature causing existing functionality to change) * [ ] Documentation update **Infrastructure & Configuration:** * [x] GitHub Actions workflow * [ ] Linting configuration (markdown, PowerShell, etc.) * [ ] Security configuration * [ ] DevContainer configuration * [ ] Dependency update **AI Artifacts:** * [ ] Reviewed contribution with `prompt-builder` agent and addressed all feedback * [ ] Copilot instructions (`.github/instructions/*.instructions.md`) * [ ] Copilot prompt (`.github/prompts/*.prompt.md`) * [ ] Copilot agent (`.github/agents/*.agent.md`) * [ ] Copilot skill (`.github/skills/*/SKILL.md`) > Note for AI Artifact Contributors: > > * Agents: Research, indexing/referencing other project (using standard VS Code GitHub Copilot/MCP tools), planning, and general implementation agents likely already exist. Review `.github/agents/` before creating new ones. > * Skills: Must include both bash and PowerShell scripts. See [Skills](../docs/contributing/skills.md). > * Model Versions: Only contributions targeting the **latest Anthropic and OpenAI models** will be accepted. Older model versions (e.g., GPT-3.5, Claude 3) will be rejected. > * See [Agents Not Accepted](../docs/contributing/custom-agents.md#agents-not-accepted) and [Model Version Requirements](../docs/contributing/ai-artifacts-common.md#model-version-requirements). **Other:** * [ ] Script/automation (`.ps1`, `.sh`, `.py`) * [ ] Other (please describe): ## Testing **Automated validation results (all passed):** | Command | Result | |---|---| | `npm run lint:md` | Pass — 145 files, 0 errors | | `npm run spell-check` | Pass — 234 files, 0 issues | | `npm run lint:frontmatter` | Pass — 296 files, 0 errors | | `npm run validate:skills` | Pass — 3 skills, 0 errors | | `npm run lint:md-links` | Pass | | `npm run lint:ps` | Pass — all files clean | | `npm run plugin:generate` | Pass — 11 plugins generated, 0 formatting changes | **Diff-based assessment:** - Subagent review completed — confirmed SHA-pinning consistency, least-privilege permissions, hub-and-spoke integration pattern, and stdout-only JSON redirect for artifact integrity. - Target working directory `.github/skills/experimental/powerpoint` does not yet exist on `main`; expected pending PR #868 for the PowerPoint skill. **Manual testing:** - Manual testing was not performed. Workflow validation requires GitHub Actions execution on push. ## Checklist ### Required Checks * [ ] Documentation is updated (N/A — no documentation changes required for workflow addition) * [x] Files follow existing naming conventions * [ ] Changes are backwards compatible (N/A — new workflow with no impact on existing functionality) * [ ] Tests added for new functionality (N/A — no test infrastructure for GitHub Actions workflows) ### AI Artifact Contributions <!-- Not applicable — no AI artifact changes in this PR --> ### Required Automated Checks The following validation commands must pass before merging: * [x] Markdown linting: `npm run lint:md` * [x] Spell checking: `npm run spell-check` * [x] Frontmatter validation: `npm run lint:frontmatter` * [x] Skill structure validation: `npm run validate:skills` * [x] Link validation: `npm run lint:md-links` * [x] PowerShell analysis: `npm run lint:ps` * [x] Plugin freshness: `npm run plugin:generate` ## Security Considerations * [x] This PR does not contain any sensitive or NDA information * [x] Any new dependencies have been reviewed for security issues * [x] Security-related scripts follow the principle of least privilege **Security analysis:** - No customer data or secrets in changes. - Runtime dependencies limited to `uv` and `ruff` (well-established Python dev tools). All GitHub Actions are SHA-pinned to known release tags. - Workflow permissions restricted to `contents: read`. Checkout uses `persist-credentials: false`. - No elevated privileges required. ## Additional Notes - The `python-lint` job targets `.github/skills/experimental/powerpoint` which depends on the PowerPoint skill from PR #868. The lint job will skip gracefully when no Python files are found in the target directory. - Two commits in this PR: initial implementation (`80156d0`) and a follow-up fix (`7393ab8`) addressing review findings — consuming the `changed-files-only` input and correcting stderr redirect behavior for JSON artifact integrity. --------- Co-authored-by: Bill Berry <wbery@microsoft.com>
1 parent 5dbab82 commit f89f0eb

File tree

6 files changed

+251
-63
lines changed

6 files changed

+251
-63
lines changed

.github/workflows/label-sync.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
issues: write
2626
steps:
2727
- name: Checkout code
28-
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
28+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2
2929
with:
3030
persist-credentials: false
3131

.github/workflows/pr-validation.yml

Lines changed: 56 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,56 @@ jobs:
5050
soft-fail: false
5151
changed-files-only: true
5252

53+
discover-python-projects:
54+
name: Discover Python Projects
55+
runs-on: ubuntu-latest
56+
permissions:
57+
contents: read
58+
outputs:
59+
directories: ${{ steps.find.outputs.directories }}
60+
has-projects: ${{ steps.find.outputs.has-projects }}
61+
steps:
62+
- name: Checkout repository
63+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2
64+
with:
65+
persist-credentials: false
66+
67+
- name: Find Python projects
68+
id: find
69+
shell: pwsh
70+
run: |
71+
$projectList = @(Get-ChildItem -Recurse -Filter pyproject.toml |
72+
Where-Object { $_.FullName -notmatch 'node_modules' } |
73+
ForEach-Object { Resolve-Path -Relative $_.DirectoryName } |
74+
ForEach-Object { $_ -replace '^\.[\\/]', '' } |
75+
Sort-Object)
76+
$jsonItems = $projectList | ForEach-Object { $_ | ConvertTo-Json -Compress }
77+
$dirs = '[' + (($jsonItems) -join ',') + ']'
78+
"directories=$dirs" >> $env:GITHUB_OUTPUT
79+
if ($projectList.Count -eq 0) {
80+
"has-projects=false" >> $env:GITHUB_OUTPUT
81+
Write-Output 'No Python projects found'
82+
} else {
83+
"has-projects=true" >> $env:GITHUB_OUTPUT
84+
Write-Output "Found Python projects: $dirs"
85+
}
86+
87+
python-lint:
88+
name: "Python Lint (${{ matrix.directory }})"
89+
needs: discover-python-projects
90+
if: needs.discover-python-projects.outputs.has-projects == 'true'
91+
strategy:
92+
fail-fast: false
93+
matrix:
94+
directory: ${{ fromJson(needs.discover-python-projects.outputs.directories) }}
95+
uses: ./.github/workflows/python-lint.yml
96+
permissions:
97+
contents: read
98+
with:
99+
soft-fail: false
100+
changed-files-only: true
101+
working-directory: ${{ matrix.directory }}
102+
53103
copyright-headers:
54104
name: Copyright Headers
55105
uses: ./.github/workflows/copyright-headers.yml
@@ -78,71 +128,22 @@ jobs:
78128
changed-files-only: false
79129
code-coverage: true
80130

81-
# Discover Python skills with pyproject.toml
82-
discover-python-skills:
83-
name: Discover Python Skills
84-
runs-on: ubuntu-latest
85-
permissions:
86-
contents: read
87-
outputs:
88-
matrix: ${{ steps.discover.outputs.matrix }}
89-
has-skills: ${{ steps.discover.outputs.has-skills }}
90-
steps:
91-
- name: Checkout code
92-
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2
93-
with:
94-
persist-credentials: false
95-
fetch-depth: 0
96-
97-
- name: Discover Python skill directories
98-
id: discover
99-
shell: bash
100-
run: |
101-
# Find all pyproject.toml files under .github/skills/
102-
SKILLS=$(find .github/skills -name "pyproject.toml" -exec dirname {} \; 2>/dev/null | sort -u)
103-
104-
if [ -z "$SKILLS" ]; then
105-
echo "No Python skills found"
106-
echo "matrix={\"include\":[]}" >> "$GITHUB_OUTPUT"
107-
echo "has-skills=false" >> "$GITHUB_OUTPUT"
108-
exit 0
109-
fi
110-
111-
# Build JSON matrix
112-
MATRIX='{"include":['
113-
FIRST=true
114-
while IFS= read -r skill_dir; do
115-
# Get skill name from directory path
116-
SKILL_NAME=$(basename "$skill_dir")
117-
if [ "$FIRST" = true ]; then
118-
FIRST=false
119-
else
120-
MATRIX+=','
121-
fi
122-
MATRIX+="{\"name\":\"$SKILL_NAME\",\"working-directory\":\"$skill_dir\"}"
123-
done <<< "$SKILLS"
124-
MATRIX+=']}'
125-
126-
echo "matrix=$(echo "$MATRIX" | jq -c .)" >> "$GITHUB_OUTPUT"
127-
echo "has-skills=true" >> "$GITHUB_OUTPUT"
128-
echo "Discovered Python skills:"
129-
echo "$MATRIX" | jq .
130-
131131
pytest:
132-
name: Python Tests (${{ matrix.name }})
133-
needs: discover-python-skills
134-
if: needs.discover-python-skills.outputs.has-skills == 'true'
132+
name: "Python Tests (${{ matrix.directory }})"
133+
needs: discover-python-projects
134+
if: needs.discover-python-projects.outputs.has-projects == 'true'
135135
uses: ./.github/workflows/pytest-tests.yml
136136
permissions:
137137
contents: read
138138
id-token: write
139139
with:
140-
working-directory: ${{ matrix.working-directory }}
140+
working-directory: ${{ matrix.directory }}
141141
soft-fail: false
142142
changed-files-only: true
143143
strategy:
144144
fail-fast: false
145-
matrix: ${{ fromJson(needs.discover-python-skills.outputs.matrix) }}
145+
matrix:
146+
directory: ${{ fromJson(needs.discover-python-projects.outputs.directories) }}
146147

147148
docusaurus-tests:
148149
name: Docusaurus Tests

.github/workflows/pytest-tests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ jobs:
5959
6060
- name: Install uv
6161
if: "!inputs.changed-files-only || steps.check-python.outputs.has_changes == 'true'"
62-
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
62+
uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0
6363
with:
64-
version: "latest"
64+
version: "0.10.9"
6565

6666
- name: Set up Python
6767
if: "!inputs.changed-files-only || steps.check-python.outputs.has_changes == 'true'"

.github/workflows/python-lint.yml

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
name: Python Lint
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
soft-fail:
7+
description: 'When true, linting warnings do not fail the build'
8+
required: false
9+
type: boolean
10+
default: false
11+
changed-files-only:
12+
description: 'When true, lint only changed Python files'
13+
required: false
14+
type: boolean
15+
default: true
16+
working-directory:
17+
description: 'Directory containing pyproject.toml with ruff config'
18+
required: true
19+
type: string
20+
21+
permissions:
22+
contents: read
23+
24+
jobs:
25+
python-lint:
26+
name: Ruff Lint and Format Check
27+
runs-on: ubuntu-latest
28+
permissions:
29+
contents: read
30+
defaults:
31+
run:
32+
shell: pwsh
33+
34+
steps:
35+
- name: Checkout repository
36+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2
37+
with:
38+
persist-credentials: false
39+
fetch-depth: 0
40+
41+
- name: Derive artifact name
42+
id: meta
43+
run: |
44+
$name = '${{ inputs.working-directory }}' -replace '/', '-' -replace '^\.', '' -replace '^-', ''
45+
"artifact-name=python-lint-$name" >> $env:GITHUB_OUTPUT
46+
47+
- name: Install uv
48+
uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0
49+
with:
50+
version: "0.10.9"
51+
52+
- name: Setup Python
53+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
54+
with:
55+
python-version: "3.12"
56+
57+
- name: Install dependencies
58+
run: uv sync --locked
59+
working-directory: ${{ inputs.working-directory }}
60+
61+
- name: Detect changed Python files
62+
if: inputs.changed-files-only
63+
run: |
64+
$baseRef = if ($env:GITHUB_BASE_REF) { $env:GITHUB_BASE_REF } else { 'main' }
65+
$workingDir = '${{ inputs.working-directory }}'
66+
$changed = git diff --name-only --diff-filter=ACMR "origin/$baseRef...HEAD" -- $workingDir | Where-Object { $_ -match '\.pyi?$' }
67+
if ($changed) {
68+
"HAS_CHANGES=true" >> $env:GITHUB_ENV
69+
$fileCount = @($changed).Count
70+
Write-Output "Detected $fileCount changed Python file(s)"
71+
} else {
72+
Write-Output "No Python file changes detected under $workingDir"
73+
}
74+
75+
- name: Run ruff check
76+
id: ruff-check
77+
if: "!inputs.changed-files-only || env.HAS_CHANGES == 'true'"
78+
run: |
79+
New-Item -ItemType Directory -Path "$env:GITHUB_WORKSPACE/logs" -Force | Out-Null
80+
# JSON output -> file only; stderr flows to CI log to preserve valid JSON
81+
uv run ruff check . --output-format json > "$env:GITHUB_WORKSPACE/logs/python-lint-results.json"
82+
if ($LASTEXITCODE -ne 0) { "RUFF_CHECK_FAILED=true" >> $env:GITHUB_ENV }
83+
uv run ruff check .
84+
if ($LASTEXITCODE -ne 0) { "RUFF_CHECK_FAILED=true" >> $env:GITHUB_ENV }
85+
$global:LASTEXITCODE = 0
86+
working-directory: ${{ inputs.working-directory }}
87+
continue-on-error: ${{ inputs.soft-fail }}
88+
89+
- name: Run ruff format check
90+
id: ruff-format
91+
if: "!inputs.changed-files-only || env.HAS_CHANGES == 'true'"
92+
run: |
93+
# Stderr flows to CI log; no file redirect so output stays readable
94+
uv run ruff format --check .
95+
if ($LASTEXITCODE -ne 0) { "RUFF_FORMAT_FAILED=true" >> $env:GITHUB_ENV }
96+
$global:LASTEXITCODE = 0
97+
working-directory: ${{ inputs.working-directory }}
98+
continue-on-error: ${{ inputs.soft-fail }}
99+
100+
- name: Upload lint results
101+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v4.4.3
102+
if: always() && (!inputs.changed-files-only || env.HAS_CHANGES == 'true')
103+
with:
104+
name: ${{ steps.meta.outputs.artifact-name }}
105+
path: logs/python-lint-results.json
106+
retention-days: 30
107+
if-no-files-found: ignore
108+
109+
- name: Check results
110+
if: "!inputs.soft-fail && (!inputs.changed-files-only || env.HAS_CHANGES == 'true')"
111+
run: |
112+
if ($env:RUFF_CHECK_FAILED -eq 'true' -or $env:RUFF_FORMAT_FAILED -eq 'true') {
113+
Write-Output '::error::Python linting failed'
114+
if ($env:RUFF_CHECK_FAILED -eq 'true') { Write-Output ' - ruff check failed' }
115+
if ($env:RUFF_FORMAT_FAILED -eq 'true') { Write-Output ' - ruff format check failed' }
116+
exit 1
117+
}
118+
Write-Output 'All Python lint checks passed'

.github/workflows/release-stable.yml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,73 @@ jobs:
8181
with:
8282
soft-fail: false
8383

84+
discover-python-projects:
85+
name: Discover Python Projects
86+
runs-on: ubuntu-latest
87+
permissions:
88+
contents: read
89+
outputs:
90+
directories: ${{ steps.find.outputs.directories }}
91+
has-projects: ${{ steps.find.outputs.has-projects }}
92+
steps:
93+
- name: Checkout repository
94+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2
95+
with:
96+
persist-credentials: false
97+
98+
- name: Find Python projects
99+
id: find
100+
shell: pwsh
101+
run: |
102+
$projectList = @(Get-ChildItem -Recurse -Filter pyproject.toml |
103+
Where-Object { $_.FullName -notmatch 'node_modules' } |
104+
ForEach-Object { Resolve-Path -Relative $_.DirectoryName } |
105+
ForEach-Object { $_ -replace '^\.[\\/]', '' } |
106+
Sort-Object)
107+
$jsonItems = $projectList | ForEach-Object { $_ | ConvertTo-Json -Compress }
108+
$dirs = '[' + (($jsonItems) -join ',') + ']'
109+
"directories=$dirs" >> $env:GITHUB_OUTPUT
110+
if ($projectList.Count -eq 0) {
111+
"has-projects=false" >> $env:GITHUB_OUTPUT
112+
Write-Output 'No Python projects found'
113+
} else {
114+
"has-projects=true" >> $env:GITHUB_OUTPUT
115+
Write-Output "Found Python projects: $dirs"
116+
}
117+
118+
python-lint:
119+
name: "Python Lint (${{ matrix.directory }})"
120+
needs: discover-python-projects
121+
if: needs.discover-python-projects.outputs.has-projects == 'true'
122+
strategy:
123+
fail-fast: false
124+
matrix:
125+
directory: ${{ fromJson(needs.discover-python-projects.outputs.directories) }}
126+
uses: ./.github/workflows/python-lint.yml
127+
permissions:
128+
contents: read
129+
with:
130+
soft-fail: false
131+
changed-files-only: false
132+
working-directory: ${{ matrix.directory }}
133+
134+
pytest:
135+
name: "Python Tests (${{ matrix.directory }})"
136+
needs: discover-python-projects
137+
if: needs.discover-python-projects.outputs.has-projects == 'true'
138+
uses: ./.github/workflows/pytest-tests.yml
139+
permissions:
140+
contents: read
141+
id-token: write
142+
with:
143+
working-directory: ${{ matrix.directory }}
144+
soft-fail: false
145+
changed-files-only: false
146+
strategy:
147+
fail-fast: false
148+
matrix:
149+
directory: ${{ fromJson(needs.discover-python-projects.outputs.directories) }}
150+
84151
release-please:
85152
name: Release Please
86153
needs:
@@ -91,6 +158,8 @@ jobs:
91158
- gitleaks-scan
92159
- pester-tests
93160
- docusaurus-tests
161+
- python-lint
162+
- pytest
94163
runs-on: ubuntu-latest
95164
outputs:
96165
release_created: ${{ steps.release.outputs.release_created }}

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)