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
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,11 +256,35 @@ duplication.min_tokens: an integer from 10 to 10000 is required (got 5)`.

| Toolchain | Cap |
| --- | --- |
| `node` | `lib/rsscap.sh`: the process group's resident memory and a timeout; never `ulimit -v`, which breaks vite and vitest |
| `node` | `lib/rsscap.sh`: the launched session's sampled resident memory and a timeout; never `ulimit -v`, which breaks vite and vitest |
| `go` | `flock` on the machine lock, `ulimit -v`, `GOFLAGS=-mod=readonly` (a step never rewrites `go.mod`), a per-clone `GOCACHE`, `timeout` |
| `python` | `ulimit -v` and `timeout` |
| `shell` | `timeout` only |

The RSS guard counts all non-zombie processes in the new session, including GNU
`timeout` and compiler/test children in other process groups. It keeps monitoring
until the session is empty, even when the original command exits first. Memory,
time and interruption stops kill the session's observed process groups. The
command's exit status is preserved when no guard stops it. Limits are positive
integers of at most nine digits; RSS is compared in KiB against the MiB limit.

This is a cooperative, sampled guard, not a security boundary or a hard memory
reservation. Between samples a process may exceed the limit. Descendants that
call `setsid` leave the owned session and are neither measured nor cleaned up;
use an external containment mechanism for workloads that detach this way. Shared
pages can be counted more than once when summing resident memory.

For a Go race check that cannot use the virtual-address limit, retain the existing
wrapper's lock, per-clone cache, read-only module mode and inner timeout:

```sh
JPDEV_GO_ULIMIT_KB=unlimited lib/rsscap.sh 4096 300 -- lib/wrap.sh go -- go test -race ./...
```

This explicit invocation applies the outer RSS/time limits to the wrapper and its
session. Normal Go steps retain their existing virtual-address cap. A limit that
fires while the wrapper waits for the lock also stops that wait.

When the manifest declares a virtual environment, its path must be a non-root relative child of
the clone with no `..` segment, and setup and checks create it when it is missing.
They use `python3.X` on PATH first, then the newest `~/.pyenv/versions/3.X.*/bin/python3`, and
Expand Down
3 changes: 2 additions & 1 deletion config/env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
# The machine lock every Go step holds, so two sessions never run Go at once.
#JPDEV_LOCK="$JPDEV_SCRATCH/harness.lock"

# Node steps: the resident-memory cap of the step's process group (MB) and its default timeout (s).
# Node steps: the resident-memory cap of the step's session (MiB) and its default timeout (s).
#JPDEV_NODE_RSS_MB=6000
#JPDEV_NODE_TIMEOUT_S=1200

# Go steps: ulimit -v (KB) and the default timeout (s). The per-clone GOCACHE sits under JPDEV_SCRATCH.
# Set unlimited only with an explicit outer RSS guard for a race check; see README.md.
#JPDEV_GO_ULIMIT_KB=12000000
#JPDEV_GO_TIMEOUT_S=3000
# Go steps always run with GOFLAGS=-mod=readonly and the per-clone GOCACHE, which lib/wrap.sh sets
Expand Down
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
71 changes: 56 additions & 15 deletions lib/rsscap.sh
Original file line number Diff line number Diff line change
@@ -1,29 +1,70 @@
#!/usr/bin/env bash
# rsscap.sh MAX_MB TIMEOUT_S -- command...
# Runs the command in its own process group and stops the whole group when the
# group's resident memory passes MAX_MB or the run passes TIMEOUT_S.
# For programs an address-space cap (ulimit -v) breaks, such as vite and vitest.
# Exit: the command's own status; 137 when stopped for memory; 124 for time.
set -u
if ! { [ $# -ge 4 ] && [ "$3" = "--" ]; }; then echo "usage: rsscap.sh MAX_MB TIMEOUT_S -- command..." >&2; exit 2; fi
# Sample resident memory across the command's new session, including nested process
# groups (for example GNU timeout). A descendant that creates a new session escapes
# this cooperative guard; it is not a security boundary or an instantaneous limit.
# Exit: command status; 137 for memory; 124 for time; 2 for invalid/unreadable input.
set -uo pipefail
if ! { [ $# -ge 4 ] && [ "$3" = "--" ] && [[ "$1" =~ ^[1-9][0-9]{0,8}$ ]] && [[ "$2" =~ ^[1-9][0-9]{0,8}$ ]]; }; then
echo "usage: rsscap.sh MAX_MB TIMEOUT_S -- command... (positive integers, at most nine digits)" >&2
exit 2
fi
max_mb=$1; tmo=$2; shift 3
# No job-control process group for the background child: setsid keeps this PID as SID.
set +m
setsid "$@" &
pid=$!
start=$(date +%s)
start=$SECONDS
peak=0
while kill -0 "$pid" 2>/dev/null; do
rss_mb=$(ps -eo pgid=,rss= | awk -v g="$pid" '$1==g {s+=$2} END {printf "%d", s/1024}')

session_rows() {
ps -eo sid=,pgid=,stat=,rss= | awk -v session="$pid" '$1 == session && $3 !~ /^Z/ {print $2, $4}'
}

# Invoked by the EXIT trap, including signal and limit exits.
# shellcheck disable=SC2329
cleanup() {
local rows groups group round
trap '' HUP INT TERM
# The launcher may not have entered its session yet. Stop it as well as every
# observed group; never select by process name or another session's ancestry.
if jobs -pr | grep -qx "$pid"; then kill -KILL "$pid" 2>/dev/null || true; fi
for ((round=0; round<10; round++)); do
rows=$(session_rows) || { echo 'rsscap: could not read session during cleanup' >&2; exit 2; }
[ -n "$rows" ] || break
groups=$(awk '{print $1}' <<< "$rows" | sort -u)
while read -r group; do
kill -KILL -- "-$group" 2>/dev/null || true
done <<< "$groups"
sleep 0.05
done
rows=$(session_rows) || { echo 'rsscap: could not verify session cleanup' >&2; exit 2; }
if [ -n "$rows" ]; then echo 'rsscap: session cleanup did not finish' >&2; exit 2; fi
wait "$pid" 2>/dev/null || true
}
trap 'cleanup' EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

while :; do
rows=$(session_rows) || { echo 'rsscap: could not read session memory' >&2; exit 2; }
# Keep watching after the leader exits: a background child still belongs to the
# session, even after reparenting or moving to a different process group.
if [ -z "$rows" ] && ! kill -0 "$pid" 2>/dev/null; then break; fi
rss_kb=$(awk '{s += $2} END {printf "%.0f", s}' <<< "$rows")
rss_mb=$(( (rss_kb + 1023) / 1024 ))
[ "$rss_mb" -gt "$peak" ] && peak=$rss_mb
if [ "$rss_mb" -gt "$max_mb" ]; then
echo "rsscap: STOPPED for memory: the group held ${rss_mb} MB, over the ${max_mb} MB limit" >&2
kill -KILL -- "-$pid" 2>/dev/null; wait "$pid" 2>/dev/null; exit 137
if [ "$rss_kb" -gt $(( max_mb * 1024 )) ]; then
echo "rsscap: STOPPED for memory: the session held ${rss_mb} MB, over the ${max_mb} MB limit" >&2
exit 137
fi
if [ $(( $(date +%s) - start )) -gt "$tmo" ]; then
if [ $(( SECONDS - start )) -ge "$tmo" ]; then
echo "rsscap: STOPPED for time: over ${tmo} s (peak ${peak} MB)" >&2
kill -KILL -- "-$pid" 2>/dev/null; wait "$pid" 2>/dev/null; exit 124
exit 124
fi
sleep 0.5
done
wait "$pid"; status=$?
echo "rsscap: exit ${status}, peak ${peak} MB" >&2
exit $status
exit "$status"
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