Skip to content

security: gate releases on reviewed main provenance - #375

Merged
codeforester merged 1 commit into
mainfrom
security/271-20260927-v1-0-gate-releases-on-protected-main-and-independent
Sep 29, 2026
Merged

codeforester merged 1 commit into
mainfrom
security/271-20260927-v1-0-gate-releases-on-protected-main-and-independent

Conversation

@codeforester

@codeforester codeforester commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • add a fail-closed provenance validator for annotated tags, exact reviewed SHA, trusted main ancestry, complete history, and forced/deleted tag events
  • make publication, attestations, and GitHub Release creation depend on the same provenance job
  • document and test the release contract and negative paths

Repository controls

  • tightened the existing default-branch ruleset to require one approval, stale-review dismissal, last-push approval, strict status checks, and policy/quality/runtime/consumer checks
  • disabled PyPI self-review and administrator bypass; production release now waits for an independent reviewer tracked by [v1.0] Expand maintainer capacity and document project governance #252

Validation

  • full test suite: 391 passed
  • targeted provenance/workflow tests: 11 passed
  • Ruff check, Ruff format check, and strict mypy pass

Fixes #271

@codeforester codeforester self-assigned this Sep 29, 2026
@codeforester

Copy link
Copy Markdown
Contributor Author

Validation refresh (2026-09-30):

  • Existing hosted checks are green, including the Package, quality/security, platform, consumer, examples, and issue-branch-policy gates.
  • Local targeted tests pass: 11 tests.
  • Local full suite passes.
  • Ruff check and format check pass for the changed files.
  • Strict mypy passes for scripts/validate_release_provenance.py.
  • git diff --check is clean.

The repository-side #271 controls are now also complete: pypi lists codeforester and independent reviewer rameshulugar, with self-review prevention enabled.

@rameshulugar

Copy link
Copy Markdown
Collaborator

This looks good.

@codeforester
codeforester merged commit 40da6be into main Sep 29, 2026
137 of 138 checks passed
@codeforester
codeforester deleted the security/271-20260927-v1-0-gate-releases-on-protected-main-and-independent branch September 29, 2026 19:47
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--event-name", default=os.environ.get("GITHUB_EVENT_NAME", ""))
parser.add_argument("--event-path", type=Path, default=os.environ.get("GITHUB_EVENT_PATH", ""))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: default=os.environ.get("GITHUB_EVENT_PATH", "") combined with type=Path makes the guard below dead code. argparse applies type to a string default, so when GITHUB_EVENT_PATH is unset the default becomes Path("") → PosixPath('.'), which is always truthy (Path has no __bool__). Reproduced directly: bool(Path('')) is True, so if not args.event_path: (line 115) never fires.

Failure scenario: GITHUB_EVENT_PATH unset and --event-path not passed (local/manual invocation, or a future workflow change that drops the env var) with ref_type=="tag". The intended clear error ("GitHub event payload is required...") never fires; instead _read_event_flags(Path('.')) calls .read_text() on a directory, raises IsADirectoryError (an OSError subclass), and gets caught into a confusing "could not read the GitHub event payload: [Errno 21] Is a directory" message. It still fails closed today only by accident via that except clause — the intended guard itself is unreachable, so a future tweak to that except clause could silently flip this to fail-open. Suggest defaulting to None (not Path-typed) and checking args.event_path is None.

tag_type=tag_type,
resolved_tag_commit=resolved_tag_commit,
main_reachable=main_reachable,
repository_shallow=_git_output("rev-parse", "--is-shallow-repository") == "true",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness (fail-open, inconsistent with main_reachable's handling of the same failure mode): repository_shallow=_git_output("rev-parse", "--is-shallow-repository") == "true". _git_output swallows any non-zero git exit into "". For main_reachable (lines 103/109), that same swallowing defaults to False, which correctly fails the tag-release gate (fail closed). But for repository_shallow, "" == "true" is also False, and False here means "not shallow" — i.e. the check passes.

Failure scenario: any git-command failure in this step (corrupted checkout, an unsupported flag on a future git version, a transient runner issue) silently waves through the shallow-history gate instead of raising an error, even though this module's own docstring promises "fail closed unless a publication comes from a reviewed main-line commit." Consider treating a non-zero exit here as an error (e.g. _git(...) returning a failure tuple should itself append a violation) rather than coercing it to a boolean that happens to mean "safe."

return validate_release_provenance(**values) # type: ignore[arg-type]


class ReleaseProvenanceValidationTests(unittest.TestCase):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap: every test in this file calls validate_release_provenance() directly with hand-built kwargs — none exercise main(), --event-path argparse wiring, _read_event_flags(), or the _git/_git_output subprocess wrappers. Both of the bugs I flagged on validate_release_provenance.py (the Path('') dead-code guard at line 93/115, and the fail-open shallow-repo check at line 129) live entirely in this untested glue code, so the full test suite gives no signal for either. Worth adding at least one test that drives main() end-to-end (e.g. via subprocess or by refactoring the argparse/env wiring into a small testable seam) to cover the actual production entrypoint, not just the pure validation function.

errors = valid(resolved_tag_commit="b" * 40)
self.assertTrue(any("does not resolve" in error for error in errors))

def test_rejects_forced_tag_update(self) -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap (asymmetric coverage): there's a test_rejects_forced_tag_update (forced=True) but no counterpart for deleted=True, even though validate_release_provenance() treats them symmetrically (if forced: ... / if deleted: ..., scripts/validate_release_provenance.py lines ~57-58). A regression on the deleted branch specifically (typo'd variable, inverted condition, accidentally removed check) would ship green. Suggest adding a test_rejects_deleted_tag_event mirroring the forced-tag test.

assert "name: Verify reviewed release provenance" in workflow
assert 'git fetch --no-tags --prune origin "refs/heads/main:refs/remotes/origin/main"' in workflow
assert "python scripts/validate_release_provenance.py" in workflow
assert "needs: [build, smoke, provenance]" in workflow

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap (weak assertion on a security-critical gate): assert "needs: [build, smoke, provenance]" in workflow is a plain substring check. In the actual workflow, both the publish job and the attest job declare needs: [build, smoke, provenance] verbatim — this assertion can't distinguish "both jobs have the gate" from "only one does." If attest's needs: were ever reverted to [build, smoke] (silently dropping the provenance gate on the attestation step) while publish kept the full list, this assertion would still pass because the substring still occurs once, via publish. Consider asserting on each job's needs: line individually (e.g. locate the attest: job block and assert its own needs: line), so a regression on either job is caught independently.

if repository_shallow:
errors.append("release provenance cannot be verified from a shallow repository")

production_release = (event_name == "push" and ref_type == "tag") or publish_target == "pypi"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup (minor): production_release = (event_name == "push" and ref_type == "tag") or publish_target == "pypi" is only used once, at if production_release and ref_type != "tag": (next line) — which is logically equivalent to if publish_target == "pypi" and ref_type != "tag":, since the first disjunct of production_release can never make ref_type != "tag" true (it requires ref_type == "tag"). Not a functional bug, just dead weight that could confuse a future maintainer editing this gate into thinking event_name/the first disjunct matters here.

@codeforester

Copy link
Copy Markdown
Contributor Author

Additional finding (file not touched by this PR's diff, so not postable as an inline comment):

scripts/validate_release_ref.py:19 — the v-prefix tag check added in this PR (scripts/validate_release_provenance.py, if not tag.startswith("v") or tag == "v": ..."release tag must be a v-prefixed version, got {tag!r}") is byte-for-byte duplicated from this pre-existing script. scripts/release_metadata_helpers.py already exists as the shared module for release-validation logic (currently just sha256_file) — a natural home for a validate_tag_prefix(tag) -> str | None helper so both scripts stay in sync if the v-prefix rule ever changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[v1.0] Gate releases on protected main lineage and independent approval

2 participants