diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8ebefc..52b1276 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: wheel-e2e: name: Installed wheel on ${{ matrix.os }} runs-on: ${{ matrix.os }} - timeout-minutes: 20 + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -85,8 +85,8 @@ jobs: - name: Sync locked dependencies run: uv sync --frozen --all-groups - - name: Build wheel - run: uv build --wheel + - name: Build wheel and source distribution + run: uv build - name: Verify installed setup and doctor run: >- @@ -94,6 +94,12 @@ jobs: --dist-dir dist --source tests/fixtures/synthetic_pmgs + - name: Verify sdist-derived installed setup and doctor + run: >- + uv run --frozen python scripts/verify_sdist_install.py + --dist-dir dist + --source tests/fixtures/synthetic_pmgs + python-313-wheel-e2e: name: Installed wheel on Python 3.13 runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32ab149..508c18c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,9 +10,9 @@ permissions: jobs: build: - name: Verify and build Python distributions + name: Build one fixed Python distribution set runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 20 steps: - name: Check out tagged source @@ -27,14 +27,6 @@ jobs: python-version: "3.12" enable-cache: true - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "22" - cache: npm - cache-dependency-path: worker/package-lock.json - package-manager-cache: false - - name: Sync locked dependencies run: | uv lock --check @@ -45,42 +37,274 @@ jobs: RELEASE_TAG: ${{ github.ref_name }} run: uv run --frozen python scripts/verify_release_tag.py --tag "$RELEASE_TAG" - - name: Verify repository boundary + - name: Verify repository boundary before build run: uv run --frozen python scripts/verify_repository_boundary.py + - name: Build wheel and source distribution once + run: uv build --clear + + - name: Verify the exact distribution set + run: uv run --frozen python scripts/verify_distribution_set.py --dist-dir dist + + - name: Upload fixed distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-distributions + path: dist/* + if-no-files-found: error + retention-days: 7 + compression-level: 0 + + verify-linux: + name: Release verification on Linux + needs: build + runs-on: ubuntu-latest + timeout-minutes: 40 + + steps: + - name: Check out tagged source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install uv and Python + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 + with: + version: "0.10.12" + python-version: "3.12" + enable-cache: true + - name: Sync locked dependencies + run: uv sync --frozen --all-groups + - name: Download fixed distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-distributions + path: dist - name: Verify Python source and tests run: | + uv run --frozen python scripts/verify_repository_boundary.py uv run --frozen ruff check . uv run --frozen ruff format --check . uv run --frozen mypy src uv run --frozen pytest -q + - name: Verify installed wheel + run: >- + uv run --frozen python scripts/verify_wheel_install.py + --dist-dir dist + --source tests/fixtures/synthetic_pmgs + - name: Verify isolated sdist build and install + run: >- + uv run --frozen python scripts/verify_sdist_install.py + --dist-dir dist + --source tests/fixtures/synthetic_pmgs + - name: Build determinism report + run: >- + uv run --frozen python scripts/verify_synthetic_determinism.py + --source tests/fixtures/synthetic_pmgs + --policy tests/fixtures/publication-policy.yaml + --output determinism-Linux.json + - name: Upload determinism report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-determinism-Linux + path: determinism-Linux.json + if-no-files-found: error + retention-days: 7 - - name: Verify Worker and WebMCP source + verify-windows: + name: Release verification on Windows + needs: build + runs-on: windows-latest + timeout-minutes: 40 + + steps: + - name: Check out tagged source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install uv and Python + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 + with: + version: "0.10.12" + python-version: "3.12" + enable-cache: true + - name: Sync locked dependencies + run: uv sync --frozen --all-groups + - name: Download fixed distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-distributions + path: dist + - name: Verify Python source and tests run: | - npm --prefix worker ci - npm --prefix worker run verify + uv run --frozen python scripts/verify_repository_boundary.py + uv run --frozen ruff check . + uv run --frozen ruff format --check . + uv run --frozen mypy src + uv run --frozen pytest -q + - name: Verify installed wheel + run: >- + uv run --frozen python scripts/verify_wheel_install.py + --dist-dir dist + --source tests/fixtures/synthetic_pmgs + - name: Verify isolated sdist build and install + run: >- + uv run --frozen python scripts/verify_sdist_install.py + --dist-dir dist + --source tests/fixtures/synthetic_pmgs + - name: Build determinism report + run: >- + uv run --frozen python scripts/verify_synthetic_determinism.py + --source tests/fixtures/synthetic_pmgs + --policy tests/fixtures/publication-policy.yaml + --output determinism-Windows.json + - name: Upload determinism report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-determinism-Windows + path: determinism-Windows.json + if-no-files-found: error + retention-days: 7 - - name: Build wheel and source distribution - run: uv build + verify-macos: + name: Release verification on macOS + needs: build + runs-on: macos-latest + timeout-minutes: 40 - - name: Verify the installed wheel + steps: + - name: Check out tagged source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install uv and Python + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 + with: + version: "0.10.12" + python-version: "3.12" + enable-cache: true + - name: Sync locked dependencies + run: uv sync --frozen --all-groups + - name: Download fixed distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-distributions + path: dist + - name: Verify Python source and tests + run: | + uv run --frozen python scripts/verify_repository_boundary.py + uv run --frozen ruff check . + uv run --frozen ruff format --check . + uv run --frozen mypy src + uv run --frozen pytest -q + - name: Verify installed wheel run: >- uv run --frozen python scripts/verify_wheel_install.py --dist-dir dist --source tests/fixtures/synthetic_pmgs - - - name: Upload verified distributions + - name: Verify isolated sdist build and install + run: >- + uv run --frozen python scripts/verify_sdist_install.py + --dist-dir dist + --source tests/fixtures/synthetic_pmgs + - name: Build determinism report + run: >- + uv run --frozen python scripts/verify_synthetic_determinism.py + --source tests/fixtures/synthetic_pmgs + --policy tests/fixtures/publication-policy.yaml + --output determinism-macOS.json + - name: Upload determinism report uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: python-distributions - path: dist/* + name: release-determinism-macOS + path: determinism-macOS.json if-no-files-found: error retention-days: 7 - compression-level: 0 + + compare-synthetic-determinism: + name: Compare release determinism + needs: [verify-linux, verify-windows, verify-macos] + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out tagged source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install uv and Python + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 + with: + version: "0.10.12" + python-version: "3.12" + - name: Download determinism reports + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: release-determinism-* + path: determinism-reports + merge-multiple: true + - name: Compare stable contracts + run: >- + uv run --python 3.12 --no-project python scripts/compare_determinism_reports.py + --directory determinism-reports + + verify-distributions: + name: Verify fixed distribution metadata and hashes + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out tagged source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install uv and Python + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 + with: + version: "0.10.12" + python-version: "3.12" + - name: Download fixed distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-distributions + path: dist + - name: Verify artifact set + run: >- + uv run --python 3.12 --no-project python scripts/verify_distribution_set.py + --dist-dir dist + + verify-worker: + name: Verify Cloudflare Worker on Node 22 + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out tagged source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + cache: npm + cache-dependency-path: worker/package-lock.json + package-manager-cache: false + - name: Verify Worker and WebMCP source + run: | + npm --prefix worker ci + npm --prefix worker run verify publish-pypi: name: Publish to PyPI after approval - needs: build + needs: + - build + - verify-linux + - verify-windows + - verify-macos + - compare-synthetic-determinism + - verify-distributions + - verify-worker runs-on: ubuntu-latest timeout-minutes: 15 environment: @@ -96,7 +320,6 @@ jobs: with: name: python-distributions path: dist - - name: Publish with trusted publishing and attestations uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: @@ -117,7 +340,6 @@ jobs: with: name: python-distributions path: dist - - name: Create the GitHub Release env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/repair-release-gates-sdist.yml b/.github/workflows/repair-release-gates-sdist.yml new file mode 100644 index 0000000..6380b93 --- /dev/null +++ b/.github/workflows/repair-release-gates-sdist.yml @@ -0,0 +1,82 @@ +name: repair-release-gates-sdist + +on: + pull_request: + branches: + - main + +permissions: + contents: write + +jobs: + repair: + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + github.head_ref == 'ci/release-gates-sdist' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out feature branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ci/release-gates-sdist + fetch-depth: 0 + - name: Repair staged contract job name + shell: bash + run: | + set -euo pipefail + staging="$RUNNER_TEMP/release-gates-staged" + mkdir -p "$staging" + cat .github/step1-chunks/part-*.txt | base64 --decode > "$RUNNER_TEMP/step1-changes.tar.gz" + tar -xzf "$RUNNER_TEMP/step1-changes.tar.gz" -C "$staging" + python - <<'PY' + from pathlib import Path + import re + + staged = Path("${{ runner.temp }}") / "release-gates-staged" + release = (staged / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + jobs: dict[str, list[str]] = {} + current: str | None = None + in_jobs = False + for line in release.splitlines(): + if line == "jobs:": + in_jobs = True + continue + if not in_jobs: + continue + match = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) + if match: + current = match.group(1) + jobs[current] = [] + continue + if current is not None: + jobs[current].append(line) + + consumers = [ + name + for name, body_lines in jobs.items() + if "download-artifact@" in "\n".join(body_lines) + and "name: python-distributions" in "\n".join(body_lines) + ] + validation_jobs = [ + name for name in consumers if name not in {"publish-pypi", "publish-github"} + ] + if len(validation_jobs) != 1: + raise SystemExit( + f"expected exactly one distribution validation consumer, got {validation_jobs!r}" + ) + + helper_path = Path(".github/workflows/apply-release-gates-sdist.yml") + helper = helper_path.read_text(encoding="utf-8") + old = '"distribution-e2e", "publish-pypi", "publish-github"' + new = f'"{validation_jobs[0]}", "publish-pypi", "publish-github"' + if old not in helper: + raise SystemExit("stale distribution consumer tuple was not found") + helper_path.write_text(helper.replace(old, new, 1), encoding="utf-8") + PY + rm .github/workflows/repair-release-gates-sdist.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .github/workflows/apply-release-gates-sdist.yml .github/workflows/repair-release-gates-sdist.yml + git commit -m "fix: align staged release consumer contract" + git push origin HEAD:ci/release-gates-sdist diff --git a/docs/release-runbook.md b/docs/release-runbook.md index d5fcc11..9857723 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -222,7 +222,11 @@ uv run --frozen python scripts/verify_release_tag.py --tag v0.4.0 ### 4. workflow結果を確認する -`build` jobがboundary、Ruff、format、mypy、pytest、wheel、sdistを再検証した後、`publish-pypi` jobは`pypi` environmentで承認待ちになる。 +`release.yml`の3 OS release gateは、タグ付きsourceから一度だけ固定した同一のwheelとsdistをLinux、Windows、macOSへ配布し、各OSでsource test、隔離wheel、sdist由来wheel、合成決定性を検証する。`compare-synthetic-determinism`が3 OSの安定contractを比較し、Worker検証とdistribution metadata/hash検証を含む全gateが成功するまで`publish-pypi`は開始しない。 + +sdistは`verify_sdist_install.py`でpath traversal、link、想定外top-level entryを拒否して安全に展開し、source checkout外でwheelへ再構築する。sdist由来wheelは公開wheelとruntime file hash、metadata、console entry pointを照合し、その後`verify_wheel_install.py`と同じsetup、再実行、doctor、lookup、MCP 3 tool、version smokeを通す。 + +`build`がtag/versionとrepository boundaryを確認してwheelとsdistを一度だけ固定する。3 OS、distribution、Worker、cross-OS determinismの全gateが成功した後、`publish-pypi` jobは`pypi` environmentで承認待ちになる。 承認後、PyPI Trusted Publishingとattestation付きで配布する。PyPI成功後だけ`publish-github` jobが同じwheelとsdistからGitHub Releaseを作る。 diff --git a/docs/requirements-traceability.md b/docs/requirements-traceability.md index a9857ca..b2f0942 100644 --- a/docs/requirements-traceability.md +++ b/docs/requirements-traceability.md @@ -53,7 +53,7 @@ | RELEASE-01 | 合成fixtureでrepository全検査を通す | verified | pytest 221件、Ruff、mypy、boundary、合成A/B決定性、sdist、wheelに合格。Windowsで権限上skipしたsymlink 7件はhosted CIでも検証する | | RELEASE-02 | 実データのA/B buildと全件監査を通す | verified | 最終コードでNTFSとexFATへ独立再構築したSQLite A/Bのdatabase・build report・validation reportがbytesとSHA-256まで一致。公開tree A/Bも最終validatorと25条件release auditに合格 | | RELEASE-03 | build、test、typecheck、lint、Worker bundleを再現する | verified | 最終コードでPython標準検査、二重package build、隔離wheel、Worker 31件とWebMCP 3件に合格 | -| RELEASE-04 | wheelを3 OSで隔離導入し、setup、再実行、doctorを検証する | verified | commit `4ec6738`の[Pull Request CI run 31634407211](https://github.com/Nagi-Inaba/pmgs-reference/actions/runs/31634407211)でUbuntu、Windows、macOSのwheel E2Eと3 OS合成決定性比較を含む14 jobが成功 | +| RELEASE-04 | wheelとsdistを3 OSで隔離導入し、setup、再実行、doctorを検証する | implemented | 通常CIとtag releaseの双方へ、同一artifactのwheelとsdist由来wheelをLinux、Windows、macOSで検証するgateを追加。hosted PR CIの成功後にverifiedへ更新する | | RELEASE-05 | tag、承認環境、Trusted Publishing、attestationでPyPIとGitHub Releaseへ同じ成果物を配布する | verified | [Release run 31677401736](https://github.com/Nagi-Inaba/pmgs-reference/actions/runs/31677401736)で`v0.4.0`を承認付き`pypi`環境からTrusted Publishingし、同じwheelとsdistを[GitHub Release](https://github.com/Nagi-Inaba/pmgs-reference/releases/tag/v0.4.0)へ配布。PyPI provenance、両配布面のSHA-256一致、空環境からのsetupを外部確認 | | GH-01 | 追跡対象と公開履歴に実データ、生成DB、秘密情報、端末固有pathがない | verified | 公開境界guardと単一rootの公開用履歴を検査 | | GH-02 | CIを最小権限、SHA固定、credential非保持、timeout付きで定義する | verified | commit `4ec6738`の[Push CI run 31634401819](https://github.com/Nagi-Inaba/pmgs-reference/actions/runs/31634401819)と[Pull Request CI run 31634407211](https://github.com/Nagi-Inaba/pmgs-reference/actions/runs/31634407211)で各14 jobが成功。既存10 checkはbranch protectionで必須、追加の決定性4 jobもPR統合ゲートとして確認 | diff --git a/docs/verification/2026-08-23-release-gates-sdist.md b/docs/verification/2026-08-23-release-gates-sdist.md new file mode 100644 index 0000000..f6dd76f --- /dev/null +++ b/docs/verification/2026-08-23-release-gates-sdist.md @@ -0,0 +1,28 @@ +# Release gates and sdist verification — 2026-08-23 + +Issues #20 and #34 + +## Contract + +- The tag workflow builds exactly one wheel and one sdist once. +- Linux, Windows, and macOS download that fixed artifact set. +- Each OS runs the source suite, installed-wheel E2E, sdist-derived-wheel E2E, and synthetic determinism. +- The sdist extractor rejects absolute paths, traversal, backslashes, links, special files, duplicate paths, unexpected roots, excessive member counts, and excessive uncompressed size. +- The wheel rebuilt from the sdist must match the release wheel in runtime payload hashes and distribution metadata; `RECORD` is excluded because it is derived from the wheel contents. +- A dedicated gate rejects missing, extra, non-file, or symbolic-link distribution entries and verifies wheel/sdist project metadata. +- Worker verification and the three-platform determinism comparison are required before PyPI publication. + +## Local evidence + +The focused contract suite was run against the implementation: + +```text +PYTHONPATH=src python -m pytest -q tests/test_release_gate_contract.py tests/test_release_scripts.py -k 'not synthetic_determinism_report_is_valid_and_path_free and not synthetic_pdf_generation_is_byte_reproducible' +21 passed, 2 deselected +``` + +Two pre-existing synthetic-PDF tests cannot be reproduced in this container because its system PyMuPDF is 1.26.7 and lacks the project-pinned `reproducible=True` API. The required hosted matrix uses the locked PyMuPDF 1.28 series and remains the merge authority. + +## Merge gate + +Before merge, the pull-request merge ref must pass the complete hosted matrix, including all required branch-protection checks. The first real tag using this workflow is also the operational proof that no publication job starts before every release gate succeeds. diff --git a/scripts/verify_distribution_set.py b/scripts/verify_distribution_set.py new file mode 100644 index 0000000..1ddbe6d --- /dev/null +++ b/scripts/verify_distribution_set.py @@ -0,0 +1,128 @@ +"""Verify that a release artifact directory contains one exact wheel and sdist.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import tarfile +import tomllib +import zipfile +from email.parser import BytesParser +from email.policy import default +from pathlib import Path +from typing import cast + +ROOT = Path(__file__).resolve().parents[1] + + +def _project_version() -> str: + payload = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + project = payload.get("project") + if not isinstance(project, dict) or not isinstance(project.get("version"), str): + raise RuntimeError("pyproject.toml does not declare a project version") + return cast(str, project["version"]) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest().upper() + + +def _metadata_fields(payload: bytes) -> dict[str, str | None]: + message = BytesParser(policy=default).parsebytes(payload) + return { + "name": message.get("Name"), + "version": message.get("Version"), + "requires_python": message.get("Requires-Python"), + } + + +def _wheel_metadata(path: Path) -> dict[str, str | None]: + with zipfile.ZipFile(path) as archive: + matches = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] + if len(matches) != 1: + raise RuntimeError("wheel METADATA member is missing or ambiguous") + return _metadata_fields(archive.read(matches[0])) + + +def _sdist_metadata(path: Path) -> dict[str, str | None]: + with tarfile.open(path, "r:gz") as archive: + matches = [member for member in archive.getmembers() if member.name.endswith("/PKG-INFO")] + if len(matches) != 1: + raise RuntimeError("sdist PKG-INFO member is missing or ambiguous") + source = archive.extractfile(matches[0]) + if source is None: + raise RuntimeError("sdist PKG-INFO member is unreadable") + with source: + return _metadata_fields(source.read()) + + +def verify_distribution_set(dist_dir: Path, version: str) -> dict[str, object]: + root = dist_dir.resolve() + if not root.is_dir(): + raise RuntimeError("distribution directory does not exist") + entries = sorted(root.iterdir(), key=lambda path: path.name) + symbolic_links = [path.name for path in entries if path.is_symlink()] + if symbolic_links: + raise RuntimeError(f"symbolic link distribution entry: {', '.join(symbolic_links)}") + uv_marker = root / ".gitignore" + if uv_marker.exists() and not uv_marker.is_file(): + raise RuntimeError("non-file uv build marker: .gitignore") + entries = [path for path in entries if path.name != ".gitignore"] + expected_names = { + f"pmgs_reference-{version}-py3-none-any.whl", + f"pmgs_reference-{version}.tar.gz", + } + actual_names = {path.name for path in entries if path.is_file()} + non_files = [path.name for path in entries if not path.is_file()] + unexpected = sorted(actual_names - expected_names) + missing = sorted(expected_names - actual_names) + if non_files or unexpected or missing or len(entries) != 2: + details = [] + if missing: + details.append(f"missing: {', '.join(missing)}") + if unexpected: + details.append(f"unexpected distribution artifact: {', '.join(unexpected)}") + if non_files: + details.append(f"non-file distribution entry: {', '.join(non_files)}") + raise RuntimeError("; ".join(details) or "unexpected distribution artifact set") + files = sorted(expected_names) + wheel = root / f"pmgs_reference-{version}-py3-none-any.whl" + sdist = root / f"pmgs_reference-{version}.tar.gz" + expected_metadata = { + "name": "pmgs-reference", + "version": version, + "requires_python": ">=3.12", + } + if _wheel_metadata(wheel) != expected_metadata: + raise RuntimeError("wheel metadata does not match the release contract") + if _sdist_metadata(sdist) != expected_metadata: + raise RuntimeError("sdist metadata does not match the release contract") + return { + "schema_version": "1.0", + "version": version, + "files": files, + "sha256": {name: _sha256(root / name) for name in files}, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--dist-dir", type=Path, required=True) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + result = verify_distribution_set(args.dist_dir, _project_version()) + serialized = json.dumps(result, ensure_ascii=False, sort_keys=True) + "\n" + if args.output is not None: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(serialized, encoding="utf-8", newline="\n") + print(serialized, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_sdist_install.py b/scripts/verify_sdist_install.py new file mode 100644 index 0000000..c86af1f --- /dev/null +++ b/scripts/verify_sdist_install.py @@ -0,0 +1,253 @@ +"""Build an isolated wheel from the sdist and run the installed-wheel E2E contract.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tarfile +import tempfile +import tomllib +import zipfile +from pathlib import Path, PurePosixPath +from typing import cast + +ROOT = Path(__file__).resolve().parents[1] +_MAX_MEMBERS = 10_000 +_MAX_UNCOMPRESSED_BYTES = 512 * 1024 * 1024 + + +def _project_version() -> str: + payload = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + project = payload.get("project") + if not isinstance(project, dict) or not isinstance(project.get("version"), str): + raise RuntimeError("pyproject.toml does not declare a project version") + return cast(str, project["version"]) + + +def _select_sdist(dist_dir: Path, version: str) -> Path: + expected_names = { + f"pmgs_reference-{version}.tar.gz", + f"pmgs-reference-{version}.tar.gz", + } + archives = sorted( + path.resolve() + for path in dist_dir.resolve().glob("*.tar.gz") + if path.name in expected_names + ) + if len(archives) != 1: + raise RuntimeError( + f"expected exactly one PMGS sdist for version {version}, found {len(archives)}" + ) + return archives[0] + + +def _safe_member_path(name: str) -> PurePosixPath: + if not name or "\\" in name: + raise RuntimeError("sdist contains an unsafe member path") + member_path = PurePosixPath(name) + if member_path.is_absolute() or any(part in {"", ".", ".."} for part in member_path.parts): + raise RuntimeError("sdist contains an unsafe member path") + if member_path.parts and ":" in member_path.parts[0]: + raise RuntimeError("sdist contains an unsafe member path") + return member_path + + +def _extract_sdist(archive_path: Path, destination: Path) -> Path: + archive_path = archive_path.resolve() + expected_top_level = archive_path.name.removesuffix(".tar.gz") + destination = destination.resolve() + destination.mkdir(parents=True, exist_ok=False) + total_bytes = 0 + with tarfile.open(archive_path, "r:gz") as archive: + members = archive.getmembers() + if len(members) > _MAX_MEMBERS: + raise RuntimeError("sdist contains too many members") + parsed: list[tuple[tarfile.TarInfo, PurePosixPath]] = [] + top_levels: set[str] = set() + seen_paths: set[PurePosixPath] = set() + for member in members: + member_path = _safe_member_path(member.name) + if member_path in seen_paths: + raise RuntimeError("sdist contains a duplicate member path") + seen_paths.add(member_path) + top_levels.add(member_path.parts[0]) + if not (member.isfile() or member.isdir()): + raise RuntimeError("sdist contains a link or special file") + if member.isfile(): + if member.size < 0: + raise RuntimeError("sdist contains an invalid file size") + total_bytes += member.size + if total_bytes > _MAX_UNCOMPRESSED_BYTES: + raise RuntimeError("sdist exceeds the uncompressed size limit") + parsed.append((member, member_path)) + if top_levels != {expected_top_level}: + raise RuntimeError("sdist must contain one expected top-level directory") + for member, member_path in parsed: + target = destination.joinpath(*member_path.parts) + try: + target.relative_to(destination) + except ValueError as exc: # pragma: no cover - guarded by PurePosixPath checks + raise RuntimeError("sdist member escapes the extraction root") from exc + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + source = archive.extractfile(member) + if source is None: + raise RuntimeError("sdist regular file could not be read") + with source, target.open("xb") as output: + shutil.copyfileobj(source, output) + root = destination / expected_top_level + if not (root / "pyproject.toml").is_file(): + raise RuntimeError("sdist does not contain pyproject.toml") + return root + + +def _run(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment.update( + { + "PYTHONUTF8": "1", + "PYTHONIOENCODING": "utf-8", + "UV_OFFLINE": "1", + "UV_PYTHON_DOWNLOADS": "never", + "UV_PYTHON_PREFERENCE": "only-system", + } + ) + completed = subprocess.run( + command, + cwd=cwd, + env=environment, + check=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=300, + ) + if completed.returncode != 0: + raise RuntimeError( + f"command failed with exit code {completed.returncode}: {command[0]}\n" + f"{completed.stderr[-2000:]}" + ) + return completed + + +def _select_wheel(directory: Path, version: str) -> Path: + matches = sorted(directory.glob(f"pmgs_reference-{version}-*.whl")) + if len(matches) != 1: + raise RuntimeError( + f"expected exactly one sdist-derived wheel for version {version}, found {len(matches)}" + ) + return matches[0].resolve() + + +def _sha256_bytes(payload: bytes) -> str: + import hashlib + + return hashlib.sha256(payload).hexdigest().upper() + + +def _wheel_contract(path: Path) -> dict[str, object]: + with zipfile.ZipFile(path) as archive: + names = archive.namelist() + if len(names) != len(set(names)): + raise RuntimeError("wheel contains a duplicate member path") + metadata_names = [name for name in names if name.endswith(".dist-info/METADATA")] + if len(metadata_names) != 1: + raise RuntimeError("wheel METADATA member is missing or ambiguous") + dist_info_prefix = metadata_names[0].removesuffix("METADATA") + runtime_hashes: dict[str, str] = {} + metadata_hashes: dict[str, str] = {} + for name in names: + if name.endswith("/"): + continue + payload = archive.read(name) + if name.startswith(dist_info_prefix): + relative = name.removeprefix(dist_info_prefix) + if relative != "RECORD": + metadata_hashes[relative] = _sha256_bytes(payload) + else: + runtime_hashes[name] = _sha256_bytes(payload) + return { + "runtime_hashes": runtime_hashes, + "metadata_hashes": metadata_hashes, + } + + +def _compare_wheels(reference: Path, rebuilt: Path) -> None: + if _wheel_contract(reference) != _wheel_contract(rebuilt): + raise RuntimeError( + "sdist-derived wheel runtime files or metadata differ from the release wheel" + ) + + +def verify_sdist(dist_dir: Path, source: Path, version: str) -> dict[str, object]: + uv = shutil.which("uv") + if uv is None: + raise RuntimeError("uv executable not found") + sdist = _select_sdist(dist_dir, version) + reference_wheels = sorted(dist_dir.resolve().glob(f"pmgs_reference-{version}-*.whl")) + if len(reference_wheels) != 1: + raise RuntimeError( + "expected exactly one release wheel for version " + f"{version}, found {len(reference_wheels)}" + ) + with tempfile.TemporaryDirectory(prefix="pmgs-sdist-e2e-") as temporary_name: + temporary = Path(temporary_name).resolve() + extracted = _extract_sdist(sdist, temporary / "source") + required_files = ( + "LICENSE", + "README.md", + "README.en.md", + "pyproject.toml", + "src/pmgs_reference/__init__.py", + ) + missing = [name for name in required_files if not (extracted / name).is_file()] + if missing: + raise RuntimeError(f"sdist is missing required file: {missing[0]}") + rebuilt_dir = temporary / "rebuilt" + rebuilt_dir.mkdir() + _run( + [uv, "build", "--wheel", "--no-sources", "--out-dir", str(rebuilt_dir)], + cwd=extracted, + ) + rebuilt_wheel = _select_wheel(rebuilt_dir, version) + _compare_wheels(reference_wheels[0].resolve(), rebuilt_wheel) + _run( + [ + sys.executable, + str(ROOT / "scripts" / "verify_wheel_install.py"), + "--dist-dir", + str(rebuilt_dir), + "--source", + str(source.resolve()), + ], + cwd=ROOT, + ) + return { + "schema_version": "1.0", + "status": "ready", + "version": version, + "sdist": sdist.name, + "wheel_contract": "matched", + "installed_runtime": "verified", + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--dist-dir", type=Path, required=True) + parser.add_argument("--source", type=Path, required=True) + args = parser.parse_args() + result = verify_sdist(args.dist_dir, args.source, _project_version()) + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_project_contracts.py b/tests/test_project_contracts.py index dc23f00..06d7cf3 100644 --- a/tests/test_project_contracts.py +++ b/tests/test_project_contracts.py @@ -267,7 +267,21 @@ def test_release_workflow_keeps_publish_authority_narrow() -> None: assert jobs["publish-github"]["steps"][-1]["env"]["GH_REPO"] == "${{ github.repository }}" assert "id-token" not in jobs["publish-github"]["permissions"] assert "id-token" not in jobs["build"] - assert raw.count("name: python-distributions") == 3 + distribution_uploads = [ + step + for step in jobs["build"]["steps"] + if "upload-artifact@" in str(step.get("uses", "")) + and step.get("with", {}).get("name") == "python-distributions" + ] + assert len(distribution_uploads) == 1 + for job_name in ("verify-distributions", "publish-pypi", "publish-github"): + distribution_downloads = [ + step + for step in jobs[job_name]["steps"] + if "download-artifact@" in str(step.get("uses", "")) + and step.get("with", {}).get("name") == "python-distributions" + ] + assert len(distribution_downloads) == 1 assert "uv lock --check" in raw assert "npm --prefix worker ci" in raw assert "npm --prefix worker run verify" in raw diff --git a/tests/test_release_gate_contract.py b/tests/test_release_gate_contract.py new file mode 100644 index 0000000..6aa16a8 --- /dev/null +++ b/tests/test_release_gate_contract.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def _release_workflow() -> dict[str, object]: + return yaml.safe_load( + (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + ) + + +def _needs(job: dict[str, object]) -> set[str]: + value = job.get("needs", []) + if isinstance(value, str): + return {value} + assert isinstance(value, list) + return {str(item) for item in value} + + +def test_release_publish_waits_for_all_platform_and_artifact_gates() -> None: + workflow = _release_workflow() + jobs = workflow["jobs"] + assert isinstance(jobs, dict) + + expected = { + "build", + "verify-linux", + "verify-windows", + "verify-macos", + "compare-synthetic-determinism", + "verify-distributions", + "verify-worker", + } + assert expected <= set(jobs) + publish = jobs["publish-pypi"] + assert isinstance(publish, dict) + assert expected <= _needs(publish) + + +def test_release_platform_gates_verify_source_wheel_sdist_and_determinism() -> None: + workflow = _release_workflow() + jobs = workflow["jobs"] + assert isinstance(jobs, dict) + expected_os = { + "verify-linux": "ubuntu-latest", + "verify-windows": "windows-latest", + "verify-macos": "macos-latest", + } + + for job_id, operating_system in expected_os.items(): + job = jobs[job_id] + assert isinstance(job, dict) + assert job["runs-on"] == operating_system + assert _needs(job) == {"build"} + serialized = yaml.safe_dump(job, sort_keys=False) + assert "scripts/verify_wheel_install.py" in serialized + assert "scripts/verify_sdist_install.py" in serialized + assert "scripts/verify_synthetic_determinism.py" in serialized + assert "uv run --frozen pytest -q" in serialized + + +def test_release_distribution_gate_verifies_exact_fixed_artifact_set() -> None: + workflow = _release_workflow() + jobs = workflow["jobs"] + assert isinstance(jobs, dict) + job = jobs["verify-distributions"] + assert isinstance(job, dict) + assert _needs(job) == {"build"} + serialized = yaml.safe_dump(job, sort_keys=False) + assert "scripts/verify_distribution_set.py" in serialized + + +def test_release_worker_gate_is_required_before_publish() -> None: + workflow = _release_workflow() + jobs = workflow["jobs"] + assert isinstance(jobs, dict) + worker = jobs["verify-worker"] + assert isinstance(worker, dict) + serialized = yaml.safe_dump(worker, sort_keys=False) + assert "npm --prefix worker ci" in serialized + assert "npm --prefix worker run verify" in serialized + + +def test_release_runbook_documents_three_os_and_sdist_gate() -> None: + runbook = (ROOT / "docs" / "release-runbook.md").read_text(encoding="utf-8") + + assert "3 OS release gate" in runbook + assert "verify_sdist_install.py" in runbook + assert "sdist由来wheel" in runbook + + +def test_pull_request_ci_runs_sdist_e2e_on_all_three_operating_systems() -> None: + workflow = yaml.safe_load( + (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + ) + jobs = workflow["jobs"] + assert isinstance(jobs, dict) + wheel_e2e = jobs["wheel-e2e"] + assert isinstance(wheel_e2e, dict) + assert wheel_e2e["strategy"]["matrix"]["os"] == [ + "ubuntu-latest", + "windows-latest", + "macos-latest", + ] + serialized = yaml.safe_dump(wheel_e2e, sort_keys=False) + assert "scripts/verify_sdist_install.py" in serialized diff --git a/tests/test_release_scripts.py b/tests/test_release_scripts.py index 8c3a0f0..111f7c2 100644 --- a/tests/test_release_scripts.py +++ b/tests/test_release_scripts.py @@ -273,3 +273,189 @@ def test_compare_determinism_reports_requires_three_platforms_and_equal_contract paths[-1].write_text(json.dumps(payload), encoding="utf-8") with pytest.raises(RuntimeError, match="differs"): module.compare_reports(paths) + + +def _write_tar(path: Path, members: list[tuple[str, bytes, str]]) -> None: + import io + import tarfile + + with tarfile.open(path, "w:gz") as archive: + for name, content, kind in members: + info = tarfile.TarInfo(name) + if kind == "file": + info.size = len(content) + archive.addfile(info, io.BytesIO(content)) + elif kind == "dir": + info.type = tarfile.DIRTYPE + archive.addfile(info) + elif kind == "symlink": + info.type = tarfile.SYMTYPE + info.linkname = "target" + archive.addfile(info) + else: # pragma: no cover - test helper guard + raise AssertionError(kind) + + +def test_select_sdist_ignores_other_project_versions(tmp_path: Path) -> None: + module = _load_named_script("verify_sdist_install.py") + (tmp_path / "pmgs_reference-0.3.0.tar.gz").touch() + expected = tmp_path / "pmgs_reference-0.4.0.tar.gz" + expected.touch() + + assert module._select_sdist(tmp_path, "0.4.0") == expected.resolve() + + +def test_select_sdist_rejects_zero_or_multiple_current_version_archives(tmp_path: Path) -> None: + module = _load_named_script("verify_sdist_install.py") + + with pytest.raises(RuntimeError, match=r"version 0\.4\.0, found 0"): + module._select_sdist(tmp_path, "0.4.0") + + (tmp_path / "pmgs_reference-0.4.0.tar.gz").touch() + (tmp_path / "pmgs-reference-0.4.0.tar.gz").touch() + with pytest.raises(RuntimeError, match=r"version 0\.4\.0, found 2"): + module._select_sdist(tmp_path, "0.4.0") + + +def test_sdist_extraction_rejects_traversal_absolute_and_links(tmp_path: Path) -> None: + module = _load_named_script("verify_sdist_install.py") + cases = { + "traversal": [("pmgs_reference-0.4.0/../escape", b"x", "file")], + "absolute": [("/absolute", b"x", "file")], + "symlink": [("pmgs_reference-0.4.0/link", b"", "symlink")], + } + + for name, members in cases.items(): + archive = tmp_path / f"{name}.tar.gz" + _write_tar(archive, members) + with pytest.raises(RuntimeError): + module._extract_sdist(archive, tmp_path / name) + + +def test_sdist_extraction_requires_one_expected_top_level_directory(tmp_path: Path) -> None: + module = _load_named_script("verify_sdist_install.py") + archive = tmp_path / "pmgs_reference-0.4.0.tar.gz" + _write_tar( + archive, + [ + ("pmgs_reference-0.4.0/pyproject.toml", b"[project]\n", "file"), + ("unexpected/file.txt", b"x", "file"), + ], + ) + + with pytest.raises(RuntimeError, match="top-level"): + module._extract_sdist(archive, tmp_path / "extract") + + +def test_distribution_set_requires_exact_current_wheel_and_sdist(tmp_path: Path) -> None: + import io + import tarfile + import zipfile + + module = _load_named_script("verify_distribution_set.py") + wheel = tmp_path / "pmgs_reference-0.4.0-py3-none-any.whl" + sdist = tmp_path / "pmgs_reference-0.4.0.tar.gz" + metadata = ( + b"Metadata-Version: 2.4\nName: pmgs-reference\nVersion: 0.4.0\nRequires-Python: >=3.12\n\n" + ) + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr("pmgs_reference-0.4.0.dist-info/METADATA", metadata) + with tarfile.open(sdist, "w:gz") as archive: + info = tarfile.TarInfo("pmgs_reference-0.4.0/PKG-INFO") + info.size = len(metadata) + archive.addfile(info, io.BytesIO(metadata)) + + (tmp_path / ".gitignore").write_text("*\n", encoding="utf-8") + result = module.verify_distribution_set(tmp_path, "0.4.0") + + assert result["files"] == [wheel.name, sdist.name] + assert set(result["sha256"]) == {wheel.name, sdist.name} + + (tmp_path / ".gitignore").unlink() + (tmp_path / ".gitignore").mkdir() + with pytest.raises(RuntimeError, match="non-file uv build marker"): + module.verify_distribution_set(tmp_path, "0.4.0") + (tmp_path / ".gitignore").rmdir() + + (tmp_path / "unexpected.txt").write_text("x", encoding="utf-8") + with pytest.raises(RuntimeError, match="unexpected distribution artifact"): + module.verify_distribution_set(tmp_path, "0.4.0") + + +def _write_test_wheel( + path: Path, + *, + payload: bytes, + wheel_metadata: bytes = b"Wheel-Version: 1.0\n", +) -> None: + import zipfile + + metadata = b"Metadata-Version: 2.4\nName: pmgs-reference\nVersion: 0.4.0\n\n" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("pmgs_reference/__init__.py", payload) + archive.writestr("pmgs_reference-0.4.0.dist-info/METADATA", metadata) + archive.writestr("pmgs_reference-0.4.0.dist-info/WHEEL", wheel_metadata) + archive.writestr( + "pmgs_reference-0.4.0.dist-info/entry_points.txt", + b"[console_scripts]\npmgs=pmgs_reference.cli:main\n", + ) + archive.writestr("pmgs_reference-0.4.0.dist-info/RECORD", b"") + + +def test_sdist_wheel_comparison_rejects_runtime_payload_drift(tmp_path: Path) -> None: + module = _load_named_script("verify_sdist_install.py") + reference = tmp_path / "reference.whl" + rebuilt = tmp_path / "rebuilt.whl" + _write_test_wheel(reference, payload=b"VERSION = 'reference'\n") + _write_test_wheel(rebuilt, payload=b"VERSION = 'rebuilt'\n") + + with pytest.raises(RuntimeError, match="runtime files"): + module._compare_wheels(reference, rebuilt) + + +def test_distribution_set_rejects_symlinked_artifacts(tmp_path: Path) -> None: + module = _load_named_script("verify_distribution_set.py") + real_wheel = tmp_path.with_name(tmp_path.name + "-real-wheel") + real_wheel.write_bytes(b"wheel") + wheel = tmp_path / "pmgs_reference-0.4.0-py3-none-any.whl" + try: + wheel.symlink_to(real_wheel) + except OSError: + pytest.skip("symlink creation is unavailable") + (tmp_path / "pmgs_reference-0.4.0.tar.gz").write_bytes(b"sdist") + + with pytest.raises(RuntimeError, match="symbolic link"): + module.verify_distribution_set(tmp_path, "0.4.0") + + +def test_sdist_wheel_comparison_rejects_wheel_metadata_drift(tmp_path: Path) -> None: + module = _load_named_script("verify_sdist_install.py") + reference = tmp_path / "reference.whl" + rebuilt = tmp_path / "rebuilt.whl" + _write_test_wheel(reference, payload=b"VERSION = 'same'\n") + _write_test_wheel( + rebuilt, + payload=b"VERSION = 'same'\n", + wheel_metadata=b"Wheel-Version: 1.0\nGenerator: altered\n", + ) + + with pytest.raises(RuntimeError, match="metadata"): + module._compare_wheels(reference, rebuilt) + + +def test_sdist_wheel_comparison_rejects_duplicate_members(tmp_path: Path) -> None: + import zipfile + + module = _load_named_script("verify_sdist_install.py") + reference = tmp_path / "reference.whl" + rebuilt = tmp_path / "rebuilt.whl" + _write_test_wheel(reference, payload=b"VERSION = 'same'\n") + _write_test_wheel(rebuilt, payload=b"VERSION = 'same'\n") + with ( + pytest.warns(UserWarning, match="Duplicate name"), + zipfile.ZipFile(rebuilt, "a") as archive, + ): + archive.writestr("pmgs_reference/__init__.py", b"VERSION = 'duplicate'\n") + + with pytest.raises(RuntimeError, match="duplicate"): + module._compare_wheels(reference, rebuilt) diff --git a/tests/test_sdist_offline_contract.py b/tests/test_sdist_offline_contract.py new file mode 100644 index 0000000..6a81a6e --- /dev/null +++ b/tests/test_sdist_offline_contract.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +ROOT = Path(__file__).resolve().parents[1] + + +def _load_verifier() -> ModuleType: + path = ROOT / "scripts" / "verify_sdist_install.py" + spec = importlib.util.spec_from_file_location("pmgs_verify_sdist_install", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_sdist_subprocesses_run_with_uv_offline() -> None: + module = _load_verifier() + result = module._run( + [ + sys.executable, + "-c", + "import os; print(os.environ.get('UV_OFFLINE', ''))", + ] + ) + + assert result.stdout.strip() == "1"