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 2d1ad35..f7344d5 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 @@ -28,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: @@ -46,6 +59,9 @@ 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 }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -55,10 +71,23 @@ 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: - 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,23 +105,37 @@ 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 }} 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: 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. + # 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" + 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 +154,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--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 environment: testpypi permissions: @@ -143,7 +167,7 @@ jobs: steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: dist + name: dist-${{ needs.prepare.outputs.plugin }}-locked path: dist/ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: @@ -155,6 +179,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 @@ -165,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 }} @@ -172,13 +208,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 +223,7 @@ jobs: steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: dist + name: dist-${{ needs.prepare.outputs.plugin }}-locked path: dist/ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: @@ -197,7 +234,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 @@ -208,21 +245,31 @@ 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 }} 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' + !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 permissions: @@ -231,23 +278,24 @@ 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 }}-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 3a9dcfb..b0a2e36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,14 +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 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`; 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, 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. -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 f6085c3..3f84b17 100755 --- a/scripts/release/release_tool.py +++ b/scripts/release/release_tool.py @@ -3,7 +3,9 @@ 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, + 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 @@ -19,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 @@ -32,7 +36,14 @@ 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", +} +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" @@ -80,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, @@ -104,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] = [] @@ -147,13 +161,18 @@ 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.""" try: out = subprocess.run( ["git", "grep", "-l", TRANSITION_MARKER, "--", plugin_dir], capture_output=True, text=True, cwd=repo_root, ) - except FileNotFoundError: - return [] + 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): + 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 +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" 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: + 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( @@ -181,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"(? 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 +332,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) @@ -301,22 +392,23 @@ def cmd_draft_release(args: argparse.Namespace) -> 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}") - 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 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}") print(f"OK: draft release ready: https://github.com/{repo}/releases/tag/{args.tag}") return 0 @@ -338,8 +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 a4a40e4..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,17 +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_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)] + # 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_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 @@ -97,6 +183,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 +201,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..8913cef 100644 --- a/scripts/tests/test_workflows.py +++ b/scripts/tests/test_workflows.py @@ -64,8 +64,64 @@ 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" + # 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 }}-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 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()) + 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: + 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.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: