From b761c6ee1dc5ef1560fd50d44e3807b4f62eb7a3 Mon Sep 17 00:00:00 2001 From: Beomsoo Son Date: Wed, 12 Aug 2026 20:56:40 +0900 Subject: [PATCH 1/2] ci: publish verified SDK GitHub releases --- .github/RELEASING.md | 97 +++++++++ .github/scripts/check_workflows.py | 99 +++++++++ .github/scripts/release_policy.py | 337 +++++++++++++++++++++++++++++ .github/workflows/ci.yml | 66 ++++++ .github/workflows/release.yml | 266 +++++++++++++++++++++++ tests/test_release_policy.py | 183 ++++++++++++++++ 6 files changed, 1048 insertions(+) create mode 100644 .github/RELEASING.md create mode 100644 .github/scripts/check_workflows.py create mode 100644 .github/scripts/release_policy.py create mode 100644 .github/workflows/release.yml create mode 100644 tests/test_release_policy.py diff --git a/.github/RELEASING.md b/.github/RELEASING.md new file mode 100644 index 0000000..9f5ad9d --- /dev/null +++ b/.github/RELEASING.md @@ -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. diff --git a/.github/scripts/check_workflows.py b/.github/scripts/check_workflows.py new file mode 100644 index 0000000..95cb9cf --- /dev/null +++ b/.github/scripts/check_workflows.py @@ -0,0 +1,99 @@ +#!/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(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()) diff --git a/.github/scripts/release_policy.py b/.github/scripts/release_policy.py new file mode 100644 index 0000000..bcf401a --- /dev/null +++ b/.github/scripts/release_policy.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +"""Build and verify the immutable SDK release artifact contract.""" + +from __future__ import annotations + +import argparse +import email.parser +import hashlib +import json +import re +import stat +import tarfile +import zipfile +from pathlib import Path +from typing import Any, Iterable + + +PACKAGE_NAME = "oglo" +MANIFEST_NAME = "release-manifest.json" +MANIFEST_SCHEMA = 1 +REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +VERSION_RE = re.compile( + r"^(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*)){1,2}" + r"(?:(?:a|b|rc)[0-9]+)?(?:\.post[0-9]+)?(?:\.dev[0-9]+)?$" +) + + +class PolicyError(ValueError): + """A release input failed closed against the repository policy.""" + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise PolicyError(message) + + +def project_version(project_file: Path) -> str: + """Read the sole literal ``project.version`` without a TOML dependency.""" + + in_project = False + versions: list[str] = [] + for raw_line in project_file.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if line.startswith("[") and line.endswith("]"): + in_project = line == "[project]" + continue + if not in_project: + continue + match = re.fullmatch(r"version\s*=\s*(['\"])([^'\"]+)\1\s*(?:#.*)?", line) + if match: + versions.append(match.group(2)) + + _require(len(versions) == 1, "pyproject.toml must contain one literal [project].version") + version = versions[0] + _require(bool(VERSION_RE.fullmatch(version)), f"unsupported release version: {version!r}") + return version + + +def expected_tag(version: str) -> str: + _require(bool(VERSION_RE.fullmatch(version)), f"unsupported release version: {version!r}") + return f"v{version}" + + +def is_prerelease(version: str) -> bool: + expected_tag(version) + return bool(re.search(r"(?:a|b|rc)[0-9]+|\.dev[0-9]+", version)) + + +def validate_source(*, version: str, tag: str, repository: str, source_sha: str) -> None: + _require(tag == expected_tag(version), f"tag {tag!r} does not match version {version!r}") + _require(bool(REPOSITORY_RE.fullmatch(repository)), "repository must be owner/name") + _require(bool(SHA_RE.fullmatch(source_sha)), "source SHA must be 40 lowercase hex characters") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _safe_archive_name(name: str) -> bool: + path = Path(name) + return not path.is_absolute() and ".." not in path.parts and "" not in path.parts + + +def _metadata_fields(data: bytes, source: str) -> tuple[str, str]: + try: + message = email.parser.BytesParser().parsebytes(data) + except Exception as exc: # pragma: no cover - defensive parser boundary + raise PolicyError(f"cannot parse package metadata from {source}: {exc}") from exc + name = message.get("Name") + version = message.get("Version") + _require(bool(name), f"missing Name metadata in {source}") + _require(bool(version), f"missing Version metadata in {source}") + return str(name), str(version) + + +def _verify_wheel(path: Path, version: str) -> None: + normalized_version = re.sub(r"[^A-Za-z0-9.]+", "_", version) + expected_name = f"{PACKAGE_NAME}-{normalized_version}-py3-none-any.whl" + _require(path.name == expected_name, f"unexpected wheel filename: {path.name!r}") + + with zipfile.ZipFile(path) as archive: + for item in archive.infolist(): + _require(_safe_archive_name(item.filename), f"unsafe wheel member: {item.filename!r}") + mode = (item.external_attr >> 16) & 0o170000 + _require(mode != stat.S_IFLNK, f"wheel contains symlink: {item.filename!r}") + metadata_names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] + _require(len(metadata_names) == 1, "wheel must contain exactly one METADATA file") + name, embedded_version = _metadata_fields( + archive.read(metadata_names[0]), f"{path.name}:{metadata_names[0]}" + ) + _require(name == PACKAGE_NAME, f"wheel package name is {name!r}, expected {PACKAGE_NAME!r}") + _require(embedded_version == version, "wheel metadata version does not match pyproject.toml") + + +def _verify_sdist(path: Path, version: str) -> None: + expected_name = f"{PACKAGE_NAME}-{version}.tar.gz" + _require(path.name == expected_name, f"unexpected sdist filename: {path.name!r}") + + with tarfile.open(path, "r:gz") as archive: + metadata_members = [] + for member in archive.getmembers(): + _require(_safe_archive_name(member.name), f"unsafe sdist member: {member.name!r}") + _require( + not (member.issym() or member.islnk() or member.isdev() or member.isfifo()), + f"sdist contains unsafe special member: {member.name!r}", + ) + if member.name.endswith("/PKG-INFO"): + metadata_members.append(member) + _require(len(metadata_members) == 1, "sdist must contain exactly one PKG-INFO file") + extracted = archive.extractfile(metadata_members[0]) + _require(extracted is not None, "cannot read sdist PKG-INFO") + name, embedded_version = _metadata_fields( + extracted.read(), f"{path.name}:{metadata_members[0].name}" + ) + _require(name == PACKAGE_NAME, f"sdist package name is {name!r}, expected {PACKAGE_NAME!r}") + _require(embedded_version == version, "sdist metadata version does not match pyproject.toml") + + +def _distribution_paths(dist_dir: Path) -> tuple[Path, Path]: + _require(dist_dir.is_dir(), f"distribution directory does not exist: {dist_dir}") + entries = sorted(dist_dir.iterdir(), key=lambda path: path.name) + _require(all(path.is_file() and not path.is_symlink() for path in entries), "dist may contain only regular files") + payloads = [path for path in entries if path.name != MANIFEST_NAME] + wheels = [path for path in payloads if path.name.endswith(".whl")] + sdists = [path for path in payloads if path.name.endswith(".tar.gz")] + _require(len(payloads) == 2, "dist must contain exactly one wheel and one sdist") + _require(len(wheels) == 1, "dist must contain exactly one wheel") + _require(len(sdists) == 1, "dist must contain exactly one sdist") + return wheels[0], sdists[0] + + +def inspect_distributions(dist_dir: Path, version: str) -> dict[str, dict[str, Any]]: + wheel, sdist = _distribution_paths(dist_dir) + _verify_wheel(wheel, version) + _verify_sdist(sdist, version) + return { + "wheel": {"name": wheel.name, "sha256": _sha256(wheel), "size": wheel.stat().st_size}, + "sdist": {"name": sdist.name, "sha256": _sha256(sdist), "size": sdist.stat().st_size}, + } + + +def build_manifest( + *, dist_dir: Path, project_file: Path, repository: str, source_sha: str +) -> dict[str, Any]: + version = project_version(project_file) + validate_source( + version=version, + tag=expected_tag(version), + repository=repository, + source_sha=source_sha, + ) + manifest_path = dist_dir / MANIFEST_NAME + _require(not manifest_path.exists(), f"refusing to overwrite {manifest_path}") + files = inspect_distributions(dist_dir, version) + manifest = { + "schema_version": MANIFEST_SCHEMA, + "package": PACKAGE_NAME, + "version": version, + "tag": expected_tag(version), + "repository": repository, + "source_sha": source_sha, + "files": [ + {"kind": kind, **files[kind]} + for kind in ("wheel", "sdist") + ], + } + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return manifest + + +def verify_manifest( + *, + dist_dir: Path, + project_file: Path, + repository: str, + source_sha: str, + tag: str, +) -> dict[str, Any]: + version = project_version(project_file) + validate_source(version=version, tag=tag, repository=repository, source_sha=source_sha) + manifest_path = dist_dir / MANIFEST_NAME + _require(manifest_path.is_file() and not manifest_path.is_symlink(), "release manifest is missing") + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise PolicyError(f"invalid release manifest: {exc}") from exc + + expected_keys = { + "schema_version", + "package", + "version", + "tag", + "repository", + "source_sha", + "files", + } + _require(isinstance(manifest, dict), "release manifest must be an object") + _require(set(manifest) == expected_keys, "release manifest fields do not match the schema") + _require(manifest["schema_version"] == MANIFEST_SCHEMA, "unsupported release manifest schema") + _require(manifest["package"] == PACKAGE_NAME, "release manifest package mismatch") + _require(manifest["version"] == version, "release manifest version mismatch") + _require(manifest["tag"] == tag, "release manifest tag mismatch") + _require(manifest["repository"] == repository, "release manifest repository mismatch") + _require(manifest["source_sha"] == source_sha, "release manifest source SHA mismatch") + + actual_files = inspect_distributions(dist_dir, version) + manifest_files = manifest["files"] + _require(isinstance(manifest_files, list) and len(manifest_files) == 2, "manifest must list two files") + by_kind: dict[str, dict[str, Any]] = {} + for entry in manifest_files: + _require(isinstance(entry, dict), "manifest file entry must be an object") + _require(set(entry) == {"kind", "name", "sha256", "size"}, "invalid manifest file fields") + kind = entry["kind"] + _require(kind in {"wheel", "sdist"} and kind not in by_kind, "invalid manifest file kind") + by_kind[kind] = entry + _require(set(by_kind) == {"wheel", "sdist"}, "manifest must list wheel and sdist") + for kind, actual in actual_files.items(): + _require(by_kind[kind] == {"kind": kind, **actual}, f"{kind} bytes do not match the CI manifest") + + return { + "version": version, + "tag": tag, + "source_sha": source_sha, + "prerelease": "true" if is_prerelease(version) else "false", + "wheel_name": actual_files["wheel"]["name"], + "wheel_sha256": actual_files["wheel"]["sha256"], + "sdist_name": actual_files["sdist"]["name"], + "sdist_sha256": actual_files["sdist"]["sha256"], + } + + +def _write_github_output(path: Path, values: Iterable[tuple[str, str]]) -> None: + with path.open("a", encoding="utf-8") as output: + for key, value in values: + _require("\n" not in key and "\n" not in value, "GitHub output values must be single-line") + output.write(f"{key}={value}\n") + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + build = subparsers.add_parser("build-manifest") + build.add_argument("--dist", type=Path, required=True) + build.add_argument("--project-file", type=Path, required=True) + build.add_argument("--repository", required=True) + build.add_argument("--source-sha", required=True) + + source = subparsers.add_parser("validate-source") + source.add_argument("--project-file", type=Path, required=True) + source.add_argument("--repository", required=True) + source.add_argument("--source-sha", required=True) + source.add_argument("--tag", required=True) + source.add_argument("--github-output", type=Path, required=True) + + verify = subparsers.add_parser("verify-dist") + verify.add_argument("--dist", type=Path, required=True) + verify.add_argument("--project-file", type=Path, required=True) + verify.add_argument("--repository", required=True) + verify.add_argument("--source-sha", required=True) + verify.add_argument("--tag", required=True) + verify.add_argument("--github-output", type=Path, required=True) + return parser + + +def main() -> int: + args = _parser().parse_args() + try: + if args.command == "build-manifest": + manifest = build_manifest( + dist_dir=args.dist, + project_file=args.project_file, + repository=args.repository, + source_sha=args.source_sha, + ) + print(json.dumps(manifest, sort_keys=True)) + elif args.command == "validate-source": + version = project_version(args.project_file) + validate_source( + version=version, + tag=args.tag, + repository=args.repository, + source_sha=args.source_sha, + ) + _write_github_output( + args.github_output, + ( + ("version", version), + ("tag", args.tag), + ("source_sha", args.source_sha), + ("prerelease", "true" if is_prerelease(version) else "false"), + ), + ) + elif args.command == "verify-dist": + result = verify_manifest( + dist_dir=args.dist, + project_file=args.project_file, + repository=args.repository, + source_sha=args.source_sha, + tag=args.tag, + ) + _write_github_output(args.github_output, result.items()) + print(json.dumps(result, sort_keys=True)) + else: # pragma: no cover - argparse enforces the command set + raise AssertionError(args.command) + except PolicyError as exc: + raise SystemExit(f"release policy rejected input: {exc}") from exc + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d1bbdf..085bb25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: python: ["3.10", "3.12", "3.13"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python }} @@ -38,6 +40,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" @@ -51,3 +55,65 @@ jobs: run: python -m pip install dist/*.whl - name: Check installed CLI run: oglo --help + - name: Record release artifact identity + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + python .github/scripts/release_policy.py build-manifest \ + --dist dist \ + --project-file pyproject.toml \ + --repository "$GITHUB_REPOSITORY" \ + --source-sha "$GITHUB_SHA" + - name: Upload immutable main-branch distributions + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-dist-${{ github.sha }}-${{ github.run_attempt }} + path: dist/* + if-no-files-found: error + retention-days: 30 + + provenance: + name: Attest main-branch distributions + needs: package + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: Download the artifact from this CI attempt + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-dist-${{ github.sha }}-${{ github.run_attempt }} + path: dist + - name: Attest wheel and source distribution + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: | + dist/*.whl + dist/*.tar.gz + + required: + name: Required CI gate + needs: [test, package, provenance] + if: ${{ always() }} + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Require every applicable CI job + env: + EVENT_NAME: ${{ github.event_name }} + GIT_REF: ${{ github.ref }} + PACKAGE_RESULT: ${{ needs.package.result }} + PROVENANCE_RESULT: ${{ needs.provenance.result }} + TEST_RESULT: ${{ needs.test.result }} + run: | + test "$TEST_RESULT" = success + test "$PACKAGE_RESULT" = success + + if [ "$EVENT_NAME" = push ] && [ "$GIT_REF" = refs/heads/main ]; then + test "$PROVENANCE_RESULT" = success + else + test "$PROVENANCE_RESULT" = skipped + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8032645 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,266 @@ +name: GitHub Release + +on: + push: + tags: + - "v*" + +permissions: {} + +concurrency: + group: sdk-release-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + verify: + name: Verify tagged release candidate + runs-on: ubuntu-latest + permissions: + actions: read + attestations: read + contents: read + outputs: + artifact_id: ${{ steps.ci.outputs.artifact_id }} + ci_run_id: ${{ steps.ci.outputs.ci_run_id }} + prerelease: ${{ steps.artifact.outputs.prerelease }} + sdist_name: ${{ steps.artifact.outputs.sdist_name }} + sdist_sha256: ${{ steps.artifact.outputs.sdist_sha256 }} + source_sha: ${{ steps.source.outputs.source_sha }} + tag: ${{ steps.source.outputs.tag }} + version: ${{ steps.source.outputs.version }} + wheel_name: ${{ steps.artifact.outputs.wheel_name }} + wheel_sha256: ${{ steps.artifact.outputs.wheel_sha256 }} + steps: + - name: Check out the exact pushed tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.ref }} + - name: Set up policy Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Verify annotated tag, package version, and main ancestry + id: source + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + test "$(git cat-file -t "refs/tags/$RELEASE_TAG")" = tag + + source_sha="$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}")" + test "$source_sha" = "$(git rev-parse HEAD)" + git merge-base --is-ancestor "$source_sha" refs/remotes/origin/main + + python .github/scripts/release_policy.py validate-source \ + --project-file pyproject.toml \ + --repository "$GITHUB_REPOSITORY" \ + --source-sha "$source_sha" \ + --tag "$RELEASE_TAG" \ + --github-output "$GITHUB_OUTPUT" + - name: Select a successful main CI artifact for the exact SHA + id: ci + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} + run: | + runs_json="$(gh api --method GET \ + "repos/$REPOSITORY/actions/workflows/ci.yml/runs" \ + -f branch=main \ + -f event=push \ + -f head_sha="$SOURCE_SHA" \ + -f per_page=100 \ + -f status=success)" + + selected_run="" + selected_artifact="" + while IFS=$'\t' read -r run_id run_attempt; do + test -n "$run_id" || continue + + jobs_json="$(gh api --method GET \ + "repos/$REPOSITORY/actions/runs/$run_id/jobs" \ + -f filter=latest \ + -f per_page=100)" + required_ok="$(jq '[.jobs[] | select(.name == "Required CI gate" and .conclusion == "success")] | length' <<<"$jobs_json")" + provenance_ok="$(jq '[.jobs[] | select(.name == "Attest main-branch distributions" and .conclusion == "success")] | length' <<<"$jobs_json")" + if [ "$required_ok" -ne 1 ] || [ "$provenance_ok" -ne 1 ]; then + continue + fi + + artifact_name="python-dist-$SOURCE_SHA-$run_attempt" + artifacts_json="$(gh api --method GET \ + "repos/$REPOSITORY/actions/runs/$run_id/artifacts" \ + -f per_page=100)" + artifact_matches="$(jq --arg name "$artifact_name" \ + '[.artifacts[] | select(.name == $name and .expired == false)] | length' \ + <<<"$artifacts_json")" + if [ "$artifact_matches" -ne 1 ]; then + continue + fi + + selected_run="$run_id" + selected_artifact="$(jq -r --arg name "$artifact_name" \ + '.artifacts[] | select(.name == $name and .expired == false) | .id' \ + <<<"$artifacts_json")" + break + done < <(jq -r --arg sha "$SOURCE_SHA" ' + .workflow_runs[] + | select( + .head_sha == $sha + and .head_branch == "main" + and .event == "push" + and .status == "completed" + and .conclusion == "success" + ) + | [.id, .run_attempt] + | @tsv + ' <<<"$runs_json") + + test -n "$selected_run" + test -n "$selected_artifact" + echo "ci_run_id=$selected_run" >> "$GITHUB_OUTPUT" + echo "artifact_id=$selected_artifact" >> "$GITHUB_OUTPUT" + - name: Download the selected immutable CI artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ steps.ci.outputs.artifact_id }} + github-token: ${{ github.token }} + path: dist + repository: ${{ github.repository }} + run-id: ${{ steps.ci.outputs.ci_run_id }} + - name: Verify package metadata, manifest, and byte hashes + id: artifact + env: + RELEASE_TAG: ${{ steps.source.outputs.tag }} + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} + run: | + python .github/scripts/release_policy.py verify-dist \ + --dist dist \ + --project-file pyproject.toml \ + --repository "$GITHUB_REPOSITORY" \ + --source-sha "$SOURCE_SHA" \ + --tag "$RELEASE_TAG" \ + --github-output "$GITHUB_OUTPUT" + - name: Verify GitHub build provenance for the exact main SHA + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} + run: | + for artifact in dist/*.whl dist/*.tar.gz; do + gh attestation verify "$artifact" \ + --repo "$REPOSITORY" \ + --deny-self-hosted-runners \ + --signer-digest "$SOURCE_SHA" \ + --signer-workflow "$REPOSITORY/.github/workflows/ci.yml" \ + --source-digest "$SOURCE_SHA" \ + --source-ref refs/heads/main + done + + publish: + name: Publish GitHub Release + needs: verify + runs-on: ubuntu-latest + environment: + name: sdk-github-release + permissions: + actions: read + attestations: read + contents: write + steps: + - name: Download the exact verified CI artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.verify.outputs.artifact_id }} + github-token: ${{ github.token }} + path: dist + repository: ${{ github.repository }} + run-id: ${{ needs.verify.outputs.ci_run_id }} + - name: Reconcile tag and verified artifact bytes + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SDIST_NAME: ${{ needs.verify.outputs.sdist_name }} + SDIST_SHA256: ${{ needs.verify.outputs.sdist_sha256 }} + SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} + TAG: ${{ needs.verify.outputs.tag }} + WHEEL_NAME: ${{ needs.verify.outputs.wheel_name }} + WHEEL_SHA256: ${{ needs.verify.outputs.wheel_sha256 }} + run: | + case "$WHEEL_NAME" in + *[!A-Za-z0-9._+-]*) exit 1 ;; + esac + case "$WHEEL_NAME" in + *.whl) ;; + *) exit 1 ;; + esac + case "$SDIST_NAME" in + *[!A-Za-z0-9._+-]*) exit 1 ;; + esac + case "$SDIST_NAME" in + *.tar.gz) ;; + *) exit 1 ;; + esac + test -f "dist/$WHEEL_NAME" + test -f "dist/$SDIST_NAME" + test -f dist/release-manifest.json + test "$(find dist -mindepth 1 -maxdepth 1 -type f | wc -l | tr -d ' ')" = 3 + test "$(find dist -mindepth 1 -maxdepth 1 -type l | wc -l | tr -d ' ')" = 0 + test "$(sha256sum "dist/$WHEEL_NAME" | cut -d' ' -f1)" = "$WHEEL_SHA256" + test "$(sha256sum "dist/$SDIST_NAME" | cut -d' ' -f1)" = "$SDIST_SHA256" + + ref_json="$(gh api "repos/$REPOSITORY/git/ref/tags/$TAG")" + object_type="$(jq -r '.object.type' <<<"$ref_json")" + object_sha="$(jq -r '.object.sha' <<<"$ref_json")" + for _ in 1 2 3 4; do + if [ "$object_type" = commit ]; then + break + fi + test "$object_type" = tag + tag_json="$(gh api "repos/$REPOSITORY/git/tags/$object_sha")" + object_type="$(jq -r '.object.type' <<<"$tag_json")" + object_sha="$(jq -r '.object.sha' <<<"$tag_json")" + done + test "$object_type" = commit + test "$object_sha" = "$SOURCE_SHA" + + for artifact in "dist/$WHEEL_NAME" "dist/$SDIST_NAME"; do + gh attestation verify "$artifact" \ + --repo "$REPOSITORY" \ + --deny-self-hosted-runners \ + --signer-digest "$SOURCE_SHA" \ + --signer-workflow "$REPOSITORY/.github/workflows/ci.yml" \ + --source-digest "$SOURCE_SHA" \ + --source-ref refs/heads/main + done + + printf '%s %s\n%s %s\n' \ + "$WHEEL_SHA256" "$WHEEL_NAME" \ + "$SDIST_SHA256" "$SDIST_NAME" \ + > dist/SHA256SUMS + (cd dist && sha256sum --check SHA256SUMS) + - name: Create immutable GitHub Release + env: + GH_TOKEN: ${{ github.token }} + PRERELEASE: ${{ needs.verify.outputs.prerelease }} + SDIST_NAME: ${{ needs.verify.outputs.sdist_name }} + TAG: ${{ needs.verify.outputs.tag }} + VERSION: ${{ needs.verify.outputs.version }} + WHEEL_NAME: ${{ needs.verify.outputs.wheel_name }} + run: | + release_args=( + --repo "$GITHUB_REPOSITORY" + --verify-tag + --title "OGLO Python SDK $VERSION" + --notes-from-tag + ) + if [ "$PRERELEASE" = true ]; then + release_args+=(--prerelease) + fi + + gh release create "$TAG" \ + "dist/$WHEEL_NAME" \ + "dist/$SDIST_NAME" \ + dist/SHA256SUMS \ + "${release_args[@]}" diff --git a/tests/test_release_policy.py b/tests/test_release_policy.py new file mode 100644 index 0000000..6c7e1a3 --- /dev/null +++ b/tests/test_release_policy.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import importlib.util +import io +import json +import tarfile +import zipfile +from pathlib import Path +from types import ModuleType + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] + + +def _load_script(name: str) -> ModuleType: + path = ROOT / ".github" / "scripts" / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, 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 + + +release_policy = _load_script("release_policy") +workflow_policy = _load_script("check_workflows") + + +def _project_file(tmp_path: Path, version: str = "0.1.0rc3") -> Path: + path = tmp_path / "pyproject.toml" + path.write_text( + "[build-system]\nrequires = ['hatchling']\n\n" + f"[project]\nname = 'oglo'\nversion = '{version}'\n", + encoding="utf-8", + ) + return path + + +def _dist_files(tmp_path: Path, version: str = "0.1.0rc3") -> Path: + dist = tmp_path / "dist" + dist.mkdir() + metadata = f"Metadata-Version: 2.4\nName: oglo\nVersion: {version}\n\n".encode() + + wheel = dist / f"oglo-{version}-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr(f"oglo-{version}.dist-info/METADATA", metadata) + + sdist = dist / f"oglo-{version}.tar.gz" + with tarfile.open(sdist, "w:gz") as archive: + info = tarfile.TarInfo(f"oglo-{version}/PKG-INFO") + info.size = len(metadata) + archive.addfile(info, io.BytesIO(metadata)) + return dist + + +def test_release_manifest_round_trip_and_outputs(tmp_path: Path) -> None: + project = _project_file(tmp_path) + dist = _dist_files(tmp_path) + sha = "a" * 40 + manifest = release_policy.build_manifest( + dist_dir=dist, + project_file=project, + repository="OpenGraphLabs/oglo-python", + source_sha=sha, + ) + + assert manifest["tag"] == "v0.1.0rc3" + assert manifest["source_sha"] == sha + result = release_policy.verify_manifest( + dist_dir=dist, + project_file=project, + repository="OpenGraphLabs/oglo-python", + source_sha=sha, + tag="v0.1.0rc3", + ) + assert result["prerelease"] == "true" + assert result["wheel_name"] == "oglo-0.1.0rc3-py3-none-any.whl" + assert result["sdist_name"] == "oglo-0.1.0rc3.tar.gz" + + +def test_release_manifest_rejects_changed_distribution_bytes(tmp_path: Path) -> None: + project = _project_file(tmp_path) + dist = _dist_files(tmp_path) + sha = "b" * 40 + release_policy.build_manifest( + dist_dir=dist, + project_file=project, + repository="OpenGraphLabs/oglo-python", + source_sha=sha, + ) + wheel = dist / "oglo-0.1.0rc3-py3-none-any.whl" + wheel.write_bytes(wheel.read_bytes() + b"tampered") + + with pytest.raises(release_policy.PolicyError, match="bytes do not match"): + release_policy.verify_manifest( + dist_dir=dist, + project_file=project, + repository="OpenGraphLabs/oglo-python", + source_sha=sha, + tag="v0.1.0rc3", + ) + + +@pytest.mark.parametrize( + ("tag", "sha"), + [ + ("v0.1.0rc2", "a" * 40), + ("0.1.0rc3", "a" * 40), + ("v0.1.0rc3", "A" * 40), + ("v0.1.0rc3", "abc"), + ], +) +def test_source_policy_rejects_tag_or_sha_mismatch(tag: str, sha: str) -> None: + with pytest.raises(release_policy.PolicyError): + release_policy.validate_source( + version="0.1.0rc3", + tag=tag, + repository="OpenGraphLabs/oglo-python", + source_sha=sha, + ) + + +def test_manifest_rejects_forged_source_identity(tmp_path: Path) -> None: + project = _project_file(tmp_path) + dist = _dist_files(tmp_path) + sha = "c" * 40 + release_policy.build_manifest( + dist_dir=dist, + project_file=project, + repository="OpenGraphLabs/oglo-python", + source_sha=sha, + ) + manifest_path = dist / release_policy.MANIFEST_NAME + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["source_sha"] = "d" * 40 + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + with pytest.raises(release_policy.PolicyError, match="source SHA mismatch"): + release_policy.verify_manifest( + dist_dir=dist, + project_file=project, + repository="OpenGraphLabs/oglo-python", + source_sha=sha, + tag="v0.1.0rc3", + ) + + +def test_repository_workflows_satisfy_release_policy() -> None: + workflow_policy.check_repository(ROOT) + + +def test_workflow_policy_rejects_unpinned_action(tmp_path: Path) -> None: + workflow = tmp_path / "unsafe.yml" + text = "steps:\n - uses: actions/checkout@v7\n" + with pytest.raises(workflow_policy.WorkflowPolicyError, match="full SHA"): + workflow_policy.check_action_pins(workflow, text) + + +def test_workflow_policy_rejects_unpinned_reusable_workflow(tmp_path: Path) -> None: + workflow = tmp_path / "unsafe-reusable.yml" + text = "jobs:\n inherited:\n uses: owner/repository/.github/workflows/ci.yml@main\n" + with pytest.raises(workflow_policy.WorkflowPolicyError, match="full SHA"): + workflow_policy.check_action_pins(workflow, text) + + +def test_workflow_policy_rejects_pr_path_filter(tmp_path: Path) -> None: + workflow = tmp_path / "ci.yml" + text = """on: + pull_request: + paths: + - src/** +name: Required CI gate +if: ${{ always() }} +name: Build and install distributions +name: Attest main-branch distributions +python-dist-${{ github.sha }}-${{ github.run_attempt }} +actions/attest-build-provenance@ +id-token: write +attestations: write +""" + with pytest.raises(workflow_policy.WorkflowPolicyError, match="path filters"): + workflow_policy.check_ci(workflow, text) From e47e16f60fcf55a65fbb7020c4ee113bfea768f8 Mon Sep 17 00:00:00 2001 From: Beomsoo Son Date: Wed, 12 Aug 2026 22:08:28 +0900 Subject: [PATCH 2/2] fix: make GitHub release retries immutable --- .github/scripts/check_workflows.py | 4 + .github/workflows/release.yml | 116 +++++++++++++++++++++++++---- tests/test_release_policy.py | 14 ++++ 3 files changed, 120 insertions(+), 14 deletions(-) diff --git a/.github/scripts/check_workflows.py b/.github/scripts/check_workflows.py index 95cb9cf..ef52f60 100644 --- a/.github/scripts/check_workflows.py +++ b/.github/scripts/check_workflows.py @@ -57,6 +57,10 @@ def check_release(path: Path, text: str) -> None: _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") diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8032645..cfc2a4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -240,27 +240,115 @@ jobs: "$SDIST_SHA256" "$SDIST_NAME" \ > dist/SHA256SUMS (cd dist && sha256sum --check SHA256SUMS) - - name: Create immutable GitHub Release + - name: Create or reconcile immutable GitHub Release env: GH_TOKEN: ${{ github.token }} PRERELEASE: ${{ needs.verify.outputs.prerelease }} + SDIST_SHA256: ${{ needs.verify.outputs.sdist_sha256 }} SDIST_NAME: ${{ needs.verify.outputs.sdist_name }} + SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} TAG: ${{ needs.verify.outputs.tag }} VERSION: ${{ needs.verify.outputs.version }} + WHEEL_SHA256: ${{ needs.verify.outputs.wheel_sha256 }} WHEEL_NAME: ${{ needs.verify.outputs.wheel_name }} run: | - release_args=( - --repo "$GITHUB_REPOSITORY" - --verify-tag - --title "OGLO Python SDK $VERSION" - --notes-from-tag - ) - if [ "$PRERELEASE" = true ]; then - release_args+=(--prerelease) + set -euo pipefail + title="OGLO Python SDK $VERSION" + expected_assets="$(printf '%s\n' "$WHEEL_NAME" "$SDIST_NAME" SHA256SUMS | sort)" + + verify_release_metadata() { + local release_json="$1" + local allow_partial="$2" + jq -e \ + --arg source_sha "$SOURCE_SHA" \ + --arg tag "$TAG" \ + --arg title "$title" \ + --argjson prerelease "$PRERELEASE" \ + '.tag_name == $tag and + .target_commitish == $source_sha and + .name == $title and + .prerelease == $prerelease' \ + "$release_json" >/dev/null + + local observed_assets + observed_assets="$(jq -r '.assets[].name' "$release_json" | sort)" + if [ "$allow_partial" = true ]; then + comm -13 \ + <(printf '%s\n' "$expected_assets") \ + <(printf '%s\n' "$observed_assets") | + test "$(cat)" = "" + else + test "$observed_assets" = "$expected_assets" + fi + test "$(jq '[.assets[].name] | length' "$release_json")" = \ + "$(jq '[.assets[].name] | unique | length' "$release_json")" + } + + download_and_verify_asset() { + local release_json="$1" + local name="$2" + local expected_sha="$3" + local asset_id + asset_id="$(jq -r --arg name "$name" '.assets[] | select(.name == $name) | .id' "$release_json")" + test "$asset_id" != "" + gh api \ + --header 'Accept: application/octet-stream' \ + "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \ + >"$RUNNER_TEMP/release-$name" + test "$(sha256sum "$RUNNER_TEMP/release-$name" | cut -d' ' -f1)" = "$expected_sha" + } + + sums_sha256="$(sha256sum dist/SHA256SUMS | cut -d' ' -f1)" + release_json="$RUNNER_TEMP/sdk-release.json" + if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" >"$release_json" 2>/dev/null; then + # A failed prior upload may leave a draft behind. Existing bytes + # are immutable: verify them, add only missing assets to that + # draft, and never overwrite a mismatched file. + is_draft="$(jq -r .draft "$release_json")" + verify_release_metadata "$release_json" "$is_draft" + else + release_args=( + --repo "$GITHUB_REPOSITORY" + --verify-tag + --target "$SOURCE_SHA" + --title "$title" + --notes-from-tag + --draft + ) + if [ "$PRERELEASE" = true ]; then + release_args+=(--prerelease) + fi + gh release create "$TAG" "${release_args[@]}" + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" >"$release_json" + is_draft=true + verify_release_metadata "$release_json" true fi - gh release create "$TAG" \ - "dist/$WHEEL_NAME" \ - "dist/$SDIST_NAME" \ - dist/SHA256SUMS \ - "${release_args[@]}" + while IFS=$'\t' read -r name path expected_sha; do + if jq -e --arg name "$name" '.assets[] | select(.name == $name)' "$release_json" >/dev/null; then + download_and_verify_asset "$release_json" "$name" "$expected_sha" + else + test "$is_draft" = true + gh release upload "$TAG" "$path" --repo "$GITHUB_REPOSITORY" + fi + done <"$release_json" + verify_release_metadata "$release_json" false + download_and_verify_asset "$release_json" "$WHEEL_NAME" "$WHEEL_SHA256" + download_and_verify_asset "$release_json" "$SDIST_NAME" "$SDIST_SHA256" + download_and_verify_asset "$release_json" SHA256SUMS "$sums_sha256" + + if [ "$(jq -r .draft "$release_json")" = true ]; then + gh release edit "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --draft=false \ + --verify-tag + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" >"$release_json" + fi + test "$(jq -r .draft "$release_json")" = false + verify_release_metadata "$release_json" false diff --git a/tests/test_release_policy.py b/tests/test_release_policy.py index 6c7e1a3..24efb08 100644 --- a/tests/test_release_policy.py +++ b/tests/test_release_policy.py @@ -150,6 +150,20 @@ def test_repository_workflows_satisfy_release_policy() -> None: workflow_policy.check_repository(ROOT) +def test_release_workflow_reconciles_partial_drafts_without_overwrite() -> None: + text = (ROOT / ".github" / "workflows" / "release.yml").read_text( + encoding="utf-8" + ) + + assert "--draft" in text + assert "--draft=false" in text + assert '--target "$SOURCE_SHA"' in text + assert "download_and_verify_asset" in text + assert 'test "$is_draft" = true' in text + assert "gh release upload" in text + assert "--clobber" not in text + + def test_workflow_policy_rejects_unpinned_action(tmp_path: Path) -> None: workflow = tmp_path / "unsafe.yml" text = "steps:\n - uses: actions/checkout@v7\n"