Skip to content
Merged
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
49 changes: 46 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,36 @@ on:
required: true
type: string

# Least privilege, granted per job rather than to the whole file.
#
# This block used to read `contents: write` + `id-token: write` +
# `attestations: write`, and only two of the four jobs narrowed it. Under GitHub
# Actions' inheritance rules a job with no `permissions:` block of its own receives
# the whole workflow-level grant, so `quality-gate` and `build` were both handed
# `id-token: write` — the ability to mint an OIDC token — despite neither needing one.
# Only `publish-pypi` does, for PyPI trusted publishing, and it already scopes itself.
#
# Why that matters more here than in an ordinary repository: an OIDC token minted
# anywhere in this workflow may be accepted by PyPI as proof of identity for
# data-product-forge-sdk. So any step in `quality-gate` or `build` — including a
# third-party action reached through a floating tag — could request one and publish,
# without ever entering the job that is supposed to be the only publisher. The
# `environment: pypi` claim on `publish-pypi` blocks this ONLY IF the trusted-publisher
# configuration on PyPI also pins that environment name; if that field is blank there,
# nothing distinguishes a token minted in `build` from the real one. That setting lives
# on pypi.org and cannot be read from here, so this workflow does not depend on it.
#
# Precedent, not hypothesis: the compromise of aquasecurity/trivy-action in March 2026
# rewrote 75 of its 76 tags, and reached a backdoored LiteLLM release through that
# action sitting inside LiteLLM's own CI.
#
# `attestations: write` is dropped outright. It appeared exactly once in this file —
# in the grant itself. Nothing has ever used it.
#
# The default is now read-only, so a job added later without its own block gets the
# safe end of the mistake rather than the dangerous one.
permissions:
contents: write
id-token: write
attestations: write
contents: read

env:
PYTHON_VERSION: "3.12"
Expand All @@ -33,6 +59,11 @@ jobs:
quality-gate:
name: Quality Gate
runs-on: ubuntu-latest
# Lints, tests and reads the version out of pyproject.toml. Checkout is the only
# thing here that touches the token. Explicit rather than inherited: the whole
# defect being fixed was a job silently receiving more than it asked for.
permissions:
contents: read
outputs:
version: ${{ steps.meta.outputs.version }}
is_prerelease: ${{ steps.meta.outputs.is_prerelease }}
Expand Down Expand Up @@ -85,6 +116,13 @@ jobs:
name: Build Package
needs: quality-gate
runs-on: ubuntu-latest
# Builds the sdist and wheel and uploads them as a run artifact. Artifact upload
# uses the Actions runtime token, not GITHUB_TOKEN, so it needs no grant here —
# proven in this repository rather than assumed: `publish-pypi` below declares only
# `id-token: write`, which leaves it `contents: none`, and its
# actions/download-artifact step has succeeded in all three successful releases.
permissions:
contents: read
steps:
- uses: actions/checkout@v7

Expand Down Expand Up @@ -138,6 +176,11 @@ jobs:
name: GitHub Release
needs: [quality-gate, build]
runs-on: ubuntu-latest
# The one job that legitimately writes: softprops/action-gh-release creates the
# release and uploads the dist files to it. It gets `contents: write` and nothing
# else — in particular, no `id-token: write`, which it was inheriting before.
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
Expand Down