Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
94 changes: 94 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 74 additions & 0 deletions scripts/check_release.py
Original file line number Diff line number Diff line change
@@ -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())
53 changes: 53 additions & 0 deletions tests/test_release.py
Original file line number Diff line number Diff line change
@@ -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
Loading