diff --git a/.github/workflows/release-health.yml b/.github/workflows/release-health.yml index f15bf86..7ce8d88 100644 --- a/.github/workflows/release-health.yml +++ b/.github/workflows/release-health.yml @@ -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): diff --git a/.github/workflows/reusable-publish-release-packages.yml b/.github/workflows/reusable-publish-release-packages.yml index 81cbf78..3b48627 100644 --- a/.github/workflows/reusable-publish-release-packages.yml +++ b/.github/workflows/reusable-publish-release-packages.yml @@ -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 @@ -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 @@ -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 @@ -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 @@ -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: @@ -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: diff --git a/.github/workflows/reusable-report-pypi-result.yml b/.github/workflows/reusable-report-pypi-result.yml new file mode 100644 index 0000000..f7e24c2 --- /dev/null +++ b/.github/workflows/reusable-report-pypi-result.yml @@ -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" diff --git a/docs/publishing-automation.md b/docs/publishing-automation.md index 85a22a3..830e2b4 100644 --- a/docs/publishing-automation.md +++ b/docs/publishing-automation.md @@ -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 @@ -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//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//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 @@ -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 @@ -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. | @@ -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 @@ -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 | diff --git a/docs/workflows.md b/docs/workflows.md index 60164bd..6eda7de 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -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/` | diff --git a/tests/test_release_health.py b/tests/test_release_health.py index 977f10a..c0f092f 100644 --- a/tests/test_release_health.py +++ b/tests/test_release_health.py @@ -52,7 +52,22 @@ def payload(distribution: str, version: str) -> str: ) -def run_step(clone: Path, distribution: str, version: str) -> subprocess.CompletedProcess[str]: +def partial_pypi_payload(distribution: str, version: str, result: str) -> str: + """What reusable-report-pypi-result.yml sends after a caller's pypi job.""" + return json.dumps( + { + "partial": True, + "repo": "audiodsp", + "distribution": distribution, + "version": version, + "pypi": result, + } + ) + + +def run_step( + clone: Path, distribution: str, version: str, body: str | None = None +) -> subprocess.CompletedProcess[str]: """Run the workflow step in `clone`, as one dispatched report would.""" script = clone / ".step.sh" script.write_text(update_step_shell(), encoding="utf-8") @@ -65,7 +80,7 @@ def run_step(clone: Path, distribution: str, version: str) -> subprocess.Complet "PATH": "/usr/bin:/bin", "HOME": str(clone.parent), "RUNNER_TEMP": str(runner_temp), - "PAYLOAD": payload(distribution, version), + "PAYLOAD": body if body is not None else payload(distribution, version), "DISTRIBUTION": distribution, "RELEASE_VERSION": version, }, @@ -161,5 +176,68 @@ def test_a_repeated_report_is_recorded_not_skipped(self): self.assertEqual(self.published()["data"]["pydevices-cmods"]["version"], "0.1.1") + +class PartialPypiReportTests(unittest.TestCase): + """The caller-side PyPI upload reports after the coordinator's full report.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + root = Path(self._tmp.name) + origin = root / "origin.git" + subprocess.run( + ["git", "init", "-q", "--bare", "-b", "main", str(origin)], + check=True, + capture_output=True, + ) + self.clone = root / "clone" + git(root, "clone", "-q", str(origin), str(self.clone)) + git(self.clone, "config", "user.email", "test@example.invalid") + git(self.clone, "config", "user.name", "Test") + (self.clone / "release-health").mkdir() + (self.clone / "release-health/data.json").write_text("{}\n", encoding="utf-8") + (self.clone / "RELEASE_HEALTH.md").write_text("# Release health\n", encoding="utf-8") + git(self.clone, "add", "-A") + git(self.clone, "commit", "-q", "-m", "seed") + git(self.clone, "push", "-q", "origin", "main") + + def tearDown(self): + self._tmp.cleanup() + + def report(self, version: str, body: str | None = None) -> dict: + result = run_step(self.clone, "pydevices-audiodsp", version, body) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return json.loads((self.clone / "release-health/data.json").read_text()) + + def test_a_partial_report_fills_in_the_same_release(self): + full = json.loads(payload("pydevices-audiodsp", "0.6.2")) + full["pypi"] = "pending" + self.report("0.6.2", json.dumps(full)) + row = self.report( + "0.6.2", partial_pypi_payload("pydevices-audiodsp", "0.6.2", "success") + )["pydevices-audiodsp"] + self.assertEqual(row["pypi"], "success") + # Everything the coordinator reported is still there. + self.assertEqual(row["testpypi"], "success") + self.assertEqual(row["run_url"], "https://example.invalid/pydevices-audiodsp") + self.assertNotIn("partial", row) + page = (self.clone / "RELEASE_HEALTH.md").read_text(encoding="utf-8") + self.assertIn("| 0.6.2 | OK | OK | OK | OK |", page) + + def test_a_partial_report_for_another_version_does_not_borrow_its_row(self): + self.report("0.6.1") + row = self.report( + "0.6.2", partial_pypi_payload("pydevices-audiodsp", "0.6.2", "failure") + )["pydevices-audiodsp"] + self.assertEqual(row["version"], "0.6.2") + self.assertEqual(row["pypi"], "failure") + self.assertNotIn("testpypi", row) + + def test_a_full_report_still_replaces_the_row(self): + self.report("0.6.1", partial_pypi_payload("pydevices-audiodsp", "0.6.1", "success")) + row = self.report("0.6.2")["pydevices-audiodsp"] + self.assertEqual(row["version"], "0.6.2") + self.assertEqual(row["pypi"], "skipped") + + if __name__ == "__main__": unittest.main()