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
41 changes: 8 additions & 33 deletions .github/protected-pr-ci.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": 1,
"version": 2,
"default_branch": "main",
"workflow_file": ".github/workflows/pr-ci.yml",
"required_check": "Required CI",
Expand Down Expand Up @@ -27,14 +27,14 @@
"commit_policy",
"workflow_lint",
"static_checks",
"rust"
"rust",
"candidate_ci"
],
"sensitive_paths": [
"CODEOWNERS",
"**/CODEOWNERS",
".github/**",
"candidate_ci_paths": [
".cargo/**",
"**/.cargo/**",
".github/workflows/ci.yml",
".github/workflows/pr-ci.yml",
"rust-toolchain",
"rust-toolchain.toml",
"**/rust-toolchain",
Expand All @@ -49,36 +49,11 @@
"**/.clippy.toml",
".rumdl.toml",
"rumdl.toml",
".markdownlint.json",
".markdownlint.yaml",
"Cargo.toml",
"Cargo.lock",
"**/Cargo.toml",
"**/Cargo.lock",
"build.rs",
"**/build.rs",
"benches/**",
"**/benches/**",
"examples/**",
"**/examples/**",
"AGENTS.md",
"**/AGENTS.md",
".agents/**",
"**/.agents/**",
"RELEASING.md",
".release-plz.toml",
"release-plz.toml",
".gitmodules",
"source-spec/**",
"deny.toml",
"deny.exceptions.toml",
"crates/yaml-sigil-core/buf.yaml",
"crates/yaml-sigil-core/buf.lock",
"crates/yaml-sigil-core/buf.gen.yaml",
"crates/yaml-sigil-core/**",
"crates/yaml-sigil-transcription/**",
"crates/yaml-sigil-signing/**",
"crates/yaml-sigil-verification/**",
"crates/yaml-sigil-test-keys/**",
"crates/yaml-sigil-conformance/**",
"xtask/**"
]
}
5 changes: 3 additions & 2 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@
contract in
[yaml-sigil-traits](https://github.com/NVIDIA/yaml-sigil-traits); any required
traits changes are already available or tracked as coordinated work.
- [ ] I have the right to submit this contribution and every commit includes a
`Signed-off-by` trailer.
- [ ] I have the right to submit this contribution, every commit is GitHub
Verified, and every commit includes a `Signed-off-by` trailer that exactly
matches its Git author.
- [ ] I understand and can explain this change.
- [ ] I updated documentation or tests where needed.
- [ ] I reviewed `CONTRIBUTING.md` and `SECURITY.md`.
106 changes: 62 additions & 44 deletions .github/scripts/protected_pr_ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
The workflow that invokes this module is loaded from the protected default
branch. Candidate commits are treated only as data until ``authorize`` has
checked the exact command, current repository state, live permissions, and
sensitive-path policy. Check runs are created and updated only with the
contributor-commit policy. Check runs are created and updated only with the
repository's narrowly scoped GitHub App token.

This module intentionally uses only the Python standard library so privileged
Expand Down Expand Up @@ -134,10 +134,10 @@ def load_config(path: str) -> Mapping[str, Any]:
"required_check",
"release_app",
"expected_jobs",
"sensitive_paths",
"candidate_ci_paths",
}
require(set(config) == required, "policy configuration keys are incomplete or ambiguous")
require(config["version"] == 1, "unsupported policy configuration version")
require(config["version"] == 2, "unsupported policy configuration version")
require(config["default_branch"] == "main", "the protected branch must be exact main")
validate_path(config["workflow_file"], "workflow_file")
require(config["required_check"] == CHECK_NAME, f"required_check must be {CHECK_NAME!r}")
Expand Down Expand Up @@ -204,11 +204,17 @@ def load_config(path: str) -> Mapping[str, Any]:
f"expected_jobs[{index}] is not a job identifier",
)

patterns = require_sequence(config["sensitive_paths"], "sensitive_paths")
require(patterns and len(set(patterns)) == len(patterns), "sensitive_paths must be nonempty and unique")
patterns = require_sequence(config["candidate_ci_paths"], "candidate_ci_paths")
require(
patterns and len(set(patterns)) == len(patterns),
"candidate_ci_paths must be nonempty and unique",
)
for index, pattern in enumerate(patterns):
require_string(pattern, f"sensitive_paths[{index}]")
require("\\" not in pattern and not pattern.startswith("/"), f"sensitive_paths[{index}] is invalid")
require_string(pattern, f"candidate_ci_paths[{index}]")
require(
"\\" not in pattern and not pattern.startswith("/"),
f"candidate_ci_paths[{index}] is invalid",
)
return config


Expand Down Expand Up @@ -351,7 +357,7 @@ def normalized_casefold(value: str) -> str:
return unicodedata.normalize("NFKC", normalized.casefold())


def is_sensitive(path: str, patterns: Sequence[str]) -> bool:
def matches_path_inventory(path: str, patterns: Sequence[str]) -> bool:
"""Match declarations, treating a trailing ``/**`` as including its root."""
identity = normalized_casefold(path)
for pattern in patterns:
Expand All @@ -365,13 +371,10 @@ def is_sensitive(path: str, patterns: Sequence[str]) -> bool:
return False


def commit_identities(commit: Mapping[str, Any]) -> tuple[str, str]:
def commit_author_identity(commit: Mapping[str, Any]) -> str:
details = require_mapping(commit.get("commit"), "commit details")
author = require_mapping(details.get("author"), "commit author")
committer = require_mapping(details.get("committer"), "commit committer")
author_identity = f"{require_string(author.get('name'), 'commit author name')} <{require_string(author.get('email'), 'commit author email')}>"
committer_identity = f"{require_string(committer.get('name'), 'commit committer name')} <{require_string(committer.get('email'), 'commit committer email')}>"
return author_identity, committer_identity
return f"{require_string(author.get('name'), 'commit author name')} <{require_string(author.get('email'), 'commit author email')}>"


def signoffs(message: Any) -> set[str]:
Expand All @@ -391,13 +394,11 @@ def require_verified(commit: Mapping[str, Any], label: str) -> None:
require(verification.get("reason") == "valid", f"{label} verification is not valid")


def require_dco(commit: Mapping[str, Any], *, require_committer: bool, label: str) -> None:
def require_author_dco(commit: Mapping[str, Any], *, label: str) -> None:
details = require_mapping(commit.get("commit"), f"{label} details")
found = signoffs(details.get("message"))
author, committer = commit_identities(commit)
author = commit_author_identity(commit)
require(author in found, f"{label} lacks the author's DCO sign-off")
if require_committer:
require(committer in found, f"{label} lacks the adopting committer's DCO sign-off")


def current_main(api: GitHubApi, repository: str, branch: str) -> str:
Expand Down Expand Up @@ -690,29 +691,23 @@ def require_release_app_change(
"release App raw commit committer email is unexpected",
)
require_verified(commit, "release App commit")
require_dco(commit, require_committer=False, label="release App commit")
require_author_dco(commit, label="release App commit")


def require_adopted_change(
def require_contributor_change(
api: GitHubApi,
repository: str,
pull: Mapping[str, Any],
commits: Sequence[Mapping[str, Any]],
) -> None:
head = require_mapping(pull.get("head"), "pull request head")
head_repo = require_mapping(head.get("repo"), "pull request head repository")
require(head_repo.get("full_name") == repository, "sensitive changes require a same-repository branch")

for index, summary in enumerate(commits):
sha = validate_sha(summary.get("sha"), f"adopted commit {index} SHA")
sha = validate_sha(summary.get("sha"), f"contributor commit {index} SHA")
commit = full_commit(api, repository, sha)
parents = require_sequence(commit.get("parents"), f"adopted commit {index} parents")
require(len(parents) == 1, f"adopted commit {index} must be linear")
require_verified(commit, f"adopted commit {index}")
committer = require_mapping(commit.get("committer"), f"adopted commit {index} GitHub committer")
login = validate_login(committer.get("login"), f"adopted commit {index} GitHub committer login")
require_writer(api, repository, login, f"adopted commit {index} committer")
require_dco(commit, require_committer=True, label=f"adopted commit {index}")
parents = require_sequence(
commit.get("parents"), f"contributor commit {index} parents"
)
require(len(parents) == 1, f"contributor commit {index} must be linear")
require_verified(commit, f"contributor commit {index}")
require_author_dco(commit, label=f"contributor commit {index}")


@dataclass(frozen=True)
Expand All @@ -725,6 +720,7 @@ class Authorization:
head_ref: str
policy_sha: str
comment_id: int
candidate_ci_required: bool

def github_outputs(self) -> Mapping[str, str]:
return {
Expand All @@ -735,6 +731,7 @@ def github_outputs(self) -> Mapping[str, str]:
"head_repository": self.head_repository,
"policy_sha": self.policy_sha,
"comment_id": str(self.comment_id),
"candidate_ci_required": str(self.candidate_ci_required).lower(),
}


Expand Down Expand Up @@ -796,17 +793,28 @@ def authorize(
require(validate_sha(commits[-1].get("sha"), "last pull request commit SHA") == head_sha, "commit list does not end at pull request head")
require_commit_chain(commits, base_sha, head_sha)

patterns = [require_string(value, "sensitive path pattern") for value in require_sequence(config.get("sensitive_paths"), "sensitive_paths")]
sensitive = [path for path in paths if is_sensitive(path, patterns)]
if sensitive:
release_app = require_mapping(config.get("release_app"), "release_app")
user = require_mapping(pull.get("user"), "pull request author")
if user.get("login") == release_app.get("login"):
require_release_app_change(
api, repository, pull, commits, paths, statuses, main_sha, release_app
)
else:
require_adopted_change(api, repository, pull, commits)
release_app = require_mapping(config.get("release_app"), "release_app")
user = require_mapping(pull.get("user"), "pull request author")
is_release_app = (
user.get("login") == release_app.get("login")
or user.get("id") == release_app.get("bot_user_id")
)
if is_release_app:
require_release_app_change(
api, repository, pull, commits, paths, statuses, main_sha, release_app
)
else:
require_contributor_change(api, repository, commits)

patterns = [
require_string(value, "candidate CI path pattern")
for value in require_sequence(
config.get("candidate_ci_paths"), "candidate_ci_paths"
)
]
candidate_ci_required = any(
matches_path_inventory(path, patterns) for path in paths
)

require_live_authorization_state(
api,
Expand All @@ -828,6 +836,7 @@ def authorize(
head_ref=head_ref,
policy_sha=policy_sha,
comment_id=comment_id,
candidate_ci_required=candidate_ci_required,
)


Expand Down Expand Up @@ -1183,7 +1192,16 @@ def finish_check(
require(current.policy_sha == external.policy_sha, "policy commit changed before reporting")
expected = [require_string(value, "expected job") for value in require_sequence(config.get("expected_jobs"), "expected_jobs")]
results = parse_results(result_values, expected)
failed = [(job, result) for job, result in results.items() if result != "success"]
failed = [
(job, result)
for job, result in results.items()
if result != "success"
and not (
job == "candidate_ci"
and result == "skipped"
and not current.candidate_ci_required
)
]
if failed:
details = ", ".join(f"{job}={result}" for job, result in failed)
raise PolicyError(f"required candidate jobs did not all succeed: {details}")
Expand Down
Loading