From 0d215726db6f1939867368816b69fd201084eee2 Mon Sep 17 00:00:00 2001 From: Dzmitryi Kharlanau Date: Wed, 26 Aug 2026 12:28:47 +0300 Subject: [PATCH 1/5] Add deterministic release version gate --- scripts/check_release.py | 74 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 scripts/check_release.py diff --git a/scripts/check_release.py b/scripts/check_release.py new file mode 100644 index 0000000..3bac43d --- /dev/null +++ b/scripts/check_release.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import argparse +import ast +import re +from pathlib import Path + +_PROJECT_SECTION = re.compile(r"(?ms)^\[project\]\s*(.*?)(?=^\[|\Z)") +_VERSION_LINE = re.compile(r'(?m)^version\s*=\s*"([^"]+)"\s*$') + + +def pyproject_version(path: str | Path = "pyproject.toml") -> str: + text = Path(path).read_text(encoding="utf-8") + section = _PROJECT_SECTION.search(text) + if not section: + raise ValueError("pyproject.toml has no [project] section") + match = _VERSION_LINE.search(section.group(1)) + if not match: + raise ValueError("pyproject.toml [project] has no literal version") + return match.group(1) + + +def runtime_version(path: str | Path = "src/process_as_code/__init__.py") -> str: + tree = ast.parse(Path(path).read_text(encoding="utf-8"), filename=str(path)) + for node in tree.body: + if not isinstance(node, (ast.Assign, ast.AnnAssign)): + continue + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + if any(isinstance(target, ast.Name) and target.id == "__version__" for target in targets): + value = node.value + if isinstance(value, ast.Constant) and isinstance(value.value, str): + return value.value + raise ValueError("__version__ must be a literal string") + raise ValueError("runtime __version__ is missing") + + +def validate_release_tag( + tag: str, + pyproject_path: str | Path = "pyproject.toml", + runtime_path: str | Path = "src/process_as_code/__init__.py", +) -> list[str]: + package_version = pyproject_version(pyproject_path) + code_version = runtime_version(runtime_path) + errors: list[str] = [] + if code_version != package_version: + errors.append(f"runtime __version__ '{code_version}' does not match pyproject version '{package_version}'") + expected_tag = f"v{package_version}" + if tag != expected_tag: + errors.append(f"release tag '{tag}' does not match expected tag '{expected_tag}'") + return errors + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Validate Process as Code release tag/version parity") + parser.add_argument("tag", help="GitHub release tag, for example v0.2.0") + parser.add_argument("--pyproject", default="pyproject.toml") + parser.add_argument("--runtime", default="src/process_as_code/__init__.py") + args = parser.parse_args(argv) + + try: + errors = validate_release_tag(args.tag, args.pyproject, args.runtime) + except (OSError, SyntaxError, ValueError) as exc: + print(f"ERROR: {exc}") + return 1 + if errors: + for error in errors: + print(f"ERROR: {error}") + return 1 + print(f"OK release {args.tag}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 9024a0db264e33a03934e53aa72218f8d2f5b72c Mon Sep 17 00:00:00 2001 From: Dzmitryi Kharlanau Date: Wed, 26 Aug 2026 12:29:15 +0300 Subject: [PATCH 2/5] Harden release provenance and PyPI publishing --- .github/workflows/release.yml | 43 +++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7eca2a..40cfa4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,16 +13,47 @@ jobs: - uses: actions/setup-python@v6 with: python-version: "3.12" - - run: python -m pip install build - - run: python -m build + - name: Verify release tag and package version + run: python scripts/check_release.py "${{ github.event.release.tag_name }}" + - name: Install build and test dependencies + run: | + python -m pip install --upgrade pip build twine + python -m pip install -e '.[dev]' + - name: Run release quality gate + run: | + pytest -q + process-code validate examples/customer-creation.process.yaml --strict + process-code conformance conformance/v0.2 + - name: Build distributions + run: python -m build + - name: Validate distribution metadata + run: python -m twine check dist/* - uses: actions/upload-artifact@v4 with: name: python-package-distributions path: dist/ + if-no-files-found: error - publish-pypi: + attest: needs: build runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Generate GitHub build provenance + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 + with: + subject-path: dist/* + + publish-pypi: + needs: [build, attest] + runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/process-as-code @@ -33,4 +64,8 @@ jobs: with: name: python-package-distributions path: dist/ - - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Publish distributions to PyPI with Trusted Publishing + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: dist/ + attestations: true From 4f00517af7e579ad75b66f11c5e05b24aec382a2 Mon Sep 17 00:00:00 2001 From: Dzmitryi Kharlanau Date: Wed, 26 Aug 2026 12:29:40 +0300 Subject: [PATCH 3/5] Test release version and trust-boundary invariants --- tests/test_release.py | 53 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/test_release.py diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..030ac7b --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import runpy +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RELEASE = runpy.run_path(str(ROOT / "scripts/check_release.py")) +validate_release_tag = RELEASE["validate_release_tag"] + + +def test_release_tag_matches_package_and_runtime_versions() -> None: + assert validate_release_tag( + "v0.2.0", + ROOT / "pyproject.toml", + ROOT / "src/process_as_code/__init__.py", + ) == [] + + +def test_release_tag_mismatch_is_rejected() -> None: + errors = validate_release_tag( + "v0.2.1", + ROOT / "pyproject.toml", + ROOT / "src/process_as_code/__init__.py", + ) + assert errors == ["release tag 'v0.2.1' does not match expected tag 'v0.2.0'"] + + +def test_runtime_version_mismatch_is_rejected(tmp_path: Path) -> None: + runtime = tmp_path / "__init__.py" + runtime.write_text('__version__ = "0.1.0"\n', encoding="utf-8") + errors = validate_release_tag("v0.2.0", ROOT / "pyproject.toml", runtime) + assert any("does not match pyproject version" in error for error in errors) + + +def test_release_workflow_preserves_privilege_separation_and_pins() -> None: + workflow = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + + build = workflow.split("\n attest:\n", 1)[0] + attest_and_publish = workflow.split("\n attest:\n", 1)[1] + publish = workflow.split("\n publish-pypi:\n", 1)[1] + + assert "python scripts/check_release.py" in build + assert "pytest -q" in build + assert "python -m twine check dist/*" in build + assert "id-token: write" not in build + assert "attestations: write" not in build + + assert "actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d" in attest_and_publish + assert "attestations: write" in attest_and_publish + assert "needs: [build, attest]" in publish + assert "id-token: write" in publish + assert "pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33" in publish + assert "attestations: true" in publish From 47b3e7104383aa75539994e33a9b2bebe10cb712 Mon Sep 17 00:00:00 2001 From: Dzmitryi Kharlanau Date: Wed, 26 Aug 2026 12:30:13 +0300 Subject: [PATCH 4/5] Document provenance-backed release workflow --- docs/releasing.md | 94 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/releasing.md diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..c32321f --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,94 @@ +# Releasing Process as Code + +The release path is designed around two independent provenance mechanisms: + +1. GitHub build provenance created with `actions/attest` for the built wheel and source distribution. +2. PyPI PEP 740 attestations generated automatically by the official PyPI publishing Action when Trusted Publishing is used. + +The build and publish trust boundaries are intentionally separate. + +## One-time PyPI setup + +Before the first release, configure a PyPI Trusted Publisher with: + +- Owner: `dkharlanau` +- Repository: `process-as-code` +- Workflow: `release.yml` +- Environment: `pypi` + +No PyPI API token should be added to GitHub Secrets. The publish job receives only `id-token: write` and uses OIDC. + +## Release version gate + +The GitHub release tag must exactly match the package version: + +```text +pyproject.toml version = 0.2.0 +runtime __version__ = 0.2.0 +release tag = v0.2.0 +``` + +The workflow runs: + +```bash +python scripts/check_release.py v0.2.0 +``` + +A mismatch blocks the release before build provenance or PyPI credentials are requested. + +## Release workflow + +When a GitHub release is published, `.github/workflows/release.yml` performs: + +```text +build (contents: read only) + -> version/tag gate + -> pytest + conformance + -> wheel/sdist build + -> twine metadata check + -> workflow artifact + +attest (contents: read + id-token + attestations) + -> download exact prebuilt distributions + -> GitHub build provenance + +publish-pypi (id-token only) + -> download the same prebuilt distributions + -> PyPI Trusted Publishing + -> PyPI PEP 740 publish attestations +``` + +Build/test code never runs in the PyPI publishing job. + +## Verify GitHub provenance + +After downloading a released distribution, verify its GitHub build provenance with GitHub CLI: + +```bash +gh attestation verify process_as_code-0.2.0-py3-none-any.whl \ + -R dkharlanau/process-as-code +``` + +PyPI exposes the publish attestations attached to distributions uploaded through Trusted Publishing. + +## GitHub release assets and immutability + +The Python wheel/sdist are canonically distributed through PyPI. The release workflow stores them as a workflow artifact and attests them on GitHub; it does not mutate an already-published GitHub release to attach files. + +This is intentional because GitHub immutable releases prevent release assets from being modified after publication. If immutable releases are enabled, GitHub recommends preparing a draft release, attaching any desired release assets, and only then publishing it. + +For the initial `v0.2.0`, GitHub's source archives plus the PyPI distributions are sufficient. If duplicate wheel/sdist assets are desired on the GitHub Release page, attach them to the draft before publishing rather than weakening release immutability. + +## GitHub Marketplace + +The repository root contains `action.yml`. When publishing `v0.2.0`, select **Publish this Action to the GitHub Marketplace** on the release screen after accepting the Marketplace Developer Agreement if required. + +Consumers should then reference the immutable versioned release rather than `main`: + +```yaml +uses: dkharlanau/process-as-code@v0.2.0 +``` + +## Remaining activation + +Code-side release automation is complete once CI for this workflow is green. The only account-level steps are the PyPI Trusted Publisher registration and the GitHub Marketplace/Release UI actions tracked in issue #21. From 5c461bedc9e92dd44e8fd7665ee9a351a1a08d08 Mon Sep 17 00:00:00 2001 From: Dzmitryi Kharlanau Date: Wed, 26 Aug 2026 12:30:45 +0300 Subject: [PATCH 5/5] Record provenance-backed release pipeline --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f73a4d9..4fe7905 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,5 +22,6 @@ - Deterministic JSON-LD enterprise knowledge-graph export. - Provider-neutral AI drafting context bundle with bundled package schema. - Self-contained wheel distribution with `process-code schema` and clean-install CI smoke testing. +- Provenance-backed release pipeline with strict tag/version parity, isolated build/attest/Publish-to-PyPI trust zones, GitHub build attestations and PyPI Trusted Publishing/PEP 740 attestations. - Static zero-backend browser playground and VS Code authoring extension. - Vendor-neutral adapter framework with BPMN and CSV reference adapters.