Skip to content
Draft
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
97 changes: 97 additions & 0 deletions .github/RELEASING.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# GitHub release procedure

The public SDK is released only from the canonical
[`OpenGraphLabs/oglo-python`](https://github.com/OpenGraphLabs/oglo-python)
repository. GitHub Releases are the only publication target. This repository does
not publish to PyPI, and no workflow or release operator should upload a package
to PyPI.

## One-time repository settings

Apply these controls before creating the first tag handled by
`.github/workflows/release.yml`:

1. Keep the existing `main` required checks while this workflow change is being
reviewed. After it lands and a pull request has produced the new check, replace
the seven matrix-specific required contexts with the single stable
`Required CI gate` context. Keep strict up-to-date branches, admin enforcement,
linear history, and required conversation resolution enabled.
2. Create an environment named `sdk-github-release`. Limit it to tags matching
`v*`. Add an owner/release-manager approval rule when a second approver is
available. The publish job is the only job with `contents: write` and waits on
this environment.
3. Add a tag ruleset for `refs/tags/v*`: restrict tag creation to release managers
and block updates and deletion. A released tag is immutable and is never moved
to another commit.
4. In Actions settings, require full-length action SHA pins. If organization policy
permits it, allow GitHub-authored actions only. The checked-in policy test also
rejects movable action tags.

No PyPI token, API key, cloud signing key, or long-lived GitHub token is required.
The workflow uses the repository-scoped `GITHUB_TOKEN` with per-job permissions.

## Release a version

1. Merge the version and changelog change through a pull request. The literal
`[project].version` in `pyproject.toml` is the release version.
2. Wait for the `main` push run of `CI` at that exact merge SHA to succeed. That run
builds one wheel and one sdist, records their hashes and source SHA in an
internal manifest, uploads one immutable artifact, and creates GitHub build
provenance. The artifact is retained for 30 days.
3. Create one annotated tag whose name is exactly `v` plus the package version.
Tag the already-tested `main` SHA, not a local rebuild or a different checkout:

```bash
git fetch origin main --tags
git switch main
git pull --ff-only origin main
version="0.1.0rc4" # example; must equal pyproject.toml
sha="$(git rev-parse origin/main)"
git tag -a "v${version}" "$sha" -m "OGLO Python SDK ${version}"
git push origin "refs/tags/v${version}"
```

4. The tag starts `GitHub Release`. Its read-only verification job fails unless:

- the tag is annotated, resolves to a commit contained in `main`, and exactly
matches `[project].version`;
- a successful `push` run of `.github/workflows/ci.yml` exists for that exact
commit and its stable gate and provenance jobs both passed;
- the exact, unexpired artifact ID from that CI run contains one wheel, one
sdist, and the matching source/hash manifest;
- both public packages have GitHub provenance signed by the CI workflow at that
exact `main` SHA.

5. Approve the `sdk-github-release` environment if it has a reviewer rule. The
write-scoped job downloads the same artifact ID again, reconciles its hashes,
re-resolves the tag through the GitHub API, re-verifies provenance, and creates
a GitHub Release containing the wheel, sdist, and `SHA256SUMS`. It never rebuilds
and never publishes to PyPI.

If the CI artifact expired, use **Re-run all jobs** on the original successful
`main` push run before creating the tag. The artifact name includes both source SHA
and run attempt, so reruns cannot silently reuse an older attempt's bytes.

## Independent verification

Download a release and verify both the published checksums and GitHub provenance:

```bash
tag="v0.1.0rc4" # example
mkdir -p "/tmp/oglo-${tag}"
gh release download "$tag" \
--repo OpenGraphLabs/oglo-python \
--dir "/tmp/oglo-${tag}"
(cd "/tmp/oglo-${tag}" && shasum -a 256 --check SHA256SUMS)
for artifact in "/tmp/oglo-${tag}"/*.whl "/tmp/oglo-${tag}"/*.tar.gz; do
gh attestation verify "$artifact" \
--repo OpenGraphLabs/oglo-python \
--signer-workflow OpenGraphLabs/oglo-python/.github/workflows/ci.yml \
--source-ref refs/heads/main \
--deny-self-hosted-runners
done
```

If a published package is bad, do not replace its assets or move its tag. Preserve
the audit trail, fix the issue on `main`, increment the version, and release a new
tag.
103 changes: 103 additions & 0 deletions .github/scripts/check_workflows.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Fail closed when GitHub workflows drift from the release security policy."""

from __future__ import annotations

import argparse
import re
from pathlib import Path


FULL_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
USES_RE = re.compile(r"^\s*(?:-\s+)?uses:\s*([^\s#]+)(?:\s+#.*)?$")


class WorkflowPolicyError(ValueError):
pass


def _require(condition: bool, message: str) -> None:
if not condition:
raise WorkflowPolicyError(message)


def check_action_pins(path: Path, text: str) -> None:
for line_number, line in enumerate(text.splitlines(), start=1):
match = USES_RE.match(line)
if not match:
continue
target = match.group(1)
_require("@" in target, f"{path}:{line_number}: action has no ref")
action, ref = target.rsplit("@", 1)
_require(not action.startswith("./"), f"{path}:{line_number}: local actions are not allowed")
_require(bool(FULL_SHA_RE.fullmatch(ref)), f"{path}:{line_number}: action is not pinned to a full SHA")


def check_ci(path: Path, text: str) -> None:
_require(re.search(r"(?m)^\s{2}pull_request:\s*$", text) is not None, "CI must run for every pull request")
_require("pull_request_target:" not in text, "CI must not use pull_request_target")
_require(re.search(r"(?m)^\s+paths(?:-ignore)?:", text) is None, "CI triggers must not use path filters")
_require("name: Required CI gate" in text, "CI must expose one stable required gate")
_require("if: ${{ always() }}" in text, "the required gate must always be created")
_require("name: Build and install distributions" in text, "the existing package check must remain during migration")
_require("name: Attest main-branch distributions" in text, "main distributions must be attested")
_require("python-dist-${{ github.sha }}-${{ github.run_attempt }}" in text, "artifacts must bind SHA and run attempt")
_require("actions/attest-build-provenance@" in text, "CI must create GitHub provenance")
_require("id-token: write" in text and "attestations: write" in text, "provenance job permissions are incomplete")


def check_release(path: Path, text: str) -> None:
_require(re.search(r"(?ms)^on:\s*\n\s{2}push:\s*\n\s{4}tags:", text) is not None, "release must be tag-push-only")
_require("workflow_dispatch:" not in text, "release must not bypass tag creation with workflow_dispatch")
_require(re.search(r"(?m)^permissions: \{\}\s*$", text) is not None, "release default permissions must be empty")
_require("name: sdk-github-release" in text, "publish must use the protected release environment")
_require("artifact-ids:" in text and "run-id:" in text, "release must select an exact CI artifact")
_require("head_sha" in text and "event=push" in text, "release must select exact main push CI")
_require("--signer-workflow" in text, "release must pin the provenance signer workflow")
_require("--signer-digest" in text, "release must pin the signer workflow SHA")
_require("--source-digest" in text and "--source-ref refs/heads/main" in text, "release must pin source provenance")
_require("gh release create" in text and "--verify-tag" in text, "release creation must reuse the verified tag")
_require("--draft" in text and "--draft=false" in text, "release creation must be retry-safe through a draft")
_require("--target \"$SOURCE_SHA\"" in text, "release metadata must target the exact source SHA")
_require("download_and_verify_asset" in text, "existing release assets must be byte-verified")
_require("Existing bytes" in text, "release retries must not overwrite existing assets")
_require(text.count("contents: write") == 1, "only the publish job may write repository contents")
_require("id-token: write" not in text, "release does not need OIDC write permission")

lower = text.lower()
banned = (
"pypa/gh-action-pypi-publish",
"twine upload",
"upload.pypi.org",
"pypi_api_token",
"pypi-api-token",
"__token__",
)
for term in banned:
_require(term not in lower, f"PyPI publication is forbidden in release workflow: {term}")


def check_repository(root: Path) -> None:
workflows = root / ".github" / "workflows"
paths = sorted((*workflows.glob("*.yml"), *workflows.glob("*.yaml")))
_require(bool(paths), "no workflows found")
for path in paths:
check_action_pins(path, path.read_text(encoding="utf-8"))
check_ci(workflows / "ci.yml", (workflows / "ci.yml").read_text(encoding="utf-8"))
check_release(workflows / "release.yml", (workflows / "release.yml").read_text(encoding="utf-8"))


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path.cwd())
args = parser.parse_args()
try:
check_repository(args.root.resolve())
except WorkflowPolicyError as exc:
raise SystemExit(f"workflow policy rejected repository: {exc}") from exc
print("workflow policy: ok")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading