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
36 changes: 35 additions & 1 deletion docs/ci-evidence.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ and each of these must hold, or nothing is reused:

| Input | Bound by |
| --- | --- |
| the trusted relationship | records that survive the merge and that the pull request's code cannot write, read through the API: one pull request of this repository (not a fork, base repository this one) merged into the trusted branch with M as its merge commit; its head branch's listing (`pulls?head=owner:branch&state=all`, every page to an empty one, ten at most) shows exactly that one pull request ever, from this repository; its timeline (every page) has no base-change event of any kind (`base_ref_changed`, `automatic_base_change_succeeded`, `automatic_base_change_failed`, and any event whose name contains `base_change` or `base_ref`); the newest `pull_request` run of the same workflow is at its final head, from this repository, on its head branch, and was created after the pull request was opened. The run's own `pull_requests`, which GitHub clears once a pull request merges, must name exactly that pull request into the trusted branch when it is not empty. Anything of this that cannot be read refuses (`reuse-pull-request`) |
| the trusted relationship | records that survive the merge and that the pull request's code cannot write, read through the API: one pull request of this repository (not a fork, base repository this one) merged into the trusted branch with exactly one timeline `merged` event whose `commit_id` equals M (the pinned API has no `merge_commit_sha` on PR objects); its head branch's listing (`pulls?head=owner:branch&state=all`, every page to an empty one, ten at most) shows exactly that one pull request ever, from this repository; its timeline (every page) has no base-change event of any kind (`base_ref_changed`, `automatic_base_change_succeeded`, `automatic_base_change_failed`, and any event whose name contains `base_change` or `base_ref`); the newest `pull_request` run of the same workflow is at its final head, from this repository, on its head branch, and was created after the pull request was opened. The run's own `pull_requests`, which GitHub clears once a pull request merges, must name exactly that pull request into the trusted branch when it is not empty. Anything of this that cannot be read refuses (`reuse-pull-request`) |
| exact source and tree | the merge commit the pull request job checked out (S), read through the API: its parents are M's first parent and the pull request's head, and its tree is M's tree |
| the base relationship | S was made on M's first parent: a change set tested on another base is not M's, even with the same tree (a rebase merge of several commits therefore never qualifies) |
| workflow, configuration and dependency digests | the pull request job's input identities (the manifest, the configuration, the gate, the workflow files, every pin file) equal M's |
Expand Down Expand Up @@ -698,3 +698,37 @@ the fresh route's full CI run and a re-run.
GitHub's API contracts: [workflow runs](https://docs.github.com/en/rest/actions/workflow-runs),
[attempt jobs](https://docs.github.com/en/rest/actions/workflow-jobs),
[artifacts](https://docs.github.com/en/rest/actions/artifacts).


## Pinned API response audit

The gate sends API version `2026-03-10`. Read-only response projections captured on
2026-11-01 are in `tests/fixture/github-api-2026-03-10.json`, from
`judgment-pack-agent` PR #267 and run 41149914554, and the annotated
`judgment-pack-devkit` v0.13.0 tag. The fixture records its source identities and the
consumed field paths. Unrelated fields and personal identities are omitted. These
are API contract samples, not execution evidence or generated acceptance vectors.
Handwritten decision/refusal vectors remain in `tests/release_evidence_test.py`.

| API record | Fields read by the gate |
| --- | --- |
| Repository | `id`, `full_name`, `default_branch` |
| Annotated tag reference/object | reference `ref`; `object.type`, `object.sha` at each peel |
| Workflow | `id`, `path`, `name`, `state` |
| Run listing/single run | `id`, `run_attempt`, `event`, `head_branch`, `head_sha`, `workflow_id`, `path`, `status`, `conclusion`, `created_at`, `run_started_at`, `updated_at`, `repository.id/full_name`, `head_repository.id/full_name`, `pull_requests` |
| Nonempty run PR association | `number`, `base.ref`, `base.repo.id` |
| Job | `id`, `name`, `run_id`, `run_attempt`, `head_sha`, `head_branch`, `workflow_name`, `status`, `conclusion`, `labels`, `runner_group_id`, `runner_group_name`, `started_at`, `completed_at` |
| Artifact | `id`, `name`, `expired`, `size_in_bytes`, `created_at`, `digest`, `workflow_run.id/head_sha/head_branch/repository_id/head_repository_id`; ZIP bytes |
| Git commit | `sha`, `tree.sha`, `parents[].sha` |
| Commit-associated PR | `number`, `state`, `created_at`, `merged_at`, `head.sha/ref/repo.id`, `base.ref/repo.id` |
| Head-branch PR listing | `number`, `head.repo.id` |
| PR timeline | `event`; a `merged` event's `commit_id` |
| Paged wrappers | `total_count` and `workflow_runs`, `jobs` or `artifacts`; every row's `id` for uniqueness |

All listed paths were checked against the captured records. The merged run has an
empty PR association; a separate captured run, 41223435315, checks the nonempty
association fields.
`merge_commit_sha` is absent on both the commit-associated PR and single-PR
responses. Missing, unreadable, malformed or ambiguous merge events refuse with
`reuse-pull-request`; no PR-supplied record substitutes for the timeline. The same
complete timeline is used for the existing base-change checks.
28 changes: 23 additions & 5 deletions lib/release_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -1041,16 +1041,35 @@ def pull_request_of(api, config, source):
need(commit['sha'] == sha and commit['parents'], 'reuse-pull-request', 'commit %s' % sha)
pulls = api.get('commits/%s/pulls?per_page=100' % sha)
need(isinstance(pulls, list) and len(pulls) < 100, 'reuse-pull-request', 'the pull request listing')
merged = [pull for pull in pulls if pull['merge_commit_sha'] == sha and pull['merged_at']]
# The pinned API does not expose merge_commit_sha on pull request objects. The
# server's timeline is the surviving merge-to-commit binding, not PR-authored evidence.
merged = []
try:
for pull in pulls:
if not pull['merged_at']:
continue
need(positive(pull['number']), 'reuse-pull-request', 'invalid pull request number')
events = listing(api, 'issues/%d/timeline' % pull['number'])
merges = [event for event in events if event.get('event') == 'merged']
need(len(merges) == 1 and isinstance(merges[0].get('commit_id'), str) and
SHA.fullmatch(merges[0]['commit_id']), 'reuse-pull-request',
'pull request %d has no unique readable merged event' % pull['number'])
if merges[0]['commit_id'] == sha:
merged.append((pull, events))
except (Refusal,) + MALFORMED as error:
if isinstance(error, Refusal) and error.code == 'reuse-pull-request':
raise
raise Refusal('reuse-pull-request', 'the merged pull request timeline could not be read: %s' % (
error.code if isinstance(error, Refusal) else type(error).__name__)) from error
need(len(merged) == 1, 'reuse-pull-request', '%d pull requests were merged as %s' % (len(merged), sha))
pull = merged[0]
pull, events = merged[0]
need(pull['state'] == 'closed' and pull['base']['ref'] == config['branch'] and
pull['base']['repo']['id'] == source['repository_id'] and positive(pull['number']) and
SHA.fullmatch(pull['head']['sha']), 'reuse-pull-request',
'pull request %s is not merged into %s here' % (pull.get('number'), config['branch']))
need((pull['head']['repo'] or {}).get('id') == source['repository_id'], 'reuse-pull-request',
'pull request %s comes from another repository' % pull['number'])
bind_head_branch(api, config, source, pull)
bind_head_branch(api, config, source, pull, events)
return pull, commit


Expand All @@ -1073,7 +1092,7 @@ def listing(api, suffix):
BASE_EVENT = re.compile(r'base_change|base_ref')


def bind_head_branch(api, config, source, pull):
def bind_head_branch(api, config, source, pull, events):
"""Records that survive the merge and that the pull request's code cannot write, binding its
head branch to it alone: the head branch has had exactly one pull request ever, the merged one,
from this repository (a run of another one executes another base's workflow at the same head),
Expand All @@ -1082,7 +1101,6 @@ def bind_head_branch(api, config, source, pull):
try:
pulls = listing(api, 'pulls?head=%s:%s&state=all' % (owner, quote(pull['head']['ref'], safe='')))
listed = [(other['number'], (other['head']['repo'] or {}).get('id')) for other in pulls]
events = listing(api, 'issues/%d/timeline' % pull['number'])
names = [event.get('event') for event in events]
need(all(isinstance(name, str) or name is None for name in names), 'unavailable-or-malformed')
changed = sorted({name for name in names if name and BASE_EVENT.search(name)})
Expand Down
28 changes: 23 additions & 5 deletions scripts/dev/release_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -1042,16 +1042,35 @@ def pull_request_of(api, config, source):
need(commit['sha'] == sha and commit['parents'], 'reuse-pull-request', 'commit %s' % sha)
pulls = api.get('commits/%s/pulls?per_page=100' % sha)
need(isinstance(pulls, list) and len(pulls) < 100, 'reuse-pull-request', 'the pull request listing')
merged = [pull for pull in pulls if pull['merge_commit_sha'] == sha and pull['merged_at']]
# The pinned API does not expose merge_commit_sha on pull request objects. The
# server's timeline is the surviving merge-to-commit binding, not PR-authored evidence.
merged = []
try:
for pull in pulls:
if not pull['merged_at']:
continue
need(positive(pull['number']), 'reuse-pull-request', 'invalid pull request number')
events = listing(api, 'issues/%d/timeline' % pull['number'])
merges = [event for event in events if event.get('event') == 'merged']
need(len(merges) == 1 and isinstance(merges[0].get('commit_id'), str) and
SHA.fullmatch(merges[0]['commit_id']), 'reuse-pull-request',
'pull request %d has no unique readable merged event' % pull['number'])
if merges[0]['commit_id'] == sha:
merged.append((pull, events))
except (Refusal,) + MALFORMED as error:
if isinstance(error, Refusal) and error.code == 'reuse-pull-request':
raise
raise Refusal('reuse-pull-request', 'the merged pull request timeline could not be read: %s' % (
error.code if isinstance(error, Refusal) else type(error).__name__)) from error
need(len(merged) == 1, 'reuse-pull-request', '%d pull requests were merged as %s' % (len(merged), sha))
pull = merged[0]
pull, events = merged[0]
need(pull['state'] == 'closed' and pull['base']['ref'] == config['branch'] and
pull['base']['repo']['id'] == source['repository_id'] and positive(pull['number']) and
SHA.fullmatch(pull['head']['sha']), 'reuse-pull-request',
'pull request %s is not merged into %s here' % (pull.get('number'), config['branch']))
need((pull['head']['repo'] or {}).get('id') == source['repository_id'], 'reuse-pull-request',
'pull request %s comes from another repository' % pull['number'])
bind_head_branch(api, config, source, pull)
bind_head_branch(api, config, source, pull, events)
return pull, commit


Expand All @@ -1074,7 +1093,7 @@ def listing(api, suffix):
BASE_EVENT = re.compile(r'base_change|base_ref')


def bind_head_branch(api, config, source, pull):
def bind_head_branch(api, config, source, pull, events):
"""Records that survive the merge and that the pull request's code cannot write, binding its
head branch to it alone: the head branch has had exactly one pull request ever, the merged one,
from this repository (a run of another one executes another base's workflow at the same head),
Expand All @@ -1083,7 +1102,6 @@ def bind_head_branch(api, config, source, pull):
try:
pulls = listing(api, 'pulls?head=%s:%s&state=all' % (owner, quote(pull['head']['ref'], safe='')))
listed = [(other['number'], (other['head']['repo'] or {}).get('id')) for other in pulls]
events = listing(api, 'issues/%d/timeline' % pull['number'])
names = [event.get('event') for event in events]
need(all(isinstance(name, str) or name is None for name in names), 'unavailable-or-malformed')
changed = sorted({name for name in names if name and BASE_EVENT.search(name)})
Expand Down
Loading
Loading