From a3e62affb66b9268d0d1322f1c55988c42865667 Mon Sep 17 00:00:00 2001 From: DABH Date: Thu, 10 Sep 2026 11:52:38 -0500 Subject: [PATCH 1/2] Harden the release pipeline: dry-run dispatch, publish the tested artifacts, strict smoke, TestPyPI check release-python.yml gains a dry run: dispatch on a branch with the tag input naming the intended tag and skip-publish on, and the tag validation, version policy, full test matrix and artifact build run without uploading anything or consuming a tag. Uploads and the draft release now require a real tag ref. The separate build job is gone: the test job's ubuntu dist cell already builds, verifies and smoke-tests the wheel and sdist, so the publish and release jobs download that dist- artifact and the published bytes are the tested bytes. The clean-project smoke installs are strict unless plugin.toml allow-final is false (the only case where the SDK still ships the same files), on TestPyPI as well as PyPI; allow_final is a prepare output. check-version-policy also refuses a version that already exists on TestPyPI, because uploads are immutable and skip-existing would otherwise hide the failure while the smoke test validated stale bytes. transition_markers() fails closed on git errors instead of reporting no markers. The release checkout no longer persists the write token, assets are uploaded with gh release upload --clobber, first-release notes link tree/refs/tags//, and the SDK-overlap warning appears in pre-release notes only for plugins the SDK still bundles. --- .github/workflows/release-python.yml | 85 ++++++++++++++++------------ AGENTS.md | 5 +- scripts/release/release_tool.py | 67 +++++++++++++--------- scripts/tests/test_release_tool.py | 27 +++++++++ scripts/tests/test_workflows.py | 28 ++++++++- 5 files changed, 147 insertions(+), 65 deletions(-) diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml index 2d1ad35..ee1cc9f 100644 --- a/.github/workflows/release-python.yml +++ b/.github/workflows/release-python.yml @@ -1,8 +1,13 @@ # Release pipeline for ONE Python plugin, triggered by a scoped tag such as # python/openai_agents/v1.0.0rc1. The plugin is a parameter parsed from the tag. # -# prepare -> test (reusable CI, full matrix) + build (once) -> publish-testpypi -# -> smoke-testpypi -> [finals only] publish-pypi -> smoke-pypi -> github-release (draft) +# prepare -> test (reusable CI, full matrix; its ubuntu dist cell builds and checks the artifacts) +# -> publish-testpypi -> smoke-testpypi -> [finals only] publish-pypi -> smoke-pypi -> github-release (draft) +# +# The artifacts that were tested are the artifacts that get published. Dry run: dispatch on +# main with the `tag` input set to the tag you intend to push and `skip-publish` on; everything +# up to and including the tested build runs, nothing is uploaded and no tag is consumed. +# Uploads and the draft release happen only for a real tag ref. # # Publishing uses PyPI trusted publishing (OIDC): no stored credentials. The # publish jobs live INLINE here on purpose: PyPI rejects reusable workflows as @@ -16,6 +21,14 @@ on: - "python/*/v*" workflow_dispatch: inputs: + tag: + description: Release tag to validate and build when dispatching from a branch (dry run), e.g. python/mcp/v0.1.0. Leave empty when dispatching on a tag ref. + type: string + default: "" + skip-publish: + description: Dry run - validate, test and build, but upload nothing and draft no release + type: boolean + default: false publish-prerelease-to-pypi: description: For a pre-release tag ref, also publish to PyPI (still gated by the pypi environment; ignored while plugin.toml [release] allow-final is false) type: boolean @@ -46,6 +59,8 @@ jobs: coordinate: ${{ steps.tag.outputs.coordinate }} root_api: ${{ steps.tag.outputs.root_api }} smoke_imports: ${{ steps.tag.outputs.smoke_imports }} + allow_final: ${{ steps.tag.outputs.allow_final }} + publish: ${{ steps.gate.outputs.publish }} publish_pypi: ${{ steps.gate.outputs.publish_pypi }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -58,7 +73,7 @@ jobs: - name: Parse tag id: tag env: - TAG: ${{ github.ref_name }} + TAG: ${{ inputs.tag || github.ref_name }} run: uv run --project scripts --locked python scripts/release/release_tool.py parse-tag "$TAG" --github-output "$GITHUB_OUTPUT" - name: Tag version matches the manifest env: @@ -76,11 +91,15 @@ jobs: PY - name: Tag is reachable from main # fetch-depth 0 above already brought origin/main; no extra fetch (credentials are not persisted). + env: + REF_TYPE: ${{ github.ref_type }} run: | git rev-parse --verify --quiet origin/main > /dev/null || { echo "::error::origin/main not present in checkout"; exit 1; } - if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + if git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then exit 0; fi + if [ "$REF_TYPE" = "tag" ]; then echo "::error::release tags must point at a commit on main"; exit 1 fi + echo "::warning::dry run from a commit that is not on main; a release tag here would be rejected" - name: Version policy env: PLUGIN_DIR: ${{ steps.tag.outputs.plugin_dir }} @@ -92,7 +111,16 @@ jobs: PRERELEASE: ${{ steps.tag.outputs.prerelease }} ALLOW_FINAL: ${{ steps.tag.outputs.allow_final }} OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish-prerelease-to-pypi }} + REF_TYPE: ${{ github.ref_type }} + SKIP_PUBLISH: ${{ github.event_name == 'workflow_dispatch' && inputs.skip-publish }} run: | + # Uploads and the draft release happen only for a real tag ref, and never on a dry run. + if [ "$REF_TYPE" = "tag" ] && [ "$SKIP_PUBLISH" != "true" ]; then + echo "publish=true" >> "$GITHUB_OUTPUT" + else + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "::notice::dry run: validation, tests and the build run; nothing is uploaded and no release is drafted" + fi # Finals reach PyPI; pre-releases only via the dispatch override, and never while the plugin # is still in the SDK-cutover transition (allow-final = false). if [ "$PRERELEASE" = "false" ] || { [ "$OVERRIDE" = "true" ] && [ "$ALLOW_FINAL" = "true" ]; }; then @@ -111,31 +139,12 @@ jobs: plugin: ${{ needs.prepare.outputs.plugin }} deps: locked - build: - name: Build distributions - needs: prepare - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - with: - version-file: ${{ needs.prepare.outputs.plugin_dir }}/pyproject.toml - - uses: ./.github/actions/python-build-check - with: - plugin-dir: ${{ needs.prepare.outputs.plugin_dir }} - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: dist - path: ${{ needs.prepare.outputs.plugin_dir }}/dist - if-no-files-found: error - publish-testpypi: name: Publish to TestPyPI - needs: [prepare, test, build] + # dist- is built, verified and smoke-tested by the test job's ubuntu dist cell + # (_python-plugin.yml): the published bytes are the tested bytes. + needs: [prepare, test] + if: needs.prepare.outputs.publish == 'true' runs-on: ubuntu-latest environment: testpypi permissions: @@ -143,7 +152,7 @@ jobs: steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: dist + name: dist-${{ needs.prepare.outputs.plugin }} path: dist/ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: @@ -155,6 +164,7 @@ jobs: smoke-testpypi: name: Smoke-test from TestPyPI needs: [prepare, publish-testpypi] + if: needs.prepare.outputs.publish == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -172,13 +182,14 @@ jobs: ROOT_API: ${{ needs.prepare.outputs.root_api }} SMOKE_IMPORTS: ${{ needs.prepare.outputs.smoke_imports }} INDEX_URL: https://test.pypi.org/simple/ - ALLOW_OVERLAP_WITH_CORE: "1" + # A plugin the SDK still bundles (allow-final = false) may overlap it; any other must not. + ALLOW_OVERLAP_WITH_CORE: ${{ needs.prepare.outputs.allow_final == 'true' && '0' || '1' }} run: bash scripts/release/smoke_from_index.sh publish-pypi: name: Publish to PyPI needs: [prepare, smoke-testpypi] - if: needs.prepare.outputs.publish_pypi == 'true' + if: needs.prepare.outputs.publish == 'true' && needs.prepare.outputs.publish_pypi == 'true' runs-on: ubuntu-latest environment: pypi permissions: @@ -186,7 +197,7 @@ jobs: steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: dist + name: dist-${{ needs.prepare.outputs.plugin }} path: dist/ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: @@ -197,7 +208,7 @@ jobs: smoke-pypi: name: Smoke-test from PyPI needs: [prepare, publish-pypi] - if: needs.prepare.outputs.publish_pypi == 'true' + if: needs.prepare.outputs.publish == 'true' && needs.prepare.outputs.publish_pypi == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -214,15 +225,16 @@ jobs: VERSION: ${{ needs.prepare.outputs.version }} ROOT_API: ${{ needs.prepare.outputs.root_api }} SMOKE_IMPORTS: ${{ needs.prepare.outputs.smoke_imports }} - # Finals must never overlap the SDK; a dispatched pre-release still may. - ALLOW_OVERLAP_WITH_CORE: ${{ needs.prepare.outputs.prerelease == 'true' && '1' || '0' }} + # Same rule as TestPyPI: only a plugin the SDK still bundles may overlap it. + ALLOW_OVERLAP_WITH_CORE: ${{ needs.prepare.outputs.allow_final == 'true' && '0' || '1' }} run: bash scripts/release/smoke_from_index.sh github-release: name: Draft GitHub release needs: [prepare, smoke-testpypi, smoke-pypi] if: >- - always() && needs.prepare.result == 'success' && needs.smoke-testpypi.result == 'success' + always() && needs.prepare.result == 'success' && needs.prepare.outputs.publish == 'true' + && needs.smoke-testpypi.result == 'success' && (needs.smoke-pypi.result == 'success' || needs.smoke-pypi.result == 'skipped') runs-on: ubuntu-latest permissions: @@ -231,12 +243,13 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false # gh uses GH_TOKEN below; do not leave the write token in .git/config - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: version-file: scripts/pyproject.toml - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: dist + name: dist-${{ needs.prepare.outputs.plugin }} path: dist/ - name: Generate release notes from history env: diff --git a/AGENTS.md b/AGENTS.md index 3a9dcfb..4a3ddaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,12 +92,13 @@ One entry workflow, one reusable workflow per language, plugin as a parameter, n Trusted publishing by ecosystem: PyPI uses OIDC trusted publishing (`pypa/gh-action-pypi-publish`, no stored token; PyPI cannot bind a reusable workflow, so publish jobs live inline in `release-python.yml`). npm supports OIDC trusted publishing (GitHub-hosted runners, npm >= 11.5.1, one publisher per package, register the calling workflow's filename; provenance is automatic for a public repo and package). Maven Central has no OIDC: Central Portal user token plus GPG signing, kept as environment-scoped secrets. Go has nothing to upload: an immutable tag plus `sum.golang.org` is the release. -Version policy (`release_tool.py check-version-policy`, evaluated against pypi.org only): a coordinate with no published release must start at exactly `1.0.0` (`ga`) or `0.1.0` (otherwise), pre-releases of that version allowed; an existing coordinate must be strictly greater than its highest published version, yanked releases included. Final versions additionally require `plugin.toml` `[release] allow-final = true` and no `TRANSITION(sdk-cutover)` marker in the plugin. +Version policy (`release_tool.py check-version-policy`, evaluated against pypi.org): a coordinate with no published release must start at exactly `1.0.0` (`ga`) or `0.1.0` (otherwise), pre-releases of that version allowed; an existing coordinate must be strictly greater than its highest published version, yanked releases included. The exact version must not already exist on TestPyPI (uploads are immutable and `skip-existing` would hide the failure). Final versions additionally require `plugin.toml` `[release] allow-final = true` and no `TRANSITION(sdk-cutover)` marker in the plugin. Runbook for `python/`: 1. Open a release PR that sets `version` in `pyproject.toml` (re-sync from upstream first while the transition rules apply). Merge it. 2. `git tag -a python//v -m "python/ v"` on the merged `main` commit and push the tag. Tags must match `//v` and are protected by a tag ruleset. -3. `release-python.yml` validates the tag, runs the full test matrix, builds once, publishes to TestPyPI (environment `testpypi`), smoke-installs from TestPyPI in a clean project, and for final versions publishes to PyPI (environment `pypi`, required reviewers confirm the tag SHA is on `main`) and smoke-installs again. +3. `release-python.yml` validates the tag, runs the full test matrix (its ubuntu dist cell builds, checks and smoke-tests the wheel and sdist), publishes those tested artifacts to TestPyPI (environment `testpypi`), smoke-installs from TestPyPI in a clean project, and for final versions publishes to PyPI (environment `pypi`, required reviewers confirm the tag SHA is on `main`) and smoke-installs again. The clean-project smoke tolerates file overlap with the SDK only while `allow-final = false`. + Dry run first: `gh workflow run release-python.yml --ref main -f tag=python//v -f skip-publish=true` runs the validation, the matrix and the build without uploading anything or consuming a tag. 4. A draft GitHub Release is created idempotently with generated notes and the artifacts. Edit the notes and publish it by hand. 5. If anything fails after upload, fix forward with the next `rcN`; uploaded files are immutable and tags are never moved. diff --git a/scripts/release/release_tool.py b/scripts/release/release_tool.py index f6085c3..9a6f094 100755 --- a/scripts/release/release_tool.py +++ b/scripts/release/release_tool.py @@ -3,7 +3,8 @@ Subcommands: parse-tag TAG validate `//v` and emit its parts - check-version-policy enforce the version policy against the production registry + check-version-policy enforce the version policy against the production registry, and + refuse a version that already exists on TestPyPI (uploads are immutable) release-notes generate release notes from commits touching the plugin dir draft-release create/update an idempotent draft GitHub Release with assets @@ -32,7 +33,10 @@ from packaging.version import InvalidVersion, Version TAG_RE = re.compile(r"^(?Ppython|typescript|java|go)/(?P[a-z0-9_.-]+)/v(?P.+)$") -REGISTRY_JSON = {"pypi": "https://pypi.org/pypi/{coordinate}/json"} +REGISTRY_JSON = { + "pypi": "https://pypi.org/pypi/{coordinate}/json", + "testpypi": "https://test.pypi.org/pypi/{coordinate}/json", +} FIRST_VERSION = {"ga": Version("1.0.0"), "preview": Version("0.1.0"), "experimental": Version("0.1.0")} TRANSITION_MARKER = "TRANSITION(sdk-cutover)" DEFAULT_REPO = "temporalio/ai-integrations" @@ -147,13 +151,15 @@ def check_policy(version: Version, maturity: str, published: list[Version]) -> N def transition_markers(repo_root: Path, plugin_dir: str) -> list[str]: - try: - out = subprocess.run( - ["git", "grep", "-l", TRANSITION_MARKER, "--", plugin_dir], - capture_output=True, text=True, cwd=repo_root, - ) - except FileNotFoundError: - return [] + """List plugin files containing the transition marker. Fails closed on any git error.""" + out = subprocess.run( + ["git", "grep", "-l", TRANSITION_MARKER, "--", plugin_dir], + capture_output=True, text=True, cwd=repo_root, + ) + # git grep exits 1 for "no match"; anything else (not a repo, bad pathspec, ...) is an error + # and must not be mistaken for "no markers". + if out.returncode not in (0, 1): + raise PolicyError(f"git grep for {TRANSITION_MARKER} failed in {repo_root} ({out.returncode}): {out.stderr.strip()}") return [line for line in out.stdout.splitlines() if line.strip()] @@ -167,6 +173,14 @@ def cmd_check_version_policy(args: argparse.Namespace) -> int: published = fetch_published_versions(coordinate, registry, Path(args.registry_json) if args.registry_json else None) check_policy(version, maturity, published) print(f"OK: {coordinate} {version} satisfies the version policy (published: {[str(v) for v in sorted(published)] or 'none'})") + if registry == "pypi": + # Every release is uploaded to TestPyPI first, and uploads are immutable: a version that is + # already there would be skipped (skip-existing) and the smoke test would validate stale + # bytes while reporting success. Fix forward with the next rcN instead. + staged = fetch_published_versions(coordinate, "testpypi", Path(args.testpypi_json) if args.testpypi_json else None) + if version in staged: + raise PolicyError(f"{coordinate} {version} already exists on TestPyPI; uploads are immutable, use the next pre-release number") + print(f"OK: {coordinate} {version} is not yet on TestPyPI") if not version.is_prerelease: if not meta.get("release", {}).get("allow-final"): raise PolicyError( @@ -227,7 +241,8 @@ def release_notes(repo_root: Path, plugin_dir: str, tag: str, repo: str) -> str: f"from https://github.com/{up_repo}/commits/main/{up_path}.", "", ] - lines += [f"**Source**: https://github.com/{repo}/tree/{tag}/{plugin_dir}", ""] + # refs/tags/ keeps GitHub from reading the slash-separated tag as a ref plus a path. + lines += [f"**Source**: https://github.com/{repo}/tree/refs/tags/{tag}/{plugin_dir}", ""] else: prev_tag, _ = prev log = _git("log", "--no-decorate", "--format=%h%x1f%s", f"{prev_tag}..{tag}", "--", plugin_dir, cwd=repo_root) @@ -245,11 +260,15 @@ def release_notes(repo_root: Path, plugin_dir: str, tag: str, repo: str) -> str: "## Pre-release notes", "", "- This pre-release is published to **TestPyPI only** to validate the release pipeline.", - f"- Do **not** install it alongside a `temporalio` release that still embeds `{root_api or coordinate}`; both distributions " - "write the same files and whichever installs last wins. Wait for the SDK cutover release.", - "- docs.temporal.io still describes the SDK-embedded install until the cutover.", - "", ] + if not meta.get("release", {}).get("allow-final", False): + # Only a plugin the SDK still bundles shares files with it. + lines += [ + f"- Do **not** install it alongside a `temporalio` release that still embeds `{root_api or coordinate}`; both distributions " + "write the same files and whichever installs last wins. Wait for the SDK cutover release.", + "- docs.temporal.io still describes the SDK-embedded install until the cutover.", + ] + lines.append("") return "\n".join(lines) @@ -305,18 +324,13 @@ def cmd_draft_release(args: argparse.Namespace) -> int: _gh("api", "-X", "PATCH", f"repos/{repo}/releases/{release_id}", "-f", f"name={args.title}", "-F", "draft=true", "-F", f"prerelease={'true' if args.prerelease else 'false'}", "-f", f"body={body}") print(f"updated existing release {release_id} for {args.tag}") - existing: list[dict] = [] - for page in _json_documents(_gh("api", f"repos/{repo}/releases/{release_id}/assets?per_page=100", "--paginate")): - existing.extend(page if isinstance(page, list) else [page]) - by_name = {a["name"]: a["id"] for a in existing} - for path in sorted(Path(args.dist).iterdir()): - if not path.is_file(): - continue - if path.name in by_name: - _gh("api", "-X", "DELETE", f"repos/{repo}/releases/assets/{by_name[path.name]}") - upload_url = f"https://uploads.github.com/repos/{repo}/releases/{release_id}/assets?name={path.name}" - _gh("api", "-X", "POST", "-H", "Content-Type: application/octet-stream", "--input", str(path), upload_url) - print(f"uploaded {path.name}") + assets = [str(path) for path in sorted(Path(args.dist).iterdir()) if path.is_file()] + if assets: + # gh streams the binaries itself and --clobber replaces same-name assets atomically, + # instead of a delete-then-POST through `gh api --input`. + _gh("release", "upload", args.tag, *assets, "--repo", repo, "--clobber") + for asset in assets: + print(f"uploaded {Path(asset).name}") print(f"OK: draft release ready: https://github.com/{repo}/releases/tag/{args.tag}") return 0 @@ -338,6 +352,7 @@ def main(argv: list[str] | None = None) -> int: p.add_argument("--plugin-dir", required=True) p.add_argument("--version", required=True) p.add_argument("--registry-json", default=None, help="read published versions from this file instead of the registry (tests)") + p.add_argument("--testpypi-json", default=None, help="read TestPyPI versions from this file instead of the registry (tests)") p.set_defaults(func=cmd_check_version_policy) p = sub.add_parser("release-notes", help="generate release notes from history") diff --git a/scripts/tests/test_release_tool.py b/scripts/tests/test_release_tool.py index a4a40e4..cc6d789 100644 --- a/scripts/tests/test_release_tool.py +++ b/scripts/tests/test_release_tool.py @@ -82,6 +82,22 @@ def test_cli_policy_final_blocked_by_transition_markers(repo: Path, tmp_path: Pa assert release_tool.main(["--repo-root", str(repo), "check-version-policy", "--plugin-dir", str(d), "--version", "0.1.0", "--registry-json", str(reg)]) == 0 +def test_cli_policy_rejects_a_version_already_on_testpypi(plugin_repo: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + reg = _registry(tmp_path, None) + staged = tmp_path / "testpypi.json" + staged.write_text(json.dumps({"releases": {"0.1.0rc1": []}})) + args = ["--repo-root", str(plugin_repo), "check-version-policy", "--plugin-dir", str(plugin_repo / "python/fakeplug"), + "--registry-json", str(reg), "--testpypi-json", str(staged)] + assert release_tool.main([*args, "--version", "0.1.0rc1"]) == 1 + assert "already exists on TestPyPI" in capsys.readouterr().out + assert release_tool.main([*args, "--version", "0.1.0rc2"]) == 0 + + +def test_transition_markers_fail_closed_outside_a_repository(tmp_path: Path) -> None: + with pytest.raises(release_tool.PolicyError, match="git grep"): + release_tool.transition_markers(tmp_path, "python/fakeplug") + + def test_cli_policy_existing_versions(plugin_repo: Path, tmp_path: Path) -> None: reg = _registry(tmp_path, ["0.1.0", "0.2.0"]) args = ["--repo-root", str(plugin_repo), "check-version-policy", "--plugin-dir", str(plugin_repo / "python/fakeplug"), "--registry-json", str(reg)] @@ -97,6 +113,7 @@ def test_release_notes_initial_and_incremental(plugin_repo: Path, tmp_path: Path assert "First standalone release of `temporalio-fakeplug`" in notes assert "TestPyPI only" in notes and "temporalio.contrib.fakeplug" in notes assert "commits/main/temporalio/contrib/fakeplug" in notes + assert "tree/refs/tags/python/fakeplug/v0.1.0rc1/python/fakeplug" in notes (repo / "python/fakeplug/src/temporalio/contrib/fakeplug/_impl.py").write_text("VALUE = 2\n") commit_all(repo, "Fix the thing (#12)") @@ -114,6 +131,16 @@ def test_release_notes_initial_and_incremental(plugin_repo: Path, tmp_path: Path assert "Pre-release notes" not in notes +def test_prerelease_notes_warn_about_sdk_overlap_only_while_the_sdk_bundles_the_plugin(repo: Path) -> None: + d = make_python_plugin(repo, "fakeplug", allow_final=True) + commit_all(repo, "plugin") + git(repo, "tag", "python/fakeplug/v0.1.0rc1") + notes = release_tool.release_notes(repo, "python/fakeplug", "python/fakeplug/v0.1.0rc1", "temporalio/ai-integrations") + assert "TestPyPI only" in notes + assert "still embeds" not in notes and "SDK cutover" not in notes + assert d.is_dir() + + def test_release_notes_ignores_other_plugins_tags(plugin_repo: Path) -> None: repo = plugin_repo git(repo, "tag", "python/other/v9.0.0") diff --git a/scripts/tests/test_workflows.py b/scripts/tests/test_workflows.py index 3be8be9..21e9992 100644 --- a/scripts/tests/test_workflows.py +++ b/scripts/tests/test_workflows.py @@ -64,8 +64,34 @@ def test_release_publish_jobs_are_inline_and_oidc_only() -> None: assert any(s.startswith("pypa/gh-action-pypi-publish@") for s in steps) assert doc["jobs"]["publish-testpypi"]["environment"] == "testpypi" assert doc["jobs"]["publish-pypi"]["environment"] == "pypi" - assert doc["jobs"]["publish-pypi"]["if"] == "needs.prepare.outputs.publish_pypi == 'true'" + # Nothing is uploaded on a dry run or from a non-tag ref; PyPI additionally needs the policy gate. + assert doc["jobs"]["publish-testpypi"]["if"] == "needs.prepare.outputs.publish == 'true'" + assert doc["jobs"]["publish-pypi"]["if"] == "needs.prepare.outputs.publish == 'true' && needs.prepare.outputs.publish_pypi == 'true'" + assert "needs.prepare.outputs.publish == 'true'" in doc["jobs"]["github-release"]["if"] assert doc[True]["push"]["tags"] == ["python/*/v*"] # PyYAML parses the `on` key as boolean True + inputs = doc[True]["workflow_dispatch"]["inputs"] + assert inputs["skip-publish"]["type"] == "boolean" and inputs["tag"]["type"] == "string" + + +def test_release_publishes_the_tested_artifacts() -> None: + doc = yaml.safe_load((REPO / ".github/workflows/release-python.yml").read_text()) + assert "build" not in doc["jobs"], "the test job's dist cell builds the artifacts; do not rebuild for publishing" + tested = "dist-${{ needs.prepare.outputs.plugin }}" + for job in ("publish-testpypi", "publish-pypi", "github-release"): + downloads = [s["with"]["name"] for s in doc["jobs"][job]["steps"] if "download-artifact" in s.get("uses", "")] + assert downloads == [tested], f"{job} must publish the artifact the test job produced" + assert "test" in doc["jobs"]["publish-testpypi"]["needs"] + plugin_wf = yaml.safe_load((REPO / ".github/workflows/_python-plugin.yml").read_text()) + upload = [s for s in plugin_wf["jobs"]["test"]["steps"] if "upload-artifact" in s.get("uses", "") and s["with"]["name"].startswith("dist-")] + assert upload and upload[0]["with"]["name"] == "dist-${{ inputs.plugin }}" + + +def test_release_checkouts_do_not_persist_credentials() -> None: + doc = yaml.safe_load((REPO / ".github/workflows/release-python.yml").read_text()) + for name, job in doc["jobs"].items(): + for step in job.get("steps", []): + if "actions/checkout@" in step.get("uses", ""): + assert step["with"].get("persist-credentials") is False, f"{name}: checkout must set persist-credentials: false" def test_top_level_permissions_are_empty_or_read_only() -> None: From fbde27c1aa17ab6b1699d48877340801d4923237 Mon Sep 17 00:00:00 2001 From: DABH Date: Thu, 10 Sep 2026 12:34:45 -0500 Subject: [PATCH 2/2] Release pipeline: keep re-runs possible, verify index files, guard dispatch inputs Self-review of the hardening PR found that a hard failure on a version already staged on TestPyPI removed the only recovery path for a failed final release (fix-forward cannot produce a new 1.0.0, and a fresh dispatch or re-run of all jobs would trip the check). The staged check is now a warning behind --check-testpypi, and the real guarantee moves to where it belongs: both smoke jobs run verify-index-files, which fails unless the index serves exactly the sha256 of the artifacts this run built (uv_build is reproducible, so a legitimate re-run passes and only foreign bytes fail). A dispatch on a tag ref with a different tag input could publish plugin A while rewriting plugin B's release: prepare now rejects that, requires the tag input for branch dispatches, and exports the parsed tag so the release job never reads github.ref_name. The publish gate compares skip-publish against the literal false, so a malformed value is a dry run rather than an upload. Artifacts are named per lane (dist--) and the release consumes -locked. draft-release refuses to modify a release that is already published. transition_markers fails closed when git is missing. github-release uses !cancelled(), and the concurrency group is per tag so dry runs do not queue behind each other. The policy tests no longer reach the network, and the workflow tests pin the smoke strictness expressions, the dist-cell ordering and the dispatch guard. The runbook now puts the dry run between the version bump and the tag push. --- .github/workflows/_python-plugin.yml | 2 +- .github/workflows/release-python.yml | 57 +++++++++-- AGENTS.md | 12 +-- scripts/release/release_tool.py | 145 +++++++++++++++++++++------ scripts/tests/test_release_tool.py | 88 ++++++++++++++-- scripts/tests/test_workflows.py | 42 ++++++-- 6 files changed, 285 insertions(+), 61 deletions(-) diff --git a/.github/workflows/_python-plugin.yml b/.github/workflows/_python-plugin.yml index a3a9f4e..a27f5f1 100644 --- a/.github/workflows/_python-plugin.yml +++ b/.github/workflows/_python-plugin.yml @@ -102,6 +102,6 @@ jobs: if: matrix.dist uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: dist-${{ inputs.plugin }} + name: dist-${{ inputs.plugin }}-${{ inputs.deps }} # one lane per name; the release publishes -locked path: python/${{ inputs.plugin }}/dist if-no-files-found: error diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml index ee1cc9f..f7344d5 100644 --- a/.github/workflows/release-python.yml +++ b/.github/workflows/release-python.yml @@ -41,7 +41,7 @@ defaults: shell: bash concurrency: - group: release-${{ github.ref }} + group: release-${{ inputs.tag || github.ref }} # dry runs of different tags on main do not queue behind each other cancel-in-progress: false jobs: @@ -59,6 +59,7 @@ jobs: coordinate: ${{ steps.tag.outputs.coordinate }} root_api: ${{ steps.tag.outputs.root_api }} smoke_imports: ${{ steps.tag.outputs.smoke_imports }} + tag: ${{ steps.tag.outputs.tag }} allow_final: ${{ steps.tag.outputs.allow_final }} publish: ${{ steps.gate.outputs.publish }} publish_pypi: ${{ steps.gate.outputs.publish_pypi }} @@ -70,6 +71,19 @@ jobs: - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: version-file: scripts/pyproject.toml + - name: Dispatch inputs are consistent with the ref + if: github.event_name == 'workflow_dispatch' + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + if [ "$REF_TYPE" = "tag" ] && [ -n "$INPUT_TAG" ] && [ "$INPUT_TAG" != "$REF_NAME" ]; then + echo "::error::tag input '$INPUT_TAG' does not match the dispatched tag ref '$REF_NAME'; dispatch on the tag without a tag input, or on a branch for a dry run"; exit 1 + fi + if [ "$REF_TYPE" != "tag" ] && [ -z "$INPUT_TAG" ]; then + echo "::error::dispatching from a branch is a dry run and needs the tag input, e.g. python/mcp/v0.1.0"; exit 1 + fi - name: Parse tag id: tag env: @@ -104,7 +118,7 @@ jobs: env: PLUGIN_DIR: ${{ steps.tag.outputs.plugin_dir }} VERSION: ${{ steps.tag.outputs.version }} - run: uv run --project scripts --locked python scripts/release/release_tool.py --repo-root "$PWD" check-version-policy --plugin-dir "$PLUGIN_DIR" --version "$VERSION" + run: uv run --project scripts --locked python scripts/release/release_tool.py --repo-root "$PWD" check-version-policy --plugin-dir "$PLUGIN_DIR" --version "$VERSION" --check-testpypi - name: Decide whether PyPI publication is allowed id: gate env: @@ -115,7 +129,8 @@ jobs: SKIP_PUBLISH: ${{ github.event_name == 'workflow_dispatch' && inputs.skip-publish }} run: | # Uploads and the draft release happen only for a real tag ref, and never on a dry run. - if [ "$REF_TYPE" = "tag" ] && [ "$SKIP_PUBLISH" != "true" ]; then + # Compare against the literal "false": anything else (a typo such as `True` or `1`) is a dry run. + if [ "$REF_TYPE" = "tag" ] && [ "$SKIP_PUBLISH" = "false" ]; then echo "publish=true" >> "$GITHUB_OUTPUT" else echo "publish=false" >> "$GITHUB_OUTPUT" @@ -141,8 +156,8 @@ jobs: publish-testpypi: name: Publish to TestPyPI - # dist- is built, verified and smoke-tested by the test job's ubuntu dist cell - # (_python-plugin.yml): the published bytes are the tested bytes. + # dist--locked is built, verified and smoke-tested by the test job's ubuntu dist cell + # (_python-plugin.yml, deps=locked): the published bytes are the tested bytes. needs: [prepare, test] if: needs.prepare.outputs.publish == 'true' runs-on: ubuntu-latest @@ -152,7 +167,7 @@ jobs: steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: dist-${{ needs.prepare.outputs.plugin }} + name: dist-${{ needs.prepare.outputs.plugin }}-locked path: dist/ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: @@ -175,6 +190,17 @@ jobs: - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: version-file: scripts/pyproject.toml + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist-${{ needs.prepare.outputs.plugin }}-locked + path: dist/ + - name: Index files are the tested artifacts + # skip-existing keeps a re-run from failing, so prove the files the index serves are the + # bytes this run built (uv_build is reproducible) rather than an earlier upload's. + env: + COORDINATE: ${{ needs.prepare.outputs.coordinate }} + VERSION: ${{ needs.prepare.outputs.version }} + run: uv run --project scripts --locked python scripts/release/release_tool.py verify-index-files --coordinate "$COORDINATE" --version "$VERSION" --dist dist --index testpypi - name: Install from TestPyPI into a clean project and smoke-test env: COORDINATE: ${{ needs.prepare.outputs.coordinate }} @@ -197,7 +223,7 @@ jobs: steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: dist-${{ needs.prepare.outputs.plugin }} + name: dist-${{ needs.prepare.outputs.plugin }}-locked path: dist/ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: @@ -219,6 +245,15 @@ jobs: - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: version-file: scripts/pyproject.toml + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist-${{ needs.prepare.outputs.plugin }}-locked + path: dist/ + - name: Index files are the tested artifacts + env: + COORDINATE: ${{ needs.prepare.outputs.coordinate }} + VERSION: ${{ needs.prepare.outputs.version }} + run: uv run --project scripts --locked python scripts/release/release_tool.py verify-index-files --coordinate "$COORDINATE" --version "$VERSION" --dist dist --index pypi - name: Install from PyPI into a clean project and smoke-test env: COORDINATE: ${{ needs.prepare.outputs.coordinate }} @@ -233,7 +268,7 @@ jobs: name: Draft GitHub release needs: [prepare, smoke-testpypi, smoke-pypi] if: >- - always() && needs.prepare.result == 'success' && needs.prepare.outputs.publish == 'true' + !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.publish == 'true' && needs.smoke-testpypi.result == 'success' && (needs.smoke-pypi.result == 'success' || needs.smoke-pypi.result == 'skipped') runs-on: ubuntu-latest @@ -249,18 +284,18 @@ jobs: version-file: scripts/pyproject.toml - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: dist-${{ needs.prepare.outputs.plugin }} + name: dist-${{ needs.prepare.outputs.plugin }}-locked path: dist/ - name: Generate release notes from history env: PLUGIN_DIR: ${{ needs.prepare.outputs.plugin_dir }} - TAG: ${{ github.ref_name }} + TAG: ${{ needs.prepare.outputs.tag }} run: uv run --project scripts --locked python scripts/release/release_tool.py --repo-root "$PWD" release-notes --plugin-dir "$PLUGIN_DIR" --tag "$TAG" --output notes.md - name: Create or update the draft release env: GH_TOKEN: ${{ github.token }} GITHUB_REPOSITORY: ${{ github.repository }} - TAG: ${{ github.ref_name }} + TAG: ${{ needs.prepare.outputs.tag }} TITLE: ${{ needs.prepare.outputs.plugin }} ${{ needs.prepare.outputs.version }} PRERELEASE: ${{ needs.prepare.outputs.prerelease }} run: | diff --git a/AGENTS.md b/AGENTS.md index 4a3ddaa..b0a2e36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,15 +92,15 @@ One entry workflow, one reusable workflow per language, plugin as a parameter, n Trusted publishing by ecosystem: PyPI uses OIDC trusted publishing (`pypa/gh-action-pypi-publish`, no stored token; PyPI cannot bind a reusable workflow, so publish jobs live inline in `release-python.yml`). npm supports OIDC trusted publishing (GitHub-hosted runners, npm >= 11.5.1, one publisher per package, register the calling workflow's filename; provenance is automatic for a public repo and package). Maven Central has no OIDC: Central Portal user token plus GPG signing, kept as environment-scoped secrets. Go has nothing to upload: an immutable tag plus `sum.golang.org` is the release. -Version policy (`release_tool.py check-version-policy`, evaluated against pypi.org): a coordinate with no published release must start at exactly `1.0.0` (`ga`) or `0.1.0` (otherwise), pre-releases of that version allowed; an existing coordinate must be strictly greater than its highest published version, yanked releases included. The exact version must not already exist on TestPyPI (uploads are immutable and `skip-existing` would hide the failure). Final versions additionally require `plugin.toml` `[release] allow-final = true` and no `TRANSITION(sdk-cutover)` marker in the plugin. +Version policy (`release_tool.py check-version-policy`; version ordering is evaluated against pypi.org): a coordinate with no published release must start at exactly `1.0.0` (`ga`) or `0.1.0` (otherwise), pre-releases of that version allowed; an existing coordinate must be strictly greater than its highest published version, yanked releases included. A version already staged on TestPyPI only produces a warning (a re-run after a staged upload is the normal recovery path); each smoke job then proves the index serves exactly the artifacts this run built (`verify-index-files`). Final versions additionally require `plugin.toml` `[release] allow-final = true` and no `TRANSITION(sdk-cutover)` marker in the plugin. Runbook for `python/`: 1. Open a release PR that sets `version` in `pyproject.toml` (re-sync from upstream first while the transition rules apply). Merge it. -2. `git tag -a python//v -m "python/ v"` on the merged `main` commit and push the tag. Tags must match `//v` and are protected by a tag ruleset. -3. `release-python.yml` validates the tag, runs the full test matrix (its ubuntu dist cell builds, checks and smoke-tests the wheel and sdist), publishes those tested artifacts to TestPyPI (environment `testpypi`), smoke-installs from TestPyPI in a clean project, and for final versions publishes to PyPI (environment `pypi`, required reviewers confirm the tag SHA is on `main`) and smoke-installs again. The clean-project smoke tolerates file overlap with the SDK only while `allow-final = false`. - Dry run first: `gh workflow run release-python.yml --ref main -f tag=python//v -f skip-publish=true` runs the validation, the matrix and the build without uploading anything or consuming a tag. -4. A draft GitHub Release is created idempotently with generated notes and the artifacts. Edit the notes and publish it by hand. -5. If anything fails after upload, fix forward with the next `rcN`; uploaded files are immutable and tags are never moved. +2. Dry run on the merged `main`: `gh workflow run release-python.yml --ref main -f tag=python//v -f skip-publish=true` runs the tag validation, the version policy, the full test matrix and the artifact build without uploading anything or consuming a tag (the manifest check needs the version bump to be on `main`). +3. `git tag -a python//v -m "python/ v"` on the merged `main` commit and push the tag. Tags must match `//v` and are protected by a tag ruleset. +4. `release-python.yml` validates the tag, runs the full test matrix (its ubuntu dist cell builds, checks and smoke-tests the wheel and sdist), publishes those tested artifacts to TestPyPI (environment `testpypi`), proves TestPyPI serves exactly those files, smoke-installs from TestPyPI in a clean project, and for final versions publishes to PyPI (environment `pypi`, required reviewers confirm the tag SHA is on `main`) and repeats the proof and the smoke there. The clean-project smoke tolerates file overlap with the SDK only while `allow-final = false`. +5. A draft GitHub Release is created idempotently with generated notes and the artifacts. Edit the notes and publish it by hand; a later re-run refuses to touch a release that is already published. +6. If a job fails after the TestPyPI upload, "Re-run failed jobs" (or a fresh dispatch on the tag) is safe: the upload is skipped and the smoke jobs verify the served files. If the artifacts themselves must change, fix forward with the next `rcN`; uploaded files are immutable and tags are never moved. ## Migration and re-sync diff --git a/scripts/release/release_tool.py b/scripts/release/release_tool.py index 9a6f094..3f84b17 100755 --- a/scripts/release/release_tool.py +++ b/scripts/release/release_tool.py @@ -3,8 +3,9 @@ Subcommands: parse-tag TAG validate `//v` and emit its parts - check-version-policy enforce the version policy against the production registry, and - refuse a version that already exists on TestPyPI (uploads are immutable) + check-version-policy enforce the version policy against the production registry (and, + with --check-testpypi, warn when the version is already staged there) + verify-index-files prove the files an index serves for a version are the local artifacts release-notes generate release notes from commits touching the plugin dir draft-release create/update an idempotent draft GitHub Release with assets @@ -20,11 +21,13 @@ from __future__ import annotations import argparse +import hashlib import json import os import re import subprocess import sys +import time import tomllib import urllib.error import urllib.request @@ -37,6 +40,10 @@ "pypi": "https://pypi.org/pypi/{coordinate}/json", "testpypi": "https://test.pypi.org/pypi/{coordinate}/json", } +RELEASE_JSON = { + "pypi": "https://pypi.org/pypi/{coordinate}/{version}/json", + "testpypi": "https://test.pypi.org/pypi/{coordinate}/{version}/json", +} FIRST_VERSION = {"ga": Version("1.0.0"), "preview": Version("0.1.0"), "experimental": Version("0.1.0")} TRANSITION_MARKER = "TRANSITION(sdk-cutover)" DEFAULT_REPO = "temporalio/ai-integrations" @@ -84,6 +91,7 @@ def parse_tag(tag: str) -> dict[str, str]: if str(version) != raw: raise PolicyError(f"tag version {raw!r} is not canonical PEP 440 (expected {version})") return { + "tag": tag, "language": m.group("language"), "plugin": m.group("plugin"), "version": raw, @@ -108,22 +116,24 @@ def cmd_parse_tag(args: argparse.Namespace) -> int: # --------------------------------------------------------------------------- policy +def _fetch_json(url: str, local: Path | None = None) -> dict | None: + """GET a registry JSON document; 404 -> None; anything else fails closed. `local` replaces the network (tests).""" + if local is not None: + return json.loads(local.read_text(encoding="utf-8")) if local.is_file() else None + try: + with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310 + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return None + raise PolicyError(f"registry returned HTTP {exc.code} for {url}; refusing to guess (fail closed)") from exc + except (urllib.error.URLError, TimeoutError) as exc: + raise PolicyError(f"registry unreachable ({exc}); refusing to guess (fail closed)") from exc + + def fetch_published_versions(coordinate: str, registry: str, registry_json: Path | None = None) -> list[Version]: """Return every published version (yanked included). 404 -> []. Anything else fails closed.""" - if registry_json is not None: - data = json.loads(registry_json.read_text(encoding="utf-8")) if registry_json.is_file() else None - else: - url = REGISTRY_JSON[registry].format(coordinate=coordinate) - try: - with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310 - data = json.loads(resp.read().decode("utf-8")) - except urllib.error.HTTPError as exc: - if exc.code == 404: - data = None - else: - raise PolicyError(f"registry returned HTTP {exc.code} for {url}; refusing to guess (fail closed)") from exc - except (urllib.error.URLError, TimeoutError) as exc: - raise PolicyError(f"registry unreachable ({exc}); refusing to guess (fail closed)") from exc + data = _fetch_json(REGISTRY_JSON[registry].format(coordinate=coordinate), registry_json) if not data: return [] versions: list[Version] = [] @@ -152,10 +162,13 @@ def check_policy(version: Version, maturity: str, published: list[Version]) -> N def transition_markers(repo_root: Path, plugin_dir: str) -> list[str]: """List plugin files containing the transition marker. Fails closed on any git error.""" - out = subprocess.run( - ["git", "grep", "-l", TRANSITION_MARKER, "--", plugin_dir], - capture_output=True, text=True, cwd=repo_root, - ) + try: + out = subprocess.run( + ["git", "grep", "-l", TRANSITION_MARKER, "--", plugin_dir], + capture_output=True, text=True, cwd=repo_root, + ) + except FileNotFoundError as exc: + raise PolicyError(f"git is required to check for {TRANSITION_MARKER} markers: {exc}") from exc # git grep exits 1 for "no match"; anything else (not a repo, bad pathspec, ...) is an error # and must not be mistaken for "no markers". if out.returncode not in (0, 1): @@ -173,14 +186,17 @@ def cmd_check_version_policy(args: argparse.Namespace) -> int: published = fetch_published_versions(coordinate, registry, Path(args.registry_json) if args.registry_json else None) check_policy(version, maturity, published) print(f"OK: {coordinate} {version} satisfies the version policy (published: {[str(v) for v in sorted(published)] or 'none'})") - if registry == "pypi": - # Every release is uploaded to TestPyPI first, and uploads are immutable: a version that is - # already there would be skipped (skip-existing) and the smoke test would validate stale - # bytes while reporting success. Fix forward with the next rcN instead. + if registry == "pypi" and (args.check_testpypi or args.testpypi_json): + # Every release is staged on TestPyPI first and uploads are immutable, so a re-run finds the + # version already there and skip-existing keeps the upload from failing. That is the normal + # recovery path (a rejected environment approval, a flaky smoke), so only warn here; the + # smoke job proves the served files are this run's artifacts (verify-index-files). staged = fetch_published_versions(coordinate, "testpypi", Path(args.testpypi_json) if args.testpypi_json else None) if version in staged: - raise PolicyError(f"{coordinate} {version} already exists on TestPyPI; uploads are immutable, use the next pre-release number") - print(f"OK: {coordinate} {version} is not yet on TestPyPI") + print(f"::warning::{coordinate} {version} already exists on TestPyPI: the TestPyPI upload will be skipped " + "and smoke-testpypi verifies that the served files are this run's artifacts") + else: + print(f"OK: {coordinate} {version} is not yet on TestPyPI") if not version.is_prerelease: if not meta.get("release", {}).get("allow-final"): raise PolicyError( @@ -195,6 +211,62 @@ def cmd_check_version_policy(args: argparse.Namespace) -> int: return 0 +# --------------------------------------------------------------------------- index files + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def index_release_files(coordinate: str, version: str, registry: str, index_json: Path | None = None) -> dict[str, str]: + """Return {filename: sha256} for one version on the index (empty when the version is absent).""" + data = _fetch_json(RELEASE_JSON[registry].format(coordinate=coordinate, version=version), index_json) + if not data: + return {} + return {entry["filename"]: entry["digests"]["sha256"] for entry in data.get("urls", [])} + + +def verify_index_files(coordinate: str, version: str, registry: str, dist: Path, *, attempts: int, delay: float, index_json: Path | None = None) -> None: + """Fail unless every local distribution file is served by the index with an identical sha256. + + Uploads are immutable and the publish step skips files that already exist, so this is what + makes "the published bytes are the tested bytes" true on a re-run as well as on the first run. + """ + local = {path.name: _sha256(path) for path in sorted(dist.iterdir()) if path.is_file()} + if not local: + raise PolicyError(f"no distribution files in {dist}") + remote: dict[str, str] = {} + for attempt in range(1, attempts + 1): + remote = index_release_files(coordinate, version, registry, index_json) + missing = sorted(set(local) - set(remote)) + if not missing: + break + if attempt == attempts: + raise PolicyError(f"{registry} does not serve {missing} for {coordinate} {version} after {attempts} attempts") + print(f"{missing} not yet on {registry}; waiting for index propagation (attempt {attempt}/{attempts})") + time.sleep(delay) + mismatched = sorted(name for name, digest in local.items() if remote[name] != digest) + if mismatched: + raise PolicyError( + f"{registry} serves different bytes than this run built for {mismatched}: an earlier upload of " + f"{coordinate} {version} is what users get. Uploads are immutable; fix forward with the next version" + ) + for name in sorted(local): + print(f"OK: {name} on {registry} matches the tested artifact (sha256 {local[name][:12]}...)") + + +def cmd_verify_index_files(args: argparse.Namespace) -> int: + verify_index_files( + args.coordinate, args.version, args.index, Path(args.dist), + attempts=args.attempts, delay=args.delay, index_json=Path(args.index_json) if args.index_json else None, + ) + return 0 + + # --------------------------------------------------------------------------- release notes PR_REF = re.compile(r"(? int: raise PolicyError("release was created but could not be found afterwards") print(f"created draft release {release_id} for {args.tag}") else: + existing = json.loads(_gh("api", f"repos/{repo}/releases/{release_id}")) + if not existing.get("draft"): + # Runbook step 4 publishes the draft by hand; a later re-run must not un-publish it or + # swap its assets. + raise PolicyError(f"the release for {args.tag} is already published; refusing to modify it") body = Path(args.notes).read_text(encoding="utf-8") _gh("api", "-X", "PATCH", f"repos/{repo}/releases/{release_id}", "-f", f"name={args.title}", "-F", "draft=true", "-F", f"prerelease={'true' if args.prerelease else 'false'}", "-f", f"body={body}") print(f"updated existing release {release_id} for {args.tag}") assets = [str(path) for path in sorted(Path(args.dist).iterdir()) if path.is_file()] if assets: - # gh streams the binaries itself and --clobber replaces same-name assets atomically, - # instead of a delete-then-POST through `gh api --input`. + # gh resolves a draft by its pending tag and streams the binaries itself. --clobber deletes a + # same-name asset before re-uploading it (not atomic), which is fine for a draft nobody has + # downloaded yet; the published-release guard above keeps it away from anything final. _gh("release", "upload", args.tag, *assets, "--repo", repo, "--clobber") for asset in assets: print(f"uploaded {Path(asset).name}") @@ -352,9 +430,20 @@ def main(argv: list[str] | None = None) -> int: p.add_argument("--plugin-dir", required=True) p.add_argument("--version", required=True) p.add_argument("--registry-json", default=None, help="read published versions from this file instead of the registry (tests)") + p.add_argument("--check-testpypi", action="store_true", help="also warn when the version is already staged on TestPyPI") p.add_argument("--testpypi-json", default=None, help="read TestPyPI versions from this file instead of the registry (tests)") p.set_defaults(func=cmd_check_version_policy) + p = sub.add_parser("verify-index-files", help="fail unless the index serves exactly the local distribution files") + p.add_argument("--coordinate", required=True) + p.add_argument("--version", required=True) + p.add_argument("--dist", required=True) + p.add_argument("--index", choices=sorted(RELEASE_JSON), default="testpypi") + p.add_argument("--attempts", type=int, default=10, help="index propagation retries (default 10)") + p.add_argument("--delay", type=float, default=30.0, help="seconds between retries (default 30)") + p.add_argument("--index-json", default=None, help="read the release JSON from this file instead of the index (tests)") + p.set_defaults(func=cmd_verify_index_files) + p = sub.add_parser("release-notes", help="generate release notes from history") p.add_argument("--plugin-dir", required=True) p.add_argument("--tag", required=True) diff --git a/scripts/tests/test_release_tool.py b/scripts/tests/test_release_tool.py index cc6d789..dfbde1b 100644 --- a/scripts/tests/test_release_tool.py +++ b/scripts/tests/test_release_tool.py @@ -1,6 +1,8 @@ from __future__ import annotations +import hashlib import json +import subprocess from pathlib import Path import pytest @@ -12,7 +14,8 @@ def test_parse_tag_valid() -> None: assert release_tool.parse_tag("python/openai_agents/v1.0.0rc1") == { - "language": "python", "plugin": "openai_agents", "version": "1.0.0rc1", "prerelease": "true", "plugin_dir": "python/openai_agents", + "tag": "python/openai_agents/v1.0.0rc1", "language": "python", "plugin": "openai_agents", "version": "1.0.0rc1", + "prerelease": "true", "plugin_dir": "python/openai_agents", } assert release_tool.parse_tag("go/googleadk/v0.3.0")["prerelease"] == "false" assert release_tool.parse_tag("typescript/vercel-ai-sdk/v1.0.0.dev1")["prerelease"] == "true" @@ -58,7 +61,7 @@ def _registry(tmp_path: Path, versions: list[str] | None) -> Path: def test_cli_policy_prerelease_with_no_published_versions(plugin_repo: Path, tmp_path: Path) -> None: reg = _registry(tmp_path, None) rc = release_tool.main(["--repo-root", str(plugin_repo), "check-version-policy", "--plugin-dir", str(plugin_repo / "python/fakeplug"), - "--version", "0.1.0rc1", "--registry-json", str(reg)]) + "--version", "0.1.0rc1", "--registry-json", str(reg), "--testpypi-json", str(tmp_path / "absent.json")]) assert rc == 0 @@ -74,33 +77,100 @@ def test_cli_policy_final_blocked_by_transition_markers(repo: Path, tmp_path: Pa (d / "src/temporalio/contrib/fakeplug/_impl.py").write_text("# TRANSITION(sdk-cutover): remove\nVALUE = 1\n") commit_all(repo, "plugin") reg = _registry(tmp_path, None) - rc = release_tool.main(["--repo-root", str(repo), "check-version-policy", "--plugin-dir", str(d), "--version", "0.1.0", "--registry-json", str(reg)]) + policy = ["--repo-root", str(repo), "check-version-policy", "--plugin-dir", str(d), "--version", "0.1.0", "--registry-json", str(reg), + "--testpypi-json", str(tmp_path / "absent.json")] + rc = release_tool.main(policy) out = capsys.readouterr().out assert rc == 1 and "TRANSITION(sdk-cutover)" in out (d / "src/temporalio/contrib/fakeplug/_impl.py").write_text("VALUE = 1\n") commit_all(repo, "clean") - assert release_tool.main(["--repo-root", str(repo), "check-version-policy", "--plugin-dir", str(d), "--version", "0.1.0", "--registry-json", str(reg)]) == 0 + assert release_tool.main(policy) == 0 -def test_cli_policy_rejects_a_version_already_on_testpypi(plugin_repo: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: +def test_cli_policy_warns_when_the_version_is_already_on_testpypi(plugin_repo: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: reg = _registry(tmp_path, None) staged = tmp_path / "testpypi.json" staged.write_text(json.dumps({"releases": {"0.1.0rc1": []}})) args = ["--repo-root", str(plugin_repo), "check-version-policy", "--plugin-dir", str(plugin_repo / "python/fakeplug"), "--registry-json", str(reg), "--testpypi-json", str(staged)] - assert release_tool.main([*args, "--version", "0.1.0rc1"]) == 1 - assert "already exists on TestPyPI" in capsys.readouterr().out + # A re-run after a staged upload is the normal recovery path, so this is a warning, not a failure. + assert release_tool.main([*args, "--version", "0.1.0rc1"]) == 0 + assert "::warning::" in capsys.readouterr().out assert release_tool.main([*args, "--version", "0.1.0rc2"]) == 0 + assert "not yet on TestPyPI" in capsys.readouterr().out -def test_transition_markers_fail_closed_outside_a_repository(tmp_path: Path) -> None: +def test_cli_policy_does_not_touch_testpypi_unless_asked(plugin_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def boom(*_args, **_kwargs): + raise AssertionError("network access") + + monkeypatch.setattr(release_tool.urllib.request, "urlopen", boom) + reg = _registry(tmp_path, None) + assert release_tool.main(["--repo-root", str(plugin_repo), "check-version-policy", "--plugin-dir", str(plugin_repo / "python/fakeplug"), + "--version", "0.1.0rc1", "--registry-json", str(reg)]) == 0 + + +def test_transition_markers_fail_closed_on_git_errors(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + def broken(*_args, **_kwargs): + return subprocess.CompletedProcess(args=[], returncode=128, stdout="", stderr="fatal: not a git repository") + + monkeypatch.setattr(release_tool.subprocess, "run", broken) with pytest.raises(release_tool.PolicyError, match="git grep"): release_tool.transition_markers(tmp_path, "python/fakeplug") + def missing(*_args, **_kwargs): + raise FileNotFoundError("git") + + monkeypatch.setattr(release_tool.subprocess, "run", missing) + with pytest.raises(release_tool.PolicyError, match="git is required"): + release_tool.transition_markers(tmp_path, "python/fakeplug") + + +def _dist(tmp_path: Path) -> tuple[Path, dict[str, str]]: + dist = tmp_path / "dist" + dist.mkdir() + digests = {} + for name, payload in (("fakeplug-0.1.0-py3-none-any.whl", b"wheel"), ("fakeplug-0.1.0.tar.gz", b"sdist")): + (dist / name).write_bytes(payload) + digests[name] = hashlib.sha256(payload).hexdigest() + return dist, digests + + +def _index_json(tmp_path: Path, digests: dict[str, str]) -> Path: + f = tmp_path / "release.json" + f.write_text(json.dumps({"urls": [{"filename": n, "digests": {"sha256": d}} for n, d in digests.items()]})) + return f + + +def test_verify_index_files_accepts_identical_digests(tmp_path: Path) -> None: + dist, digests = _dist(tmp_path) + assert release_tool.main(["verify-index-files", "--coordinate", "temporalio-fakeplug", "--version", "0.1.0", "--dist", str(dist), + "--index", "testpypi", "--attempts", "1", "--delay", "0", "--index-json", str(_index_json(tmp_path, digests))]) == 0 + + +def test_verify_index_files_rejects_different_bytes(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + dist, digests = _dist(tmp_path) + digests["fakeplug-0.1.0.tar.gz"] = hashlib.sha256(b"someone else's sdist").hexdigest() + assert release_tool.main(["verify-index-files", "--coordinate", "temporalio-fakeplug", "--version", "0.1.0", "--dist", str(dist), + "--attempts", "1", "--delay", "0", "--index-json", str(_index_json(tmp_path, digests))]) == 1 + assert "different bytes" in capsys.readouterr().out + + +def test_verify_index_files_rejects_missing_files(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + dist, digests = _dist(tmp_path) + digests.pop("fakeplug-0.1.0.tar.gz") + assert release_tool.main(["verify-index-files", "--coordinate", "temporalio-fakeplug", "--version", "0.1.0", "--dist", str(dist), + "--attempts", "2", "--delay", "0", "--index-json", str(_index_json(tmp_path, digests))]) == 1 + assert "does not serve" in capsys.readouterr().out + # An absent release (404) is reported the same way rather than as a crash. + assert release_tool.main(["verify-index-files", "--coordinate", "temporalio-fakeplug", "--version", "0.1.0", "--dist", str(dist), + "--attempts", "1", "--delay", "0", "--index-json", str(tmp_path / "missing.json")]) == 1 + def test_cli_policy_existing_versions(plugin_repo: Path, tmp_path: Path) -> None: reg = _registry(tmp_path, ["0.1.0", "0.2.0"]) - args = ["--repo-root", str(plugin_repo), "check-version-policy", "--plugin-dir", str(plugin_repo / "python/fakeplug"), "--registry-json", str(reg)] + args = ["--repo-root", str(plugin_repo), "check-version-policy", "--plugin-dir", str(plugin_repo / "python/fakeplug"), "--registry-json", str(reg), + "--testpypi-json", str(tmp_path / "absent.json")] assert release_tool.main([*args, "--version", "0.3.0rc1"]) == 0 assert release_tool.main([*args, "--version", "0.2.0"]) == 1 assert release_tool.main([*args, "--version", "0.1.5"]) == 1 diff --git a/scripts/tests/test_workflows.py b/scripts/tests/test_workflows.py index 21e9992..8913cef 100644 --- a/scripts/tests/test_workflows.py +++ b/scripts/tests/test_workflows.py @@ -71,19 +71,49 @@ def test_release_publish_jobs_are_inline_and_oidc_only() -> None: assert doc[True]["push"]["tags"] == ["python/*/v*"] # PyYAML parses the `on` key as boolean True inputs = doc[True]["workflow_dispatch"]["inputs"] assert inputs["skip-publish"]["type"] == "boolean" and inputs["tag"]["type"] == "string" + # A dispatch on a tag ref with a different tag input must be rejected before anything runs. + prepare_steps = [s.get("name", "") for s in doc["jobs"]["prepare"]["steps"]] + assert prepare_steps.index("Dispatch inputs are consistent with the ref") < prepare_steps.index("Parse tag") + gate = next(s for s in doc["jobs"]["prepare"]["steps"] if s.get("id") == "gate") + assert '[ "$REF_TYPE" = "tag" ] && [ "$SKIP_PUBLISH" = "false" ]' in gate["run"], "publish only on the literal false" + # The release job must act on the parsed tag, never on the ref name (they differ on a dry run). + release_text = yaml.dump(doc["jobs"]["github-release"]) + assert "github.ref_name" not in release_text and "needs.prepare.outputs.tag" in release_text + assert doc["concurrency"]["group"] == "release-${{ inputs.tag || github.ref }}" + assert doc["jobs"]["github-release"]["if"].lstrip().startswith("!cancelled()") + + +def test_release_smoke_is_strict_unless_the_sdk_still_bundles_the_plugin() -> None: + doc = yaml.safe_load((REPO / ".github/workflows/release-python.yml").read_text()) + # smoke.py: "0" is strict; only allow-final = false (the plugin the SDK still ships) may overlap. + expected = "${{ needs.prepare.outputs.allow_final == 'true' && '0' || '1' }}" + for job in ("smoke-testpypi", "smoke-pypi"): + smoke = [s for s in doc["jobs"][job]["steps"] if "smoke_from_index.sh" in s.get("run", "")] + assert len(smoke) == 1 and smoke[0]["env"]["ALLOW_OVERLAP_WITH_CORE"] == expected, job + verify = [s for s in doc["jobs"][job]["steps"] if "verify-index-files" in s.get("run", "")] + assert len(verify) == 1, f"{job} must prove the index serves the tested artifacts" + index = job.split("-", 1)[1] + assert f"--index {index}" in verify[0]["run"] + assert doc["jobs"]["prepare"]["outputs"]["allow_final"] == "${{ steps.tag.outputs.allow_final }}" def test_release_publishes_the_tested_artifacts() -> None: doc = yaml.safe_load((REPO / ".github/workflows/release-python.yml").read_text()) assert "build" not in doc["jobs"], "the test job's dist cell builds the artifacts; do not rebuild for publishing" - tested = "dist-${{ needs.prepare.outputs.plugin }}" - for job in ("publish-testpypi", "publish-pypi", "github-release"): + tested = "dist-${{ needs.prepare.outputs.plugin }}-locked" + for job in ("publish-testpypi", "publish-pypi", "smoke-testpypi", "smoke-pypi", "github-release"): downloads = [s["with"]["name"] for s in doc["jobs"][job]["steps"] if "download-artifact" in s.get("uses", "")] - assert downloads == [tested], f"{job} must publish the artifact the test job produced" + assert downloads == [tested], f"{job} must use the artifact the test job produced" assert "test" in doc["jobs"]["publish-testpypi"]["needs"] + assert doc["jobs"]["test"]["with"]["deps"] == "locked" plugin_wf = yaml.safe_load((REPO / ".github/workflows/_python-plugin.yml").read_text()) - upload = [s for s in plugin_wf["jobs"]["test"]["steps"] if "upload-artifact" in s.get("uses", "") and s["with"]["name"].startswith("dist-")] - assert upload and upload[0]["with"]["name"] == "dist-${{ inputs.plugin }}" + steps = plugin_wf["jobs"]["test"]["steps"] + upload = next(s for s in steps if "upload-artifact" in s.get("uses", "") and s["with"]["name"].startswith("dist-")) + assert upload["with"]["name"] == "dist-${{ inputs.plugin }}-${{ inputs.deps }}" + assert upload["if"] == "matrix.dist" and upload["with"]["if-no-files-found"] == "error" + build_check = next(s for s in steps if s.get("uses") == "./.github/actions/python-build-check") + assert build_check["if"] == "matrix.dist" + assert steps.index(build_check) < steps.index(upload), "the artifact must be built and checked before it is uploaded" def test_release_checkouts_do_not_persist_credentials() -> None: @@ -91,7 +121,7 @@ def test_release_checkouts_do_not_persist_credentials() -> None: for name, job in doc["jobs"].items(): for step in job.get("steps", []): if "actions/checkout@" in step.get("uses", ""): - assert step["with"].get("persist-credentials") is False, f"{name}: checkout must set persist-credentials: false" + assert step.get("with", {}).get("persist-credentials") is False, f"{name}: checkout must set persist-credentials: false" def test_top_level_permissions_are_empty_or_read_only() -> None: