Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .github/workflows/release-health.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ jobs:
data = json.load(open(path)) if os.path.exists(path) else {}
key = p.get("distribution") or p.get("repo")
p["updated"] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
# A partial report (reusable-report-pypi-result.yml) carries one
# stage's outcome, sent after the coordinator's full report: the
# caller-side PyPI upload. It updates that row in place, as long as
# the row is for the same version; otherwise it stands alone.
old = data.get(key, {})
if str(p.pop("partial", "")).lower() == "true" and old.get("version") == p.get("version"):
p = {**old, **p}
data[key] = p
json.dump(data, open(path, "w"), indent=1, sort_keys=True)
def mark(v):
Expand Down
63 changes: 41 additions & 22 deletions .github/workflows/reusable-publish-release-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,29 @@ on:
type: string
pypi-publish:
description: >-
Also publish final releases to production PyPI via Trusted
Publishing (requires the repository's protected "pypi"
environment and a configured trusted publisher). Default off;
Mark final releases for production PyPI. This workflow does not
upload them: PyPI matches a Trusted Publisher against the workflow
that runs the upload job, and here that would be this file, not the
caller's publish-release-packages.yml. It sets the pypi-publish
output instead, and the caller's own pypi job uploads. Default off;
enabling it for a package is an explicit per-package decision.
default: false
type: boolean
outputs:
# What a caller's own PyPI job needs; see "Production PyPI" in
# docs/publishing-automation.md for the job that reads them.
pypi-publish:
description: >-
'true' when the caller should upload to production PyPI: pypi-publish
was set, the release is final, and the build and TestPyPI succeeded.
Empty otherwise.
value: ${{ jobs.pypi-gate.outputs.publish }}
dist-artifact:
description: Name of the artifact holding the validated distributions
value: ${{ jobs.prepare-release.outputs.dist-artifact }}
version:
description: The release version, X.Y.Z without the v
value: ${{ jobs.prepare-release.outputs.version }}

permissions:
contents: read
Expand All @@ -79,6 +96,7 @@ jobs:
release-ref: ${{ steps.release.outputs.release-ref }}
version: ${{ steps.release.outputs.version }}
prerelease: ${{ steps.release.outputs.prerelease }}
dist-artifact: testpypi-${{ inputs.distribution-name }}-${{ steps.release.outputs.version }}
steps:
- name: Resolve and validate the release tag
id: release
Expand Down Expand Up @@ -191,10 +209,15 @@ jobs:
gh release upload "${{ needs.prepare-release.outputs.release-ref }}" dist/* \
--clobber --repo "$GITHUB_REPOSITORY"

publish-to-pypi:
# Production publication is opt-in per package (pypi-publish input),
# final releases only, through the protected "pypi" environment and
# PyPI Trusted Publishing (OIDC; no long-lived token).
pypi-gate:
# Production PyPI is opt-in per package (pypi-publish input) and final
# releases only. The upload itself is NOT here: PyPI's Trusted Publishing
# matches the publisher against job_workflow_ref, the workflow that runs
# the job, and in a called workflow that is this file at a publishing tag,
# which no repository's PyPI publisher can name (audiodsp v0.6.1,
# "invalid-publisher"; pypa/gh-action-pypi-publish#166). So this job only
# decides, and the caller's own pypi job (environment pypi, id-token:
# write) downloads the dist-artifact output and uploads.
needs:
- prepare-release
- build-pure-python
Expand All @@ -210,24 +233,20 @@ jobs:
&& !contains(needs.*.result, 'failure')
&& !contains(needs.*.result, 'cancelled')
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
outputs:
publish: ${{ steps.decide.outputs.publish }}
steps:
- name: Download the validated distributions
uses: actions/download-artifact@v8
with:
name: testpypi-${{ inputs.distribution-name }}-${{ needs.prepare-release.outputs.version }}
path: dist
- name: Publish to PyPI via Trusted Publishing
uses: pypa/gh-action-pypi-publish@release/v1
with:
skip-existing: true
- name: Hand production PyPI to the caller
id: decide
run: echo "publish=true" >> "$GITHUB_OUTPUT"

report-release-health:
# Feed the Release Health dashboard in PyDevices/.github regardless of
# outcome; the dashboard is only useful if failures report too. A
# reporting failure must never fail the release itself.
# reporting failure must never fail the release itself. The PyPI upload
# happens after this, in the caller, so a release headed there reports
# pypi as "pending" here and the caller's report-pypi job (the
# reusable-report-pypi-result workflow) fills in the outcome.
continue-on-error: true
needs:
- prepare-release
Expand All @@ -237,7 +256,7 @@ jobs:
- publish-to-testpypi
- attach-release-assets
- request-mip-publication
- publish-to-pypi
- pypi-gate
if: always()
runs-on: ubuntu-latest
steps:
Expand All @@ -261,7 +280,7 @@ jobs:
-F 'client_payload[testpypi]=${{ needs.publish-to-testpypi.result }}' \
-F 'client_payload[assets]=${{ needs.attach-release-assets.result }}' \
-F 'client_payload[mip]=${{ needs.request-mip-publication.result }}' \
-F 'client_payload[pypi]=${{ needs.publish-to-pypi.result }}' \
-F 'client_payload[pypi]=${{ needs.pypi-gate.result == 'success' && 'pending' || needs.pypi-gate.result }}' \
-F 'client_payload[run_url]='"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"

request-mip-publication:
Expand Down
68 changes: 68 additions & 0 deletions .github/workflows/reusable-report-pypi-result.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Report a caller-side PyPI upload to the Release Health dashboard.
#
# reusable-publish-release-packages.yml cannot upload to production PyPI
# itself (Trusted Publishing matches the workflow that runs the upload job,
# and a called workflow is never the caller's). So a caller with
# pypi-publish: true runs its own pypi job, and this reports how it went. The
# coordinator's own report has already gone out by then with pypi "pending";
# this sends a partial report that release-health.yml folds into that row.
#
# A caller uses it as a job after its pypi job:
#
# report-pypi:
# needs: [publish, pypi]
# if: >-
# !cancelled() && needs.publish.outputs.pypi-publish == 'true'
# uses: PyDevices/.github/.github/workflows/reusable-report-pypi-result.yml@publishing-vN
# with:
# distribution-name: pydevices-audiodsp
# version: ${{ needs.publish.outputs.version }}
# pypi-result: ${{ needs.pypi.result }}
# secrets: inherit

on:
workflow_call:
inputs:
distribution-name:
description: The distribution the coordinator reported, e.g. pydevices-audiodsp
required: true
type: string
version:
description: The release version, X.Y.Z
required: true
type: string
pypi-result:
description: The caller's pypi job result (success, failure, cancelled, skipped)
required: true
type: string

permissions:
contents: read

jobs:
report:
# Like the coordinator's report: a reporting failure never fails a release.
continue-on-error: true
runs-on: ubuntu-latest
steps:
- name: Mint App token
id: app-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.PYDEVICES_APP_ID }}
private-key: ${{ secrets.PYDEVICES_APP_PRIVATE_KEY }}
owner: PyDevices
repositories: .github
- name: Dispatch the PyPI result
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
DISTRIBUTION: ${{ inputs.distribution-name }}
RELEASE_VERSION: ${{ inputs.version }}
PYPI_RESULT: ${{ inputs.pypi-result }}
run: |
gh api repos/PyDevices/.github/dispatches -f event_type=release-health \
-F 'client_payload[partial]=true' \
-F 'client_payload[repo]='"${GITHUB_REPOSITORY#*/}" \
-F 'client_payload[distribution]='"$DISTRIBUTION" \
-F 'client_payload[version]='"$RELEASE_VERSION" \
-F 'client_payload[pypi]='"$PYPI_RESULT"
108 changes: 79 additions & 29 deletions docs/publishing-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,8 @@ publish chain below would silently never fire.
`reusable-publish-release-packages.yml` resolves and validates the `vX.Y.Z`
tag, builds (pure-Python, native-and-wasm, or pydevices-multi, per caller),
publishes to TestPyPI, attaches the built artifacts to the GitHub Release,
optionally publishes to production PyPI, requests MIP publication for final
releases, and reports to the Release Health dashboard regardless of outcome.
requests MIP publication for final releases, tells a caller that opted in to
upload to production PyPI (the upload runs in the caller's workflow), and reports to the Release Health dashboard regardless of outcome.
See "How the release chain is wired" below for the per-job detail.

### Manual tag + release: the fallback
Expand Down Expand Up @@ -319,27 +319,70 @@ per-repo/workflow/environment registration that has not been set up.

### Production PyPI: opt-in, protected, Trusted Publishing

Publishing to **production** PyPI (as opposed to TestPyPI, which every
release always reaches) is a separate, explicit, per-package decision via the
`pypi-publish: true` input on `reusable-publish-release-packages.yml`. As of
this writing no caller sets it — every current release stops at TestPyPI plus
MIP. When a package does opt in, `publish-to-pypi`:

- runs only for a **final** release (`prerelease == 'false'`; a `.devN` or
`{a|b|rc}N` build never reaches PyPI),
- runs through the repository's `pypi` **GitHub Environment**, which exists
in all seven publishing repositories and is configured with required
reviewers and a branch policy (confirmed via
`gh api repos/PyDevices/<repo>/environments/pypi`), and
- authenticates via **PyPI Trusted Publishing** (OIDC, `id-token: write`,
`pypa/gh-action-pypi-publish@release/v1` with no token) — no long-lived
PyPI credential is stored anywhere.

Enabling `pypi-publish` for a package therefore requires both flipping the
input in that repository's coordinator *and* a matching Trusted Publisher
already configured on the PyPI project for that repository/workflow/
environment. Do one before the other and the run fails safely at the PyPI
end, not silently.
Production PyPI (as opposed to TestPyPI, which every release reaches) is an
explicit per-package decision: `pypi-publish: true` on the coordinator call.
`audiodsp` is the only repository that sets it.

**The upload runs in the caller's own workflow, not in the reusable one.**
PyPI matches a Trusted Publisher against the workflow that runs the upload job
(`job_workflow_ref`). Inside a called workflow that is
`PyDevices/.github/.github/workflows/reusable-publish-release-packages.yml`
at a publishing tag, which no repository's publisher can name, so an upload
from there always fails with `invalid-publisher` (audiodsp v0.6.1;
[pypa/gh-action-pypi-publish#166](https://github.com/pypa/gh-action-pypi-publish/issues/166),
[PyPI's note on reusable workflows](https://docs.pypi.org/trusted-publishers/troubleshooting/#reusable-workflows-on-github)).
From the tag that carries this change, the coordinator only decides: its
`pypi-gate` job sets the `pypi-publish` output to `true` for a final release
whose build and TestPyPI upload succeeded, and the caller uploads:

```yaml
pypi:
needs: publish
if: >-
!cancelled() && needs.publish.outputs.pypi-publish == 'true'
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v8
with:
name: ${{ needs.publish.outputs.dist-artifact }}
path: dist
- uses: pypa/gh-action-pypi-publish@release/v1
with:
skip-existing: true

report-pypi:
needs: [publish, pypi]
if: >-
!cancelled() && needs.publish.outputs.pypi-publish == 'true'
uses: PyDevices/.github/.github/workflows/reusable-report-pypi-result.yml@publishing-vN
with:
distribution-name: pydevices-audiodsp
version: ${{ needs.publish.outputs.version }}
pypi-result: ${{ needs.pypi.result }}
secrets: inherit
```

The `pypi` job runs through the repository's `pypi` **GitHub Environment**
(required reviewer Brad, deployment limited to `v*` tags; check with
`gh api repos/PyDevices/<repo>/environments/pypi`) and authenticates with
**Trusted Publishing** (OIDC, no stored PyPI credential). The PyPI project's
publisher is owner `PyDevices`, the repository, workflow
`publish-release-packages.yml`, environment `pypi`.

Because the `v*` tag rule applies, a `workflow_dispatch` retry from `main`
cannot reach the `pypi` environment; a retry that needs PyPI dispatches on the
tag, and so runs the caller file as it was at that tag.

Turning on `pypi-publish` for a package therefore takes three things: the
input, the two caller jobs above, and a matching Trusted Publisher on the PyPI
project. Miss one and the run fails at the PyPI end or skips the upload, never
silently publishes somewhere else.

TestPyPI is unaffected: it authenticates with `TESTPYPI_API_TOKEN`, not
Trusted Publishing, so its upload stays in the coordinator.

## The LVGL model

Expand Down Expand Up @@ -519,7 +562,11 @@ Every `publish-release-packages` run — success or failure — ends with a
`report-release-health` job (`continue-on-error: true`, so a reporting
failure never fails the release itself) that dispatches a
`repository_dispatch` of type `release-health` to `PyDevices/.github`, with
the run's outcome for each stage (TestPyPI, assets, MIP, PyPI). This
the run's outcome for each stage (TestPyPI, assets, MIP, PyPI). A release
headed for production PyPI reports `pypi` as `pending` there, because that
upload runs later, in the caller; the caller's `report-pypi` job then sends a
partial report (`reusable-report-pypi-result.yml`) that fills in the outcome on
the same row. This
repository's `release-health.yml` folds that payload into
[`release-health/data.json`](../release-health/data.json) and regenerates
[`RELEASE_HEALTH.md`](../RELEASE_HEALTH.md) at the repo root — one row per
Expand All @@ -534,7 +581,8 @@ distribution, linking back to the run that produced it.
| TestPyPI `invalid-publisher` | The job attempted OIDC Trusted Publishing without a matching publisher. TestPyPI still uses `__token__` + `TESTPYPI_API_TOKEN`. |
| TestPyPI authentication failure | Confirm that `TESTPYPI_API_TOKEN` exists in that source repository, belongs to `bdbarnett`, has permission for the project, and has not expired or been revoked. |
| TestPyPI duplicate-file response | Retry with the current coordinator, which sets `skip-existing: true`; otherwise publish a new version. |
| Production PyPI publish did not run | Check three things: `pypi-publish: true` on the caller, the release is a final version (not `.devN`/`{a,b,rc}N`), and the `pypi` environment's required reviewers approved the run. |
| Production PyPI publish did not run | Check four things: `pypi-publish: true` on the caller, the caller's own `pypi` job (see "Production PyPI" above), the release is a final version (not `.devN`/`{a,b,rc}N`), and the `pypi` environment's required reviewers approved the run. |
| PyPI `invalid-publisher` with `job_workflow_ref` naming `PyDevices/.github` | The upload ran inside the reusable workflow, which PyPI cannot match. Move it to the caller's `pypi` job on a publishing tag that has `pypi-gate` ("Production PyPI" above). |
| MIP request is queued | Expected — the central concurrency group processes publication requests serially. |
| MIP validation sees `.publication-sources/manifest.py` | The shared synchronization job did not remove temporary checkouts; start a fresh run on a current `publishing-v*` tag. |
| Pages setup or action download returns 429/503/504 | Usually transient GitHub infrastructure trouble. Retry the failed MIP job and verify the live index afterward. |
Expand Down Expand Up @@ -668,9 +716,10 @@ inline types and stop looking for stubs.
chain: it resolves the tag, dispatches the matching build (`build-pure-python`
/ `build-native-and-wasm` / `build-pydevices-multi`, exactly one of which runs
per invocation, selected by `build-kind`), publishes to TestPyPI, attaches
release assets, requests MIP publication, optionally publishes to production
PyPI, and reports to Release Health — all as jobs inside that one reusable
workflow, not separate coordinators. It replaced five near-identical copies of
release assets, requests MIP publication, decides whether production PyPI
should follow, and reports to Release Health — all as jobs inside that one
reusable workflow, not separate coordinators. The production PyPI upload
itself is the one step that has to live in the caller (see "Production PyPI"). It replaced five near-identical copies of
the same three jobs.

Changing a reusable workflow contract is an automation rollout, not a package
Expand All @@ -687,7 +736,8 @@ pipeline, test and lint jobs, and validators — see [workflows.md](workflows.md
|---|---|
| `reusable-prepare-release-pr.yml` | Open the release PR: compute the version suggestion, write `VERSION` + `CHANGELOG.md`, push, open/update the PR |
| `reusable-tag-on-release-merge.yml` | On a merged `VERSION` change, create the `vX.Y.Z` tag and GitHub Release with the App token |
| `reusable-publish-release-packages.yml` | The whole release chain: resolve the tag, build, publish to TestPyPI, attach assets, request MIP publication, optionally publish to PyPI, report health |
| `reusable-publish-release-packages.yml` | The whole release chain: resolve the tag, build, publish to TestPyPI, attach assets, request MIP publication, tell the caller whether to publish to PyPI, report health |
| `reusable-report-pypi-result.yml` | Report a caller-side PyPI upload's outcome to Release Health |
| `reusable-build-pure-python-distribution.yml` | Build, check, clean-install, and upload one wheel/sdist artifact |
| `reusable-build-native-and-wasm-wheels.yml` | Build Linux, Windows, Android, and WASM (Pyodide) wheels into one validated artifact |
| `reusable-build-pydevices-distributions.yml` | Discover `pydevices/lib` leaves and `utils` desktop payload; build every exact-version distribution |
Expand Down
3 changes: 2 additions & 1 deletion docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ deleting them.
|---|---|
| `reusable-prepare-release-pr.yml` | Open the release PR: compute a version suggestion, write `VERSION` + `CHANGELOG.md`, open/update the PR |
| `reusable-tag-on-release-merge.yml` | On a merged `VERSION` change, create the `vX.Y.Z` tag and GitHub Release with the App token |
| `reusable-publish-release-packages.yml` | The whole release chain: resolve the tag, build, publish to TestPyPI, attach assets, request MIP publication, optionally publish to PyPI, report health |
| `reusable-publish-release-packages.yml` | The whole release chain: resolve the tag, build, publish to TestPyPI, attach assets, request MIP publication, tell the caller whether to publish to PyPI, report health |
| `reusable-report-pypi-result.yml` | Report a caller-side PyPI upload to Release Health ([why the upload is caller-side](publishing-automation.md#production-pypi-opt-in-protected-trusted-publishing)) |
| `reusable-build-pure-python-distribution.yml` | sdist + wheel for a pure-Python package |
| `reusable-build-native-and-wasm-wheels.yml` | cibuildwheel: Linux, Windows, Android, Pyodide wasm32 |
| `reusable-build-pydevices-distributions.yml` | The `pydevices` and `pydevices-desktop` distributions, derived from `lib/` and `utils/` |
Expand Down
Loading
Loading