diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a35ce1e..6d92451 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -237,9 +237,79 @@ jobs: path: dist/*.tar.gz if-no-files-found: error - publish: + publish_testpypi: needs: [install_test, build_sdist] + if: github.event_name == 'workflow_dispatch' && inputs['publish-testpypi'] == true runs-on: ubuntu-24.04 + environment: testpypi + permissions: + contents: read + id-token: write # TestPyPI trusted publishing (OIDC) + attestations: write + steps: + - uses: actions/download-artifact@v8.0.1 + with: + name: sdist + path: dist + + - uses: actions/download-artifact@v8.0.1 + with: + pattern: wheels-* + path: dist + merge-multiple: true + + - name: verify release artifacts before publish + shell: bash + run: | + shopt -s nullglob + + sdists=(dist/pyrewire-*.tar.gz) + if [ "${#sdists[@]}" -ne 1 ]; then + echo "expected exactly one pyrewire sdist, found ${#sdists[@]}" + printf '%s\n' "${sdists[@]}" + exit 1 + fi + + expected=() + for py_tag in cp311 cp312 cp313 cp314; do + expected+=("pyrewire-*-${py_tag}-${py_tag}-*manylinux_2_28_x86_64.whl") + expected+=("pyrewire-*-${py_tag}-${py_tag}-macosx_*_arm64.whl") + expected+=("pyrewire-*-${py_tag}-${py_tag}-win_amd64.whl") + done + + for pattern in "${expected[@]}"; do + matches=(dist/$pattern) + if [ "${#matches[@]}" -ne 1 ]; then + echo "expected exactly one wheel matching $pattern, found ${#matches[@]}" + ls -la dist + exit 1 + fi + done + + wheels=(dist/pyrewire-*.whl) + if [ "${#wheels[@]}" -ne "${#expected[@]}" ]; then + echo "expected ${#expected[@]} wheels, found ${#wheels[@]}" + ls -la dist + exit 1 + fi + + - name: attest release artifacts + uses: actions/attest@v4 + with: + subject-path: | + dist/pyrewire-*.whl + dist/pyrewire-*.tar.gz + + - name: publish to TestPyPI (trusted publishing) + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + publish_pypi: + needs: [install_test, build_sdist] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-24.04 + environment: pypi permissions: contents: write id-token: write # PyPI trusted publishing (OIDC) @@ -306,14 +376,7 @@ jobs: dist/pyrewire-*.whl dist/pyrewire-*.tar.gz - - name: publish to TestPyPI (trusted publishing) - if: github.event_name == 'workflow_dispatch' && inputs['publish-testpypi'] == true - uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ - - name: extract GitHub Release notes - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') run: | tag="${GITHUB_REF_NAME#v}" python scripts/ci/extract_changelog_section.py \ @@ -322,11 +385,9 @@ jobs: "$RUNNER_TEMP/release-notes.md" - name: publish to PyPI (trusted publishing) - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') uses: pypa/gh-action-pypi-publish@release/v1 - name: create GitHub Release - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v3 with: files: dist/* diff --git a/docs/release-candidate-checklist.md b/docs/release-candidate-checklist.md index 0ad3b9c..d150396 100644 --- a/docs/release-candidate-checklist.md +++ b/docs/release-candidate-checklist.md @@ -22,9 +22,9 @@ Record that SHA in the release issue or release PR. The `v1.0.0` tag must point | Wheel workflow | Packaging owner | Workflow: `.github/workflows/wheels.yml` on the frozen release commit. | Successful GitHub Actions run URL for `wheels.yml`, including Python 3.11, 3.12, 3.13, and 3.14 wheel jobs. | Link a GitHub issue, PR, or follow-up task before retrying or tagging. | | Code scanning alerts | Security owner | Manual verification in GitHub Security that code-scanning alerts are clean (no open alerts) for the default branch and frozen release commit. | Linked screenshot or URL showing no open code-scanning alerts at RC time. | Link a GitHub issue, PR, or follow-up task before retrying or tagging. | | Dependabot coverage | Release manager | Manual verification of `.github/dependabot.yml`: Dependabot is configured for both `github-actions` and `pip` at `/` on a regular schedule. | Linked review note referencing the merged `.github/dependabot.yml` and both ecosystems. | Link a GitHub issue, PR, or follow-up task before retrying or tagging. | -| Release workflow dry run review | Release manager | Manual verification of `.github/workflows/release.yml`: it builds wheels, runs dynamic-link verification, performs clean install tests, verifies release-local wheels and sdist artifacts before publish, uses trusted publishing via OIDC, keeps least-privilege top-level permissions, restricts `id-token: write` to the publish job, and publishes only after tag-triggered gates pass. Do not actually tag or publish during RC validation. | Linked review note confirming the tag-triggered `release.yml` gates, least-privilege/OIDC scope, and no pre-tag publish occurred. | Link a GitHub issue, PR, or follow-up task before retrying or tagging. | +| Release workflow dry run review | Release manager | Manual verification of `.github/workflows/release.yml`: it builds wheels, runs dynamic-link verification, performs clean install tests, verifies release-local wheels and sdist artifacts before publish, uses trusted publishing via OIDC, keeps least-privilege top-level permissions, restricts `id-token: write` to the publish jobs, separates TestPyPI and production PyPI trusted publishing into explicit `testpypi` and `pypi` GitHub environments, and publishes production only after tag-triggered gates pass. Do not actually tag or publish production during RC validation. | Linked review note confirming the tag-triggered `release.yml` gates, least-privilege/OIDC scope, separate publish environments, and no pre-tag production publish occurred. | Link a GitHub issue, PR, or follow-up task before retrying or tagging. | | TestPyPI dry run evidence | Release manager + Packaging owner | Manual verification: on the frozen RC commit, manually dispatch `.github/workflows/release.yml` with `publish-testpypi: true` and complete a TestPyPI end-to-end dry run before any production tag push. Use install command shape: `python -m pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple pyrewire==`. | Frozen commit SHA, successful `release.yml` workflow run URL, TestPyPI project/version URL, artifact filenames and candidate version, SHA256 hashes, successful TestPyPI upload logs, clean install command logs/results for Linux/macOS/Windows supported Python versions, import smoke output for `pyrewire.__version__` and `pyrewire.wirelog_version()`, confirmation wheel install uses bundled `libwirelog` with no system `libwirelog`, and documented sdist behavior if any sdist install behavior is intentional. Production PyPI release remains gated on this dry-run evidence. | Link a GitHub issue, PR, or follow-up task before retrying or tagging. | -| Artifact attestation verification | Release manager | Workflow: `.github/workflows/release.yml` publish job generates release artifact attestations using `actions/attest@v4`. For tagged release artifacts, command: `gh attestation verify -R semantic-reasoning/PyreWire --signer-workflow semantic-reasoning/PyreWire/.github/workflows/release.yml --source-ref refs/tags/v1.0.0` for every release wheel (`dist/pyrewire-*.whl`) and the sdist (`dist/pyrewire-*.tar.gz`). For RC/frozen-commit validation before the final tag exists, also verify with `--source-digest `. Repo-only `-R` verification alone is not sufficient for this gate. | Successful `release.yml` run URL, release tag or frozen RC commit SHA, artifact filenames, SHA256 hashes for each wheel/sdist, and successful `gh attestation verify` output showing enforced signer workflow and source identity (`--source-ref refs/tags/v1.0.0` or `--source-digest `) for each verified artifact. | Link a GitHub issue, PR, or follow-up task before retrying or tagging. | +| Artifact attestation verification | Release manager | Workflow: `.github/workflows/release.yml` publish jobs generate release artifact attestations using `actions/attest@v4`. For tagged release artifacts, command: `gh attestation verify -R semantic-reasoning/PyreWire --signer-workflow semantic-reasoning/PyreWire/.github/workflows/release.yml --source-ref refs/tags/v1.0.0` for every release wheel (`dist/pyrewire-*.whl`) and the sdist (`dist/pyrewire-*.tar.gz`). For RC/frozen-commit validation before the final tag exists, also verify with `--source-digest `. Repo-only `-R` verification alone is not sufficient for this gate. | Successful `release.yml` run URL, release tag or frozen RC commit SHA, artifact filenames, SHA256 hashes for each wheel/sdist, and successful `gh attestation verify` output showing enforced signer workflow and source identity (`--source-ref refs/tags/v1.0.0` or `--source-digest `) for each verified artifact. | Link a GitHub issue, PR, or follow-up task before retrying or tagging. | | Wheel dynamic-link check | Packaging owner | Command: `python scripts/ci/check_dynamic_link.py wheelhouse/*.whl`. | Passing command log for the release wheel artifacts. | Link a GitHub issue, PR, or follow-up task before retrying or tagging. | | Clean wheel install | Bindings owner | Manual verification in a clean environment with no system `libwirelog`: install the candidate wheel, import `pyrewire`, confirm PyreWire version, confirm bundled wirelog version, and run integration tests. | Environment description, install command log, import/version log, wirelog version log, and integration test log. | Link a GitHub issue, PR, or follow-up task before retrying or tagging. | | Release notes and changelog | Release manager | Manual verification that the v1.0.0 changelog section is extractable and matches the GitHub Release body generated from `CHANGELOG.md`. | Extracted release notes artifact or command log plus reviewer confirmation. | Link a GitHub issue, PR, or follow-up task before retrying or tagging. | @@ -32,4 +32,4 @@ Record that SHA in the release issue or release PR. The `v1.0.0` tag must point Attestation verification for this gate requires signer workflow and source identity constraints; repo-only verification is not treated as sufficient provenance evidence for PyreWire release artifacts. This does not independently attest upstream wirelog builds. For bundled wirelog traceability, rely on the pinned wirelog v0.50.0 SHA `272edf3a24b25676f12c4b843d55510f5048dd2f` in release configuration and docs, plus the wheel dynamic-link and clean-install gates above. -External blocker for the TestPyPI dry run gate: TestPyPI trusted publishing must be configured for the `.github/workflows/release.yml` workflow identity before dry-run uploads can pass. If TestPyPI requires an environment for trusted publishing, the workflow configuration must match that environment. +External blocker for the TestPyPI dry run gate: TestPyPI trusted publishing must be configured for the `.github/workflows/release.yml` workflow identity with GitHub environment `testpypi` before dry-run uploads can pass. diff --git a/tests/docs/test_release_candidate_checklist.py b/tests/docs/test_release_candidate_checklist.py index e4746aa..bff0f5b 100644 --- a/tests/docs/test_release_candidate_checklist.py +++ b/tests/docs/test_release_candidate_checklist.py @@ -111,9 +111,11 @@ def test_required_workflows_and_release_workflow_guards_are_documented(): "verifies release-local wheels and sdist artifacts before publish", "trusted publishing via OIDC", "least-privilege top-level permissions", - "restricts `id-token: write` to the publish job", - "publishes only after tag-triggered gates pass", - "Do not actually tag or publish", + "restricts `id-token: write` to the publish jobs", + "separates TestPyPI and production PyPI trusted publishing into explicit " + "`testpypi` and `pypi` GitHub environments", + "publishes production only after tag-triggered gates pass", + "Do not actually tag or publish production", ): assert release_guard in text @@ -155,8 +157,7 @@ def test_testpypi_dry_run_gate_and_evidence_requirements_are_documented(): "Production PyPI release remains gated on this dry-run evidence", "TestPyPI trusted publishing must be configured", "workflow identity", - "If TestPyPI requires an environment for trusted publishing, the workflow " - "configuration must match that environment", + "GitHub environment `testpypi`", ): assert required in text diff --git a/tests/test_release_workflow_yaml.py b/tests/test_release_workflow_yaml.py index 27e1d5e..eb72bd0 100644 --- a/tests/test_release_workflow_yaml.py +++ b/tests/test_release_workflow_yaml.py @@ -5,7 +5,7 @@ - verify the tag matches the `pyproject.toml` version; - build and install-test wheels in the release workflow; - publish only after release-local wheels and sdist are verified; -- publish to PyPI via trusted publishing; +- publish to TestPyPI and PyPI via separate trusted publishing identities; - create a GitHub Release. If a future refactor accidentally drops any of these, this test fails @@ -44,7 +44,7 @@ def _on_block() -> dict[str, Any]: def _publish_artifact_verify_script() -> str: - steps = _workflow()["jobs"]["publish"]["steps"] + steps = _workflow()["jobs"]["publish_pypi"]["steps"] return next( step["run"] for step in steps @@ -52,6 +52,21 @@ def _publish_artifact_verify_script() -> str: ) +def _publish_steps(job_name: str) -> list[dict[str, Any]]: + return _workflow()["jobs"][job_name]["steps"] + + +def _step_index( + steps: list[dict[str, Any]], *, name: str | None = None, uses: str | None = None +) -> int: + for i, step in enumerate(steps): + if name is not None and step.get("name") == name: + return i + if uses is not None and uses in step.get("uses", ""): + return i + raise AssertionError(f"missing step name={name!r} uses={uses!r}") + + def _write_complete_release_artifacts(dist: Path) -> None: dist.mkdir() (dist / "pyrewire-1.0.0.tar.gz").touch() @@ -87,22 +102,30 @@ def test_workflow_dispatch_publish_testpypi_input_is_boolean_default_false(): assert "publish" in publish_testpypi.get("description", "").lower() -def test_has_publish_job_with_ubuntu(): +def test_has_publish_jobs_with_ubuntu_and_explicit_environments(): wf = _workflow() - publish = wf["jobs"]["publish"] - assert "ubuntu" in str(publish.get("runs-on", "")).lower() + assert "ubuntu" in str(wf["jobs"]["publish_testpypi"].get("runs-on", "")).lower() + assert "ubuntu" in str(wf["jobs"]["publish_pypi"].get("runs-on", "")).lower() + assert wf["jobs"]["publish_testpypi"].get("environment") == "testpypi" + assert wf["jobs"]["publish_pypi"].get("environment") == "pypi" def test_release_workflow_has_build_install_publish_dag(): jobs = _workflow()["jobs"] - assert {"build_wheels", "install_test", "build_sdist", "publish"} <= set(jobs) + assert { + "build_wheels", + "install_test", + "build_sdist", + "publish_testpypi", + "publish_pypi", + } <= set(jobs) assert jobs["install_test"].get("needs") == "build_wheels" or "build_wheels" in ( jobs["install_test"].get("needs") or [] ) - publish_needs = jobs["publish"].get("needs") or [] - assert "install_test" in publish_needs - assert "build_sdist" in publish_needs + for job_name in ("publish_testpypi", "publish_pypi"): + publish_needs = jobs[job_name].get("needs") or [] + assert publish_needs == ["install_test", "build_sdist"] def test_release_workflow_has_verify_step(): @@ -115,18 +138,18 @@ def test_release_workflow_has_verify_step(): def test_publish_step_uses_pypa_action(): - steps = _workflow()["jobs"]["publish"]["steps"] - uses = [s.get("uses", "") for s in steps] - assert any( - "pypa/gh-action-pypi-publish" in u for u in uses - ), "release workflow must publish through pypa/gh-action-pypi-publish" + for job_name in ("publish_testpypi", "publish_pypi"): + uses = [s.get("uses", "") for s in _publish_steps(job_name)] + assert any( + "pypa/gh-action-pypi-publish" in u for u in uses + ), f"{job_name} must publish through pypa/gh-action-pypi-publish" def test_download_wheels_uses_node24_artifact_action(): - steps = _workflow()["jobs"]["publish"]["steps"] - uses = [s.get("uses", "") for s in steps] - assert "actions/download-artifact@v8.0.1" in uses - assert "actions/download-artifact@v4" not in uses + for job_name in ("publish_testpypi", "publish_pypi"): + uses = [s.get("uses", "") for s in _publish_steps(job_name)] + assert "actions/download-artifact@v8.0.1" in uses + assert "actions/download-artifact@v4" not in uses def test_release_has_no_disabled_or_sdist_only_wheel_download(): @@ -163,7 +186,8 @@ def test_release_install_test_gates_publish_and_uses_supported_matrix(): install_test = jobs["install_test"] matrix = install_test["strategy"]["matrix"] - assert jobs["publish"]["needs"] == ["install_test", "build_sdist"] + assert jobs["publish_testpypi"]["needs"] == ["install_test", "build_sdist"] + assert jobs["publish_pypi"]["needs"] == ["install_test", "build_sdist"] assert install_test["needs"] == "build_wheels" assert matrix["os"] == ["ubuntu-24.04", "macos-15", "windows-2025-vs2026"] assert matrix["python"] == ["3.11", "3.12", "3.13", "3.14"] @@ -183,22 +207,12 @@ def test_release_install_test_downloads_matching_wheels_and_runs_integration_tes assert "tests/integration/test_retraction_basics.py" in combined_runs -def test_publish_downloads_wheels_and_checks_artifacts_before_pypa_publish(): - steps = _workflow()["jobs"]["publish"]["steps"] - pypa_prod_idx = next( - i - for i, step in enumerate(steps) - if step.get("name") == "publish to PyPI (trusted publishing)" - ) - gh_release_idx = next( - i for i, step in enumerate(steps) if "softprops/action-gh-release" in step.get("uses", "") - ) - check_idx = next( - i - for i, step in enumerate(steps) - if step.get("name") == "verify release artifacts before publish" - ) - attest_idx = next(i for i, step in enumerate(steps) if step.get("uses") == "actions/attest@v4") +def test_pypi_publish_downloads_wheels_and_checks_artifacts_before_publish_and_release(): + steps = _publish_steps("publish_pypi") + pypa_prod_idx = _step_index(steps, name="publish to PyPI (trusted publishing)") + gh_release_idx = _step_index(steps, uses="softprops/action-gh-release") + check_idx = _step_index(steps, name="verify release artifacts before publish") + attest_idx = _step_index(steps, uses="actions/attest@v4") wheel_downloads = [ (i, s) for i, s in enumerate(steps) @@ -259,58 +273,64 @@ def test_publish_artifact_verify_rejects_duplicate_linux_wheel(tmp_path): def test_publish_has_manual_testpypi_step_with_explicit_gate_and_repository_url(): - steps = _workflow()["jobs"]["publish"]["steps"] + job = _workflow()["jobs"]["publish_testpypi"] + steps = job["steps"] step = next(s for s in steps if s.get("name") == "publish to TestPyPI (trusted publishing)") - step_if = str(step.get("if", "")) + job_if = str(job.get("if", "")) + assert job.get("environment") == "testpypi" assert step.get("uses") == "pypa/gh-action-pypi-publish@release/v1" assert step.get("with", {}).get("repository-url") == "https://test.pypi.org/legacy/" - assert "workflow_dispatch" in step_if - assert "publish-testpypi" in step_if - assert "github.event_name" in step_if - - -def test_testpypi_publish_runs_after_verify_and_attest_steps(): - steps = _workflow()["jobs"]["publish"]["steps"] - verify_idx = next( + assert "workflow_dispatch" in job_if + assert "publish-testpypi" in job_if + assert "github.event_name" in job_if + assert "if" not in step + + +def test_testpypi_publish_downloads_wheels_and_runs_after_verify_and_attest_steps(): + steps = _publish_steps("publish_testpypi") + check_idx = _step_index(steps, name="verify release artifacts before publish") + attest_idx = _step_index(steps, uses="actions/attest@v4") + testpypi_idx = _step_index(steps, name="publish to TestPyPI (trusted publishing)") + wheel_download_idx = next( i for i, step in enumerate(steps) - if step.get("name") == "verify release artifacts before publish" - ) - attest_idx = next(i for i, step in enumerate(steps) if step.get("uses") == "actions/attest@v4") - testpypi_idx = next( - i - for i, step in enumerate(steps) - if step.get("name") == "publish to TestPyPI (trusted publishing)" + if step.get("uses") == "actions/download-artifact@v8.0.1" + and step.get("with", {}).get("pattern") == "wheels-*" ) - assert verify_idx < attest_idx < testpypi_idx + assert wheel_download_idx < check_idx < attest_idx < testpypi_idx def test_production_pypi_publish_is_gated_to_tag_push_and_not_testpypi(): - steps = _workflow()["jobs"]["publish"]["steps"] + job = _workflow()["jobs"]["publish_pypi"] + steps = job["steps"] step = next(s for s in steps if s.get("name") == "publish to PyPI (trusted publishing)") - assert step.get("if") == "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')" + assert job.get("if") == "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')" + assert job.get("environment") == "pypi" + assert "if" not in step assert "repository-url" not in (step.get("with") or {}) def test_publish_attestation_subject_paths_cover_wheels_and_sdist(): - steps = _workflow()["jobs"]["publish"]["steps"] - attest_step = next(step for step in steps if step.get("uses") == "actions/attest@v4") - subject_path = str(attest_step.get("with", {}).get("subject-path", "")) - assert "dist/pyrewire-*.whl" in subject_path - assert "dist/pyrewire-*.tar.gz" in subject_path + for job_name in ("publish_testpypi", "publish_pypi"): + steps = _publish_steps(job_name) + attest_step = next(step for step in steps if step.get("uses") == "actions/attest@v4") + subject_path = str(attest_step.get("with", {}).get("subject-path", "")) + assert "dist/pyrewire-*.whl" in subject_path + assert "dist/pyrewire-*.tar.gz" in subject_path def test_release_is_not_sdist_only(): - steps = _workflow()["jobs"]["publish"]["steps"] - download_with = [s.get("with", {}) for s in steps] - assert any(w.get("name") == "sdist" for w in download_with) - assert any(w.get("pattern") == "wheels-*" for w in download_with) - assert "install_test" in _workflow()["jobs"]["publish"]["needs"] + for job_name in ("publish_testpypi", "publish_pypi"): + steps = _publish_steps(job_name) + download_with = [s.get("with", {}) for s in steps] + assert any(w.get("name") == "sdist" for w in download_with) + assert any(w.get("pattern") == "wheels-*" for w in download_with) + assert "install_test" in _workflow()["jobs"][job_name]["needs"] def test_creates_github_release(): - steps = _workflow()["jobs"]["publish"]["steps"] + steps = _publish_steps("publish_pypi") uses = [s.get("uses", "") for s in steps] assert any( "softprops/action-gh-release" in u for u in uses @@ -318,14 +338,15 @@ def test_creates_github_release(): release_step = next( step for step in steps if "softprops/action-gh-release" in step.get("uses", "") ) - assert ( - release_step.get("if") - == "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')" + assert "if" not in release_step + assert not any( + "softprops/action-gh-release" in s.get("uses", "") + for s in _publish_steps("publish_testpypi") ) def test_github_release_uses_extracted_changelog_section(): - steps = _workflow()["jobs"]["publish"]["steps"] + steps = _publish_steps("publish_pypi") release_index = next( i for i, step in enumerate(steps) if "softprops/action-gh-release" in step.get("uses", "") ) @@ -346,14 +367,13 @@ def test_github_release_uses_extracted_changelog_section(): def test_release_notes_extraction_is_gated_to_tag_push(): - steps = _workflow()["jobs"]["publish"]["steps"] + job = _workflow()["jobs"]["publish_pypi"] + steps = job["steps"] extract_step = next( step for step in steps if step.get("name") == "extract GitHub Release notes" ) - assert ( - extract_step.get("if") - == "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')" - ) + assert job.get("if") == "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')" + assert "if" not in extract_step def test_release_workflow_top_level_permissions_are_read_only(): @@ -361,10 +381,14 @@ def test_release_workflow_top_level_permissions_are_read_only(): assert perms == {"contents": "read"} -def test_only_publish_job_has_write_and_oidc_permissions(): +def test_publish_jobs_have_least_privilege_write_and_oidc_permissions(): jobs = _workflow()["jobs"] - publish_perms = jobs["publish"].get("permissions", {}) - assert publish_perms == { + assert jobs["publish_testpypi"].get("permissions", {}) == { + "contents": "read", + "id-token": "write", + "attestations": "write", + } + assert jobs["publish_pypi"].get("permissions", {}) == { "contents": "write", "id-token": "write", "attestations": "write",