Skip to content
Closed
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
98 changes: 79 additions & 19 deletions .github/workflows/pypi.yml
Original file line number Diff line number Diff line change
@@ -1,53 +1,113 @@
name: Publish to PyPI
name: Validate or publish release

on:
release:
types: [published]
workflow_dispatch:
inputs:
ref:
description: Git ref to build and publish, for example `v0.0.1`
description: Ref to validate; publication requires v<project-version>
required: true
type: string
publish:
description: Publish validated artifacts to PyPI after environment approval
required: true
default: false
type: boolean

permissions:
contents: read

defaults:
run:
shell: bash

jobs:
build:
name: Build distributions
resolve:
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.identity.outputs.sha }}
version: ${{ steps.identity.outputs.version }}
publish: ${{ steps.identity.outputs.publish }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.event.release.tag_name }}

- uses: actions/setup-python@v5
fetch-depth: 0
persist-credentials: false
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v7
with:
python-version: "3.10"
version: "0.11.17"
python-version: "3.14"
- name: Resolve and validate release identity
id: identity
env:
RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.event.release.tag_name }}
PUBLISH: ${{ github.event_name == 'release' || inputs.publish }}
run: |
PROJECT_VERSION="$(uv version --short)" uv run --locked --only-group dev python tools/check_release.py

- uses: astral-sh/setup-uv@v7
validate:
needs: resolve
uses: ./.github/workflows/validate.yml
with:
ref: ${{ needs.resolve.outputs.sha }}

- run: uv build

- uses: actions/upload-artifact@v4
evidence:
needs: [resolve, validate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v7
with:
version: "0.11.17"
python-version: "3.14"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
name: candidate-distributions
path: dist/
- name: Record validated artifact identity
env:
SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
PROJECT_VERSION: ${{ needs.resolve.outputs.version }}
run: |
uv run --locked --only-group dev python tools/check_distribution.py dist/*.whl dist/*.tar.gz > release-evidence.json
python - <<'PY'
import json
import os
from pathlib import Path
path = Path("release-evidence.json")
evidence = json.loads(path.read_text())
if evidence["version"] != os.environ["PROJECT_VERSION"]:
raise SystemExit("Validated artifact version differs from resolved project version")
evidence["source_sha"] = os.environ["SOURCE_SHA"]
evidence["run_url"] = f"{os.environ['GITHUB_SERVER_URL']}/{os.environ['GITHUB_REPOSITORY']}/actions/runs/{os.environ['GITHUB_RUN_ID']}"
path.write_text(json.dumps(evidence, indent=2) + "\n")
print(path.read_text())
PY
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: release-evidence
path: release-evidence.json
if-no-files-found: error

publish:
name: Publish to PyPI
needs: build
name: Publish validated artifacts to PyPI
if: needs.resolve.outputs.publish == 'true'
needs: [resolve, validate, evidence]
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/bearshape
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
name: candidate-distributions
path: dist/

- uses: pypa/gh-action-pypi-publish@release/v1
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ and this project follows

### Changed

- Require immutable tag/version identity and full candidate validation before
publishing the tested artifacts; add a validation-only release path.

- Prepare `0.1.0rc0` with beartype `>=0.23.0rc0,<0.24`; older beartype versions
are no longer supported. Compatibility jobs test the exact rc0 dependency,
including Python 3.10 and 3.14 CPU backend environments.
Expand Down
33 changes: 33 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,36 @@ wheel normally in a temporary environment, then runs copied runtime and checker
fixtures with no package source directory. It reports artifact hashes and
installed module origins. `--installed-package` is an explicit pytest mode for
this consumer check; ordinary source validation continues to include `src`.

## Validate and publish a release

Run a validation-only workflow before making a release decision:

```bash
gh workflow run pypi.yml --ref main -f ref=<reviewed-commit> -F publish=false
```

This resolves one commit, runs the complete shared matrix, and produces
`candidate-distributions` plus `release-evidence`. The evidence records the
source SHA, version, artifact hashes and run URL. A failed required check
prevents publication. GPU runtime evidence and open contract decisions still
require review; CPU CI does not replace them.

Publication requires a canonical `v<project-version>` tag already in `main`
history. Both the workflow and package must come from that exact tag and commit.
Versions use `X.Y.Z` with an optional `aN`, `bN` or `rcN` suffix. A GitHub
release must be marked as a prerelease exactly when its version has such a
suffix. After explicit owner approval, publish that GitHub release, or dispatch
the workflow from the same tag with `publish=true`. Publishing downloads the
same tested distributions and does not rebuild them. Only that job receives OIDC
permission and enters the `pypi` environment.

Before enabling publication, configure required review for `pypi`, prevent
self-approval and administrator bypass, and limit its deployment policy to
release tags. Protect `main` and release tags, and require the final validation
check on pull requests. Confirm the PyPI trusted publisher identifies the actual
repository owner/name, `pypi.yml`, and environment `pypi`. The workflow does not
create these controls. The 2026-09-08 inspection found unprotected `main`, no
repository rulesets and no `pypi` approval reviewers; PyPI configuration remains
unverified. Ownership transfer requires rechecking publisher identity and docs
hosting before changing public URLs.
80 changes: 80 additions & 0 deletions plans/2026-09-08-publication-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Publish only the exact validated release artifacts


Maintain this ExecPlan according to PLANS.md. This focused M9 PR builds on aggregate #27. Implementation is authorized; publication, access-control changes and ownership transfer are not.

## Purpose / Big Picture


A release must not bypass runtime, typing, documentation or installed-package validation. Resolve the requested ref to one immutable commit, validate it fully, and publish the exact wheel and source archive that passed. Maintainers also need a validation-only path that produces inspectable evidence without uploading to PyPI.

## Progress


- [x] (2026-09-08) Created codex/publication-gate and matching worktree from 51b5f08.
- [x] (2026-09-08) Inspected current publication workflow, reusable validation, GitHub protection settings and official OIDC guidance.
- [x] (2026-09-08) Opened #28 and implemented matching workflow/tag/commit, version, prerelease and main-ancestry gates.
- [x] (2026-09-08) All 19 real-Git-history tests pass; inspected 128 publication-result combinations and exact artifact/OIDC dependencies.
- [x] (2026-09-08) Full hooks/actionlint and all PR checks pass. Validation-only run 34221337124 succeeded with every required job green and publication skipped.
- [x] (2026-09-08) CONTRIBUTING records the release procedure and inspected/proposed administrative controls.
- [ ] Obtain user review before merge.

## Surprises & Discoveries


The current publish workflow accepts any dispatch ref, builds it and publishes without test dependencies. Main has no branch protection; repository rulesets are empty. The pypi environment allows main branches and v* tags, has no approval reviewers and permits administrator bypass. The github-pages environment allows docs/main branches. These are read-only observations on 2026-09-08; no controls were changed. PyPI trusted-publisher configuration is not verified through the available repository API.

## Decision Log


Decision: Keep release-published events and add dispatch publication defaulting to false. Rationale: validation-only runs should be possible for a branch/commit without making it eligible for publication. Actual publication must use a canonical v<project-version> tag whose commit is already on main and whose event prerelease flag matches the version. Date: 2026-09-08.

Decision: Use the existing reusable validation and its candidate-distributions artifact. Rationale: the wheel tested by minimal and installed consumers must be the wheel published; no second build is allowed. Resolve once and pass the immutable SHA to every checkout. Record archive hashes, version and source SHA separately from the dist directory.

Decision: Keep OIDC write permission only on the final protected pypi job, with pinned actions and no long-lived token. Rationale: build and validation jobs do not need publication credentials. Environment reviewers and tag/main protections are administrative acceptance items, not implied by YAML.

## Outcomes & Retrospective


The release workflow now validates one immutable commit and publishes only its tested artifact pair. The actual validation-only run at 3ee4c1d succeeded, produced provenance and skipped the OIDC publisher. Tests reject branch publication, mismatched versions/prerelease flags/workflow identity, missing or mismatched tags and unmerged commits. A04 native union rollback and CuPy static policy remain release blockers outside this workflow change. A green workflow does not authorize publication or establish unconfigured administrative protections.

## Context and Orientation


Worktree /Users/ale/Code/bearshape-worktrees/publication-gate, branch codex/publication-gate. .github/workflows/pypi.yml is the existing release/dispatch entry point. .github/workflows/validate.yml owns all required CPU, checker, dependency, docs, notebook and artifact jobs. tools/check_distribution.py validates archive metadata and contents. tools/check_installed.py consumes copies of downstream tests outside src. pyproject.toml declares 0.1.0rc0 and beartype >=0.23.0rc0,<0.24.

## Plan of Work


Add a small release validation tool that reads the event and resolved repository state, validates the canonical tag/version relationship for publication and prerelease status, and emits JSON plus GitHub job outputs. Use argument-list subprocess calls and strict ref handling; reject branch publication, mismatched versions, unresolved tags and tags outside main history. Permit arbitrary repository refs only in validation-only mode.

Refactor pypi.yml into resolve, reusable validation, evidence and publish jobs. The evidence job consumes the existing distributions, checks their metadata version against the resolved project version, and records SHA256/source commit/run URL. Publish downloads those same immutable artifacts and needs successful validation and evidence. Validation-only runs skip publication explicitly.

Add focused tests for valid rc/final releases and rejected branch, mismatch, prerelease and ancestry cases. Validate the workflow graph and failed-required-job exclusion without uploading anything. Keep source-archive consumer tests runnable when they reference the release helper. Document the command, release procedure and concrete settings still requiring the owner's authorization.

## Concrete Steps


From this worktree run targeted pytest for release validation, then uv run --locked prek run -a and its manual actionlint stage. Exercise the release tool with temporary Git repositories and synthetic GitHub event files. Build and inspect artifacts, then run the hosted candidate matrix. Exercise a validation-only dispatch only if GitHub can run this branch workflow without merging it; otherwise record that platform constraint and use the PR-hosted equivalent plus local event tests.

## Validation and Acceptance


A branch or version/prerelease mismatch must fail before publication eligibility. A tag outside main history must fail. Every required validation job must succeed for the immutable SHA. Published artifact selection must refer to the validated artifact without rebuilding. Evidence must contain source SHA, version, wheel/sdist hashes and run link. Validation-only mode must never enter the OIDC publisher job. Actual publishing and administrative settings remain pending explicit authorization.

## Idempotence and Recovery


Use temporary Git repositories for destructive tag/ancestry tests. Never create release tags in the real repository, publish GitHub releases, upload packages, change controls or transfer ownership during validation. Preserve diagnostic logs and retry only understood failures. Main and focused PR branches remain untouched.

## Artifacts and Notes


Store local evidence under /Users/ale/Code/bearshape-implementation-2026-09-08/evidence/publication-*. Record exact GitHub protection observations and proposed controls in the handoff documentation. Archive hash evidence is separate from the files passed to PyPI.

## Interfaces and Dependencies


Use the standard library and existing locked uv tools. Keep the supported Python package range unchanged. GitHub Actions uses immutable action pins, the local reusable validation workflow, artifact upload/download, and pypa/gh-action-pypi-publish with environment pypi and job-scoped id-token: write. No new runtime dependency.

Revision note — 2026-09-08: Verified the actual validation-only workflow at https://github.com/acecchini/bearshape/actions/runs/34221337124. Source 3ee4c1d8a0c3d22f537a24b447a9d75e025a5b2d; wheel SHA256 c1806203da013c9eaf2482a309c686a984c0a7031a495efbd100a824536b57d2; sdist SHA256 e2454095144fb4bb5a08d3bd998d0f6a8bee4bc5ad81dfab763c339ca91666df. Administrative protections remain unconfigured and publication unauthorized.
Loading
Loading