diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f82a975..e2fa5439 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,10 +13,12 @@ name: Release # 5. release - create the GitHub Release with install instructions # 6. finalize - summary line to the run's step summary # -# Auth model: PyPI publishes use the `PYPI_API_TOKEN` repo secret (account- -# scoped). Trusted-publisher setup is nicer long-term but requires per-project -# UI config in PyPI, which was blocking. Container signing uses cosign keyless -# OIDC as before; that path does not depend on PyPI auth. +# Auth model: each publish job runs in the `release` environment, which needs +# a maintainer's approval and deploys only from `v*` tags or `main`. Each job +# reads its package's project-scoped PyPI token from that environment +# (`PYPI_TOKEN_DARNIT_CORE`, ...). No PyPI credential is a repository secret, +# so no other workflow can read one. Container signing uses cosign keyless +# OIDC; that path does not depend on PyPI auth. # # Version format: `vX.Y.Z` or `vX.Y.ZrcN` (no dash before rc; matches PEP 440 # canonical form and the preflight parse-tag regex). Pre-release tags are @@ -218,8 +220,8 @@ jobs: if-no-files-found: error retention-days: 7 - # Publish jobs use `password: ${{ secrets.PYPI_API_TOKEN }}` (account-scoped - # token). Migrate to project-scoped tokens once the projects exist on PyPI. + # Publish jobs run in the `release` environment and use that package's + # project-scoped token. # # Sequenced via `needs:` because darnit-baseline, darnit-gittuf, # darnit-reproducibility, and darnit-mcp declare `darnit-core>=...` runtime @@ -231,6 +233,7 @@ jobs: needs: [preflight, build] runs-on: ubuntu-latest timeout-minutes: 10 + environment: release permissions: contents: read steps: @@ -244,7 +247,7 @@ jobs: uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: packages-dir: dist/darnit-core/ - password: ${{ secrets.PYPI_API_TOKEN }} + password: ${{ secrets.PYPI_TOKEN_DARNIT_CORE }} skip-existing: true publish-darnit-baseline: @@ -252,6 +255,7 @@ jobs: needs: [preflight, build, publish-darnit-core] runs-on: ubuntu-latest timeout-minutes: 10 + environment: release permissions: contents: read steps: @@ -265,7 +269,7 @@ jobs: uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: packages-dir: dist/darnit-baseline/ - password: ${{ secrets.PYPI_API_TOKEN }} + password: ${{ secrets.PYPI_TOKEN_DARNIT_BASELINE }} skip-existing: true publish-darnit-gittuf: @@ -273,6 +277,7 @@ jobs: needs: [preflight, build, publish-darnit-core] runs-on: ubuntu-latest timeout-minutes: 10 + environment: release permissions: contents: read steps: @@ -286,7 +291,7 @@ jobs: uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: packages-dir: dist/darnit-gittuf/ - password: ${{ secrets.PYPI_API_TOKEN }} + password: ${{ secrets.PYPI_TOKEN_DARNIT_GITTUF }} skip-existing: true publish-darnit-reproducibility: @@ -294,6 +299,7 @@ jobs: needs: [preflight, build, publish-darnit-core] runs-on: ubuntu-latest timeout-minutes: 10 + environment: release permissions: contents: read steps: @@ -307,7 +313,7 @@ jobs: uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: packages-dir: dist/darnit-reproducibility/ - password: ${{ secrets.PYPI_API_TOKEN }} + password: ${{ secrets.PYPI_TOKEN_DARNIT_REPRODUCIBILITY }} skip-existing: true publish-darnit-mcp: @@ -320,6 +326,7 @@ jobs: - publish-darnit-reproducibility runs-on: ubuntu-latest timeout-minutes: 10 + environment: release permissions: contents: read steps: @@ -333,7 +340,7 @@ jobs: uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: packages-dir: dist/darnit-mcp/ - password: ${{ secrets.PYPI_API_TOKEN }} + password: ${{ secrets.PYPI_TOKEN_DARNIT_MCP }} skip-existing: true # Build + push + cosign-sign the multi-arch container image. Depends on diff --git a/packaging/README.md b/packaging/README.md index 2f207a91..4c5f8413 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -14,7 +14,7 @@ Maintainer-facing documentation for the darnit release pipeline. End-user instal The tag-driven pipeline in `release.yml` currently ships **PyPI + container** only. Binary, Homebrew, and Claude Code plugin channels are designed but not wired up; they will be re-introduced per waybill's incremental pattern as separate PRs. -PyPI publishing uses an **account-scoped API token** (`PYPI_API_TOKEN` repo secret) rather than trusted publishers. Trusted publishers are the better long-term posture (no long-lived secrets, per-project scoping) but the per-project PyPI UI setup was blocking; tokens ship the pipeline today and can be migrated later. See "External setup" below for the token flow. +PyPI publishing uses **project-scoped API tokens** stored as secrets of the `release` environment, one per public package. Every `publish-*` job runs in that environment, which requires approval from a maintainer (mlieberman85 or Marc-cn) and deploys only from `v*` tags or `main`. No PyPI credential is a repository secret. Trusted publishers remain the better long-term posture (no long-lived secrets); see "External setup" below. Sections below marked *(deferred)* describe channels the workflow does not currently drive. @@ -40,26 +40,26 @@ Authoritative list: [`packaging/pypi/public-packages.txt`](pypi/public-packages. Before the first release works end-to-end, a maintainer with admin access must: -### PyPI API token (current) +### PyPI API tokens (current) -The workflow authenticates every `publish-*` job with the `PYPI_API_TOKEN` repo secret. Setup, one time: +Each `publish-*` job reads its package's project-scoped token from the `release` environment. Setup, per public package (`darnit-core`, `darnit-baseline`, `darnit-gittuf`, `darnit-reproducibility`, `darnit-mcp`): -1. Under a PyPI account with 2FA enabled (Account settings -> Two-factor authentication), create a new API token: - - https://pypi.org/manage/account/token/ - - Name: `darnit-github-actions` (or similar) - - Scope: **Entire account (all projects)**. Project-scoped tokens require the projects to already exist; the account-scoped token is only needed for first-time publish, then narrow down. -2. Copy the full token (starts with `pypi-`). -3. Add as a **repository secret** (not environment secret) on `darnitdevorg/darnit`: +1. Under a PyPI account with 2FA enabled that maintains the project, create an API token at https://pypi.org/manage/account/token/ with **Scope: Project: **. +2. Add it as a **`release` environment secret** (not a repository secret): ```bash - gh secret set PYPI_API_TOKEN --repo darnitdevorg/darnit + gh secret set PYPI_TOKEN_DARNIT_CORE --env release --repo darnitdevorg/darnit + gh secret set PYPI_TOKEN_DARNIT_BASELINE --env release --repo darnitdevorg/darnit + gh secret set PYPI_TOKEN_DARNIT_GITTUF --env release --repo darnitdevorg/darnit + gh secret set PYPI_TOKEN_DARNIT_REPRODUCIBILITY --env release --repo darnitdevorg/darnit + gh secret set PYPI_TOKEN_DARNIT_MCP --env release --repo darnitdevorg/darnit ``` -4. After the first release lands and the five projects exist on PyPI, replace with per-project tokens for scope reduction (optional; not required for correctness). +3. A brand-new package has no project on PyPI yet, so it cannot have a project-scoped token. Publish its first release with a temporary token, then replace it with a project-scoped one and revoke the temporary token. -If the token is bad, the first `publish-*` job in `release.yml` fails immediately with a 403 and no packages are published. Fix the secret and re-trigger via `workflow_dispatch` on the same tag; no need to bump the version. +The `release` environment (Settings -> Environments) requires a reviewer to approve each run's publish jobs, and its deployment policy allows only `v*` tags and the `main` branch (used by the `workflow_dispatch` re-release path). If a token is wrong, the first `publish-*` job fails with a 403 and nothing is published. Fix the secret and re-trigger with `workflow_dispatch` on the same tag. ### PyPI Trusted Publishing *(deferred)* -Better long-term posture; skipped for v0.1.0 because per-project UI setup was blocking. To migrate later, per public package: on the project's "Publishing" page add a Trusted Publisher with Owner: `darnitdevorg`, Repository: `darnit`, Workflow: `release.yml`, Environment: `release`. Then swap `password: ${{ secrets.PYPI_API_TOKEN }}` in each `publish-*` job for the trusted-publisher config, add `id-token: write` permission, and re-add `environment: release`. +Better long-term posture; skipped for v0.1.0 because per-project UI setup was blocking. To migrate later, per public package: on the project's "Publishing" page add a Trusted Publisher with Owner: `darnitdevorg`, Repository: `darnit`, Workflow: `release.yml`, Environment: `release`. Then drop the `password:` line in each `publish-*` job and add `id-token: write` permission; the jobs already run in `environment: release`. ### TestPyPI *(deferred)*