@plainconceptsplatform/workflows 1.2.3 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/route-processing.js +1 -0
- package/loops/actions/assess-blast-radius/action.yml +47 -8
- package/loops/actions/assess-blast-radius/assess-blast-radius.sh +27 -5
- package/loops/actions/assess-blast-radius/migration-is-additive.sh +38 -0
- package/loops/actions/classify-route/action.yml +2 -1
- package/loops/actions/classify-route/classify-route.sh +7 -1
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +139 -27
- package/loops/docs/diagrams.md +26 -11
- package/loops/workflows/agent-merge-gate.md +38 -1
- package/loops/workflows/evidence.yml +79 -4
- package/loops/workflows/work-router.yml +97 -21
- package/package.json +1 -1
package/dist/route-processing.js
CHANGED
|
@@ -35,6 +35,10 @@ inputs:
|
|
|
35
35
|
description: Lines changed at or above which the radius is MEDIUM.
|
|
36
36
|
required: false
|
|
37
37
|
default: '200'
|
|
38
|
+
ignore-paths:
|
|
39
|
+
description: Extended regular expression. Matching paths count toward neither size nor owner and sensitive paths; protected paths still match them.
|
|
40
|
+
required: false
|
|
41
|
+
default: ''
|
|
38
42
|
outputs:
|
|
39
43
|
level:
|
|
40
44
|
description: "low, medium or high."
|
|
@@ -64,11 +68,17 @@ outputs:
|
|
|
64
68
|
description: CODEOWNERS entries covering the changed paths, space separated. Empty when the repository has no CODEOWNERS.
|
|
65
69
|
value: ${{ steps.assess.outputs.required_owners }}
|
|
66
70
|
files_changed:
|
|
67
|
-
description: Number of files
|
|
71
|
+
description: Number of files that count toward size, ignored paths excluded.
|
|
68
72
|
value: ${{ steps.assess.outputs.files_changed }}
|
|
69
73
|
lines_changed:
|
|
70
|
-
description: Additions plus deletions.
|
|
74
|
+
description: Additions plus deletions of the files that count toward size.
|
|
71
75
|
value: ${{ steps.assess.outputs.lines_changed }}
|
|
76
|
+
files_total:
|
|
77
|
+
description: Number of files in the whole diff.
|
|
78
|
+
value: ${{ steps.assess.outputs.files_total }}
|
|
79
|
+
lines_total:
|
|
80
|
+
description: Additions plus deletions of the whole diff.
|
|
81
|
+
value: ${{ steps.assess.outputs.lines_total }}
|
|
72
82
|
runs:
|
|
73
83
|
using: composite
|
|
74
84
|
steps:
|
|
@@ -86,22 +96,51 @@ runs:
|
|
|
86
96
|
HIGH_LINES: ${{ inputs.high-lines }}
|
|
87
97
|
MEDIUM_FILES: ${{ inputs.medium-files }}
|
|
88
98
|
MEDIUM_LINES: ${{ inputs.medium-lines }}
|
|
99
|
+
IGNORE_PATHS: ${{ inputs.ignore-paths }}
|
|
89
100
|
run: |
|
|
90
101
|
set -euo pipefail
|
|
91
102
|
# One API call. The job this replaced already paid for it to grep PROTECTED_PATHS, and
|
|
92
103
|
# the additions and deletions come back in the same payload, so the diff shape costs
|
|
93
104
|
# nothing extra.
|
|
94
105
|
gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files?per_page=100" \
|
|
95
|
-
--jq '[.[] | {filename, additions, deletions}]' > /tmp/blast-files.json
|
|
106
|
+
--jq '[.[] | {filename, status, additions, deletions, patch}]' | jq -s 'add // []' > /tmp/blast-files.json
|
|
107
|
+
|
|
108
|
+
all_files=$(jq -r '.[].filename' /tmp/blast-files.json)
|
|
109
|
+
files_total=$(jq -r 'length' /tmp/blast-files.json)
|
|
110
|
+
lines_total=$(jq -r '[.[] | .additions + .deletions] | add // 0' /tmp/blast-files.json)
|
|
111
|
+
|
|
112
|
+
# Tests, docs, specs, translations and generated code change no runtime behaviour, so they
|
|
113
|
+
# do not make a diff bigger. They stay in ALL_PATHS, so a protected path is never ignored.
|
|
114
|
+
if [ -n "$IGNORE_PATHS" ]; then
|
|
115
|
+
jq -c --arg re "$IGNORE_PATHS" '[.[] | select(.filename | test($re) | not)]' /tmp/blast-files.json > /tmp/blast-counted.json
|
|
116
|
+
else
|
|
117
|
+
cp /tmp/blast-files.json /tmp/blast-counted.json
|
|
118
|
+
fi
|
|
119
|
+
files=$(jq -r '.[].filename' /tmp/blast-counted.json)
|
|
120
|
+
files_changed=$(jq -r 'length' /tmp/blast-counted.json)
|
|
121
|
+
lines_changed=$(jq -r '[.[] | .additions + .deletions] | add // 0' /tmp/blast-counted.json)
|
|
96
122
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
123
|
+
# A new EF Core migration whose Up() only adds schema. An edited one never qualifies:
|
|
124
|
+
# changing a migration that may already have run is exactly what needs its owner.
|
|
125
|
+
additive=""
|
|
126
|
+
while IFS= read -r candidate; do
|
|
127
|
+
[ -n "$candidate" ] || continue
|
|
128
|
+
if jq -r --arg f "$candidate" '.[] | select(.filename == $f) | .patch // ""' /tmp/blast-counted.json |
|
|
129
|
+
bash "${{ github.action_path }}/migration-is-additive.sh" >/dev/null; then
|
|
130
|
+
additive="${additive}${additive:+$'\n'}${candidate}"
|
|
131
|
+
fi
|
|
132
|
+
done < <(jq -r '.[] | select(.status == "added") | .filename
|
|
133
|
+
| select(test("(^|/)[Mm]igrations/[^/]+[.]cs$"))
|
|
134
|
+
| select(test("([.]Designer|ModelSnapshot)[.]cs$") | not)' /tmp/blast-counted.json)
|
|
100
135
|
|
|
101
|
-
|
|
136
|
+
ALL_PATHS="$all_files" ADDITIVE_MIGRATIONS="$additive" \
|
|
137
|
+
IGNORED_FILES=$((files_total - files_changed)) IGNORED_LINES=$((lines_total - lines_changed)) \
|
|
138
|
+
bash "${{ github.action_path }}/assess-blast-radius.sh" \
|
|
102
139
|
"$files_changed" "$lines_changed" <<<"$files" > /tmp/blast-out.env
|
|
103
140
|
|
|
104
141
|
cat /tmp/blast-out.env >> "$GITHUB_OUTPUT"
|
|
142
|
+
echo "files_total=$files_total" >> "$GITHUB_OUTPUT"
|
|
143
|
+
echo "lines_total=$lines_total" >> "$GITHUB_OUTPUT"
|
|
105
144
|
|
|
106
145
|
# CODEOWNERS refines who is asked; it never decides whether anyone is. A repository
|
|
107
146
|
# without the file still gets an owner disposition from OWNER_PATHS, and the comment
|
|
@@ -139,7 +178,7 @@ runs:
|
|
|
139
178
|
esac
|
|
140
179
|
# An unanchored CODEOWNERS pattern matches at any depth, an anchored one only at the root.
|
|
141
180
|
if [ "$anchored" -eq 1 ]; then regex="^${regex}"; else regex="(^|/)${regex}"; fi
|
|
142
|
-
if printf '%s\n' "$
|
|
181
|
+
if printf '%s\n' "$all_files" | grep -qE "$regex"; then
|
|
143
182
|
owners="$owners $rest"
|
|
144
183
|
fi
|
|
145
184
|
done < "$candidate"
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/assess-blast-radius/assess-blast-radius.sh. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
3
|
# Classify a pull request's blast radius from its changed paths and diff shape.
|
|
4
4
|
#
|
|
5
|
-
# Reads the changed paths on stdin, one per line. Prints
|
|
5
|
+
# Reads the changed paths that count toward the diff's size on stdin, one per line. Prints
|
|
6
|
+
# GITHUB_OUTPUT lines on stdout. ALL_PATHS, when set, is every changed path, ignored ones included:
|
|
7
|
+
# protected paths are matched against it, so ignoring a path never unprotects it.
|
|
6
8
|
# Deliberately model-free: the point of this file is that the most consequential input to the
|
|
7
9
|
# merge decision is computed from facts a shell can check, before any agent reads the diff.
|
|
8
10
|
#
|
|
@@ -21,8 +23,13 @@ HIGH_FILES="${HIGH_FILES:-20}"
|
|
|
21
23
|
HIGH_LINES="${HIGH_LINES:-800}"
|
|
22
24
|
MEDIUM_FILES="${MEDIUM_FILES:-5}"
|
|
23
25
|
MEDIUM_LINES="${MEDIUM_LINES:-200}"
|
|
26
|
+
# Added migrations the caller proved only add schema (migration-is-additive.sh), one per line.
|
|
27
|
+
ADDITIVE_MIGRATIONS="${ADDITIVE_MIGRATIONS:-}"
|
|
28
|
+
IGNORED_FILES="${IGNORED_FILES:-0}"
|
|
29
|
+
IGNORED_LINES="${IGNORED_LINES:-0}"
|
|
24
30
|
|
|
25
31
|
files="$(cat)"
|
|
32
|
+
all_paths="${ALL_PATHS:-$files}"
|
|
26
33
|
|
|
27
34
|
# An empty regex matches every line in grep -E, which would mark every pull request protected.
|
|
28
35
|
# A list nobody configured must match nothing, not everything.
|
|
@@ -34,9 +41,9 @@ files="$(cat)"
|
|
|
34
41
|
# before any of them is used: a `match` failure cannot report itself, because `match` runs
|
|
35
42
|
# inside a command substitution and anything it sets dies with the subshell.
|
|
36
43
|
match() {
|
|
37
|
-
local pattern="$1"
|
|
44
|
+
local pattern="$1" paths="${2-$files}"
|
|
38
45
|
[ -n "$pattern" ] || return 0
|
|
39
|
-
printf '%s\n' "$
|
|
46
|
+
printf '%s\n' "$paths" | grep -E "$pattern" || true
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
# Compile each configured pattern against nothing. grep exits 2 when the regex is bad, which is
|
|
@@ -50,14 +57,22 @@ for name in PROTECTED_PATHS OWNER_PATHS SENSITIVE_PATHS; do
|
|
|
50
57
|
fi
|
|
51
58
|
done
|
|
52
59
|
|
|
53
|
-
protected_hits="$(match "$PROTECTED_PATHS")"
|
|
60
|
+
protected_hits="$(match "$PROTECTED_PATHS" "$all_paths")"
|
|
54
61
|
owner_hits="$(match "$OWNER_PATHS")"
|
|
55
62
|
sensitive_hits="$(match "$SENSITIVE_PATHS")"
|
|
56
63
|
|
|
64
|
+
# An additive migration loses no data, so it asks for a second look (medium) rather than its
|
|
65
|
+
# owner. Every other owner path still forces high.
|
|
66
|
+
additive_hits=""
|
|
67
|
+
if [ -n "$ADDITIVE_MIGRATIONS" ] && [ -n "$owner_hits" ]; then
|
|
68
|
+
additive_hits="$(printf '%s\n' "$owner_hits" | grep -Fxf <(printf '%s\n' "$ADDITIVE_MIGRATIONS") || true)"
|
|
69
|
+
owner_hits="$(printf '%s\n' "$owner_hits" | grep -vFxf <(printf '%s\n' "$ADDITIVE_MIGRATIONS") || true)"
|
|
70
|
+
fi
|
|
71
|
+
|
|
57
72
|
# A threshold that is not a number turns its comparison into a shell error that `set -e` does
|
|
58
73
|
# not catch, because the test is an `if` condition: `HIGH_FILES=twenty` printed a diagnostic to
|
|
59
74
|
# stderr and quietly produced `level=low` for a diff of any size.
|
|
60
|
-
for threshold in HIGH_FILES HIGH_LINES MEDIUM_FILES MEDIUM_LINES files_changed lines_changed; do
|
|
75
|
+
for threshold in HIGH_FILES HIGH_LINES MEDIUM_FILES MEDIUM_LINES files_changed lines_changed IGNORED_FILES IGNORED_LINES; do
|
|
61
76
|
case "${!threshold}" in
|
|
62
77
|
""|*[!0-9]*)
|
|
63
78
|
echo "assess-blast-radius: ${threshold} must be a whole number, got '${!threshold}'" >&2
|
|
@@ -92,6 +107,10 @@ if [ "$lines_changed" -ge "$HIGH_LINES" ]; then
|
|
|
92
107
|
fi
|
|
93
108
|
|
|
94
109
|
if [ "$level" != high ]; then
|
|
110
|
+
if [ -n "$additive_hits" ]; then
|
|
111
|
+
level=medium
|
|
112
|
+
add_signal "additive migrations, no owner needed ($(printf '%s\n' "$additive_hits" | tr '\n' ' ' | sed 's/ $//'))"
|
|
113
|
+
fi
|
|
95
114
|
if [ -n "$sensitive_hits" ]; then
|
|
96
115
|
level=medium
|
|
97
116
|
add_signal "sensitive paths touched ($(printf '%s\n' "$sensitive_hits" | tr '\n' ' ' | sed 's/ $//'))"
|
|
@@ -107,6 +126,9 @@ if [ "$level" != high ]; then
|
|
|
107
126
|
fi
|
|
108
127
|
|
|
109
128
|
[ -n "$signals" ] || signals="no blast-radius signal fired: $files_changed files, $lines_changed lines, no configured path matched"
|
|
129
|
+
if [ "$IGNORED_FILES" -gt 0 ]; then
|
|
130
|
+
add_signal "not counted toward size: $IGNORED_FILES files, $IGNORED_LINES lines (tests, docs, specs, translations, generated code)"
|
|
131
|
+
fi
|
|
110
132
|
|
|
111
133
|
# A fixed heredoc delimiter is a fail-open here. Every multi-line value below is built from
|
|
112
134
|
# paths the pull request chose, so a path that is exactly the delimiter closes its block early
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/assess-blast-radius/migration-is-additive.sh. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
+
# Decide whether a newly added EF Core migration only adds schema. Reads the file's diff patch on
|
|
4
|
+
# stdin; exits 0 and prints "additive" when it does, exits 1 and prints the reason when it does not.
|
|
5
|
+
#
|
|
6
|
+
# Only Up() is read: Down() of an additive migration is all drops by construction. Anything that
|
|
7
|
+
# can lose or rewrite data, or a structure this cannot follow, is not additive.
|
|
8
|
+
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
awk '
|
|
12
|
+
/^\+/ { line = substr($0, 2) }
|
|
13
|
+
!/^\+/ { next }
|
|
14
|
+
line ~ /void[ \t]+Up[ \t]*\(/ { in_up = 1; seen_up = 1; next }
|
|
15
|
+
line ~ /void[ \t]+Down[ \t]*\(/ { in_up = 0; next }
|
|
16
|
+
!in_up { next }
|
|
17
|
+
line ~ /\.(Drop[A-Za-z]*|Rename[A-Za-z]*|Alter[A-Za-z]*|Sql|DeleteData|UpdateData|RestartSequence)[ \t]*[(<]/ {
|
|
18
|
+
match(line, /\.[A-Za-z]+[ \t]*[(<]/)
|
|
19
|
+
reason = "Up() calls " substr(line, RSTART + 1, RLENGTH - 1)
|
|
20
|
+
exit
|
|
21
|
+
}
|
|
22
|
+
line ~ /\.AddColumn[ \t]*</ { in_column = 1; column = "" }
|
|
23
|
+
in_column {
|
|
24
|
+
column = column " " line
|
|
25
|
+
if (line ~ /\);/) {
|
|
26
|
+
in_column = 0
|
|
27
|
+
if (column ~ /nullable:[ \t]*false/ && column !~ /defaultValue/) {
|
|
28
|
+
reason = "Up() adds a required column with no default"
|
|
29
|
+
exit
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
END {
|
|
34
|
+
if (reason == "" && !seen_up) reason = "no Up() method found in the added lines"
|
|
35
|
+
if (reason != "") { print reason; exit 1 }
|
|
36
|
+
print "additive"
|
|
37
|
+
}
|
|
38
|
+
'
|
|
@@ -56,6 +56,7 @@ runs:
|
|
|
56
56
|
PR_MERGED: ${{ github.event.pull_request.merged }}
|
|
57
57
|
COMMENT_ON_PR: ${{ github.event.issue.pull_request != '' }}
|
|
58
58
|
COMMENT_SENDER_TYPE: ${{ github.event.comment.user.type }}
|
|
59
|
+
REVIEW_STATE: ${{ github.event.review.state }}
|
|
59
60
|
ACTOR: ${{ github.actor }}
|
|
60
61
|
RUN_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
|
|
61
62
|
RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
|
|
@@ -84,7 +85,7 @@ runs:
|
|
|
84
85
|
|
|
85
86
|
- name: Resolve the issue the pull request closes
|
|
86
87
|
id: linked-issue
|
|
87
|
-
if:
|
|
88
|
+
if: contains(fromJson('["merge-gate","apply-review","merge-approved"]'), steps.classify.outputs.route)
|
|
88
89
|
shell: bash
|
|
89
90
|
env:
|
|
90
91
|
GH_TOKEN: ${{ inputs.token }}
|
|
@@ -135,7 +135,13 @@ classify_route() {
|
|
|
135
135
|
;;
|
|
136
136
|
|
|
137
137
|
pull_request_review_comment | pull_request_review)
|
|
138
|
-
|
|
138
|
+
# An approval is a decision, not feedback: the router merges the pull request when the
|
|
139
|
+
# gate had handed it to a person (merge-approved checks the rest).
|
|
140
|
+
if [ "${EVENT:-}" = "pull_request_review" ] && [ "${REVIEW_STATE:-}" = "approved" ]; then
|
|
141
|
+
route="merge-approved"
|
|
142
|
+
else
|
|
143
|
+
route="apply-review"
|
|
144
|
+
fi
|
|
139
145
|
pr_number="${EVENT_PR_NUMBER:-}"
|
|
140
146
|
;;
|
|
141
147
|
|
|
@@ -78,7 +78,7 @@ route_field() {
|
|
|
78
78
|
unset EVENT ACTION LABEL ISSUE_LABELS EVENT_ISSUE_NUMBER EVENT_PR_NUMBER \
|
|
79
79
|
COMMENT_ON_PR COMMENT_SENDER_TYPE RUN_PR_NUMBER RUN_CONCLUSION RUN_ID \
|
|
80
80
|
SCHEDULE OPERATION INPUT_ISSUE_NUMBER INPUT_PR_NUMBER INPUT_MODE \
|
|
81
|
-
INPUT_CI_CONCLUSION INPUT_CI_RUN_ID INPUT_TRIGGER_KIND PR_MERGED EVIDENCE_ENABLED
|
|
81
|
+
INPUT_CI_CONCLUSION INPUT_CI_RUN_ID INPUT_TRIGGER_KIND PR_MERGED EVIDENCE_ENABLED REVIEW_STATE
|
|
82
82
|
|
|
83
83
|
for assignment in "${assignments[@]}"; do
|
|
84
84
|
key="${assignment%%=*}"
|
|
@@ -185,6 +185,12 @@ assert_route "a review comment routes to apply-review" apply-review \
|
|
|
185
185
|
EVENT=pull_request_review_comment EVENT_PR_NUMBER=7
|
|
186
186
|
assert_route "a submitted review routes to apply-review" apply-review \
|
|
187
187
|
EVENT=pull_request_review EVENT_PR_NUMBER=7
|
|
188
|
+
assert_route "a review requesting changes routes to apply-review" apply-review \
|
|
189
|
+
EVENT=pull_request_review REVIEW_STATE=changes_requested EVENT_PR_NUMBER=7
|
|
190
|
+
assert_route "an approval routes to merge-approved" merge-approved \
|
|
191
|
+
EVENT=pull_request_review REVIEW_STATE=approved EVENT_PR_NUMBER=7
|
|
192
|
+
assert "merge-approved carries the pull request" 7 \
|
|
193
|
+
"$(route_field pr-number EVENT=pull_request_review REVIEW_STATE=approved EVENT_PR_NUMBER=7)"
|
|
188
194
|
assert_route "a pull_request_target routes to bot-approve" bot-approve \
|
|
189
195
|
EVENT=pull_request_target ACTION=opened
|
|
190
196
|
assert_route "a merged pull request routes to evidence" evidence \
|
|
@@ -704,7 +710,7 @@ if [ "$(count -c 'attempts_so_far' "$ROUTER_YML")" -lt 2 ]; then
|
|
|
704
710
|
BELT_OK=0; echo "FAIL: dispatch sites must forward attempts_so_far" >&2
|
|
705
711
|
fi
|
|
706
712
|
# A second gate for a pull request whose gate is already queued or running reads the same CI
|
|
707
|
-
# verdict and
|
|
713
|
+
# verdict, and the belt's queue would run it twice.
|
|
708
714
|
if [ "$(count -c 'a merge-gate run is already live' "$ROUTER_YML")" -lt 2 ]; then
|
|
709
715
|
BELT_OK=0; echo "FAIL: both dispatch paths must skip a pull request whose gate is already live" >&2
|
|
710
716
|
fi
|
|
@@ -1079,6 +1085,17 @@ for route in "${EXCLUDED_ROUTES[@]}"; do
|
|
|
1079
1085
|
fi
|
|
1080
1086
|
done
|
|
1081
1087
|
|
|
1088
|
+
# merge-approved serves the gate and leaves with it.
|
|
1089
|
+
if worker_installed merge-gate; then
|
|
1090
|
+
if grep -q "route == 'merge-approved'" "$ROUTER_YML"; then PASS=$((PASS + 1)); else
|
|
1091
|
+
FAIL=$((FAIL + 1)); echo "FAIL: work-router.yml has no job for route 'merge-approved'" >&2
|
|
1092
|
+
fi
|
|
1093
|
+
elif grep -q "route == 'merge-approved'" "$ROUTER_YML"; then
|
|
1094
|
+
FAIL=$((FAIL + 1)); echo "FAIL: merge-approved is installed without the merge gate" >&2
|
|
1095
|
+
else
|
|
1096
|
+
PASS=$((PASS + 1))
|
|
1097
|
+
fi
|
|
1098
|
+
|
|
1082
1099
|
while read -r operation; do
|
|
1083
1100
|
if grep -q "route == '${operation}'" "$ROUTER_YML"; then
|
|
1084
1101
|
PASS=$((PASS + 1))
|
|
@@ -1415,7 +1432,93 @@ level=low")
|
|
|
1415
1432
|
printf '%s\n' "$blast_injection" >&2
|
|
1416
1433
|
fi
|
|
1417
1434
|
|
|
1435
|
+
# Ignored paths leave the size, never the protection: a protected path that only reaches the
|
|
1436
|
+
# script through ALL_PATHS still holds the merge.
|
|
1437
|
+
blast_ignored=$(PROTECTED_PATHS='^\.' OWNER_PATHS='' SENSITIVE_PATHS='' \
|
|
1438
|
+
HIGH_FILES=20 HIGH_LINES=800 MEDIUM_FILES=5 MEDIUM_LINES=200 \
|
|
1439
|
+
ALL_PATHS=$'src/ui/list.tsx\n.github/workflows/ci.yml' IGNORED_FILES=1 IGNORED_LINES=4 \
|
|
1440
|
+
bash "$BLAST_SCRIPT" 1 10 <<<"src/ui/list.tsx")
|
|
1441
|
+
printf '%s\n' "$blast_ignored" | grep -qx 'requires_review=true' ||
|
|
1442
|
+
{ BLAST_OK=0; echo "FAIL: a protected path outside the counted files no longer holds the merge" >&2; }
|
|
1443
|
+
|
|
1444
|
+
# An additive migration under an owner path asks for a second look, not its owner. Any other
|
|
1445
|
+
# owner path in the same diff still forces high.
|
|
1446
|
+
blast_additive() {
|
|
1447
|
+
PROTECTED_PATHS='^\.' OWNER_PATHS='(^|/)([Mm]igrations|auth)/' SENSITIVE_PATHS='' \
|
|
1448
|
+
HIGH_FILES=20 HIGH_LINES=800 MEDIUM_FILES=5 MEDIUM_LINES=200 \
|
|
1449
|
+
ADDITIVE_MIGRATIONS="$1" bash "$BLAST_SCRIPT" 2 120 <<<"$2" | sed -n 's/^level=//p'
|
|
1450
|
+
}
|
|
1451
|
+
[ "$(blast_additive 'src/Migrations/1_AddX.cs' $'src/Migrations/1_AddX.cs\nsrc/ui/x.tsx')" = medium ] ||
|
|
1452
|
+
{ BLAST_OK=0; echo "FAIL: an additive migration still forces its owner" >&2; }
|
|
1453
|
+
[ "$(blast_additive '' $'src/Migrations/1_AddX.cs\nsrc/ui/x.tsx')" = high ] ||
|
|
1454
|
+
{ BLAST_OK=0; echo "FAIL: a migration nobody proved additive no longer needs its owner" >&2; }
|
|
1455
|
+
[ "$(blast_additive 'src/Migrations/1_AddX.cs' $'src/Migrations/1_AddX.cs\nsrc/auth/login.ts')" = high ] ||
|
|
1456
|
+
{ BLAST_OK=0; echo "FAIL: an additive migration let another owner path through" >&2; }
|
|
1457
|
+
|
|
1458
|
+
# The shipped ignore list, against paths from real consumer pull requests.
|
|
1459
|
+
ignore_re=$(sed -n "s/^ BLAST_IGNORE_PATHS: '\(.*\)'$/\1/p" "$MERGE_GATE_WORKER_MD")
|
|
1460
|
+
for ignored in tests/Pliny.UnitTests/Agents/SecretRedactorTests.cs apps/api/tests/Numa.UnitTests/QuoteTests.cs \
|
|
1461
|
+
apps/web/src/views/chat/ui/internals-pane.test.tsx apps/web/src/shared/config/i18n/es.json \
|
|
1462
|
+
openspec/changes/archive/x/verification-plan.md docs/ARCH.md \
|
|
1463
|
+
src/Pliny.Infrastructure/Migrations/20261014225437_X.Designer.cs src/Migrations/AppDbContextModelSnapshot.cs; do
|
|
1464
|
+
grep -qE "$ignore_re" <<<"$ignored" ||
|
|
1465
|
+
{ BLAST_OK=0; echo "FAIL: BLAST_IGNORE_PATHS counts ${ignored} toward the diff's size" >&2; }
|
|
1466
|
+
done
|
|
1467
|
+
for counted in src/Pliny.Infrastructure/Agents/SecretRedactor.cs apps/web/src/views/chat/ui/internals-pane.tsx \
|
|
1468
|
+
src/Pliny.Infrastructure/Migrations/20261014225437_X.cs apps/api/src/Numa.Api/appsettings.json; do
|
|
1469
|
+
if grep -qE "$ignore_re" <<<"$counted"; then
|
|
1470
|
+
BLAST_OK=0; echo "FAIL: BLAST_IGNORE_PATHS ignores ${counted}, which ships" >&2
|
|
1471
|
+
fi
|
|
1472
|
+
done
|
|
1473
|
+
|
|
1418
1474
|
if [ "$BLAST_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
1475
|
+
|
|
1476
|
+
# A migration is additive only when its Up() creates and adds. Down() is all drops by design,
|
|
1477
|
+
# and a data rewrite or a required column with no default needs the owner.
|
|
1478
|
+
MIGRATION_OK=1
|
|
1479
|
+
MIGRATION_SCRIPT="${HERE}/../assess-blast-radius/migration-is-additive.sh"
|
|
1480
|
+
migration_case() {
|
|
1481
|
+
local name="$1" want="$2" patch="$3" got
|
|
1482
|
+
if bash "$MIGRATION_SCRIPT" <<<"$patch" >/dev/null; then got=additive; else got=owner; fi
|
|
1483
|
+
[ "$got" = "$want" ] || { MIGRATION_OK=0; echo "FAIL: migration '${name}' read as ${got}, expected ${want}" >&2; }
|
|
1484
|
+
}
|
|
1485
|
+
migration_case "a new table, dropped in Down()" additive '+ protected override void Up(MigrationBuilder migrationBuilder)
|
|
1486
|
+
+ {
|
|
1487
|
+
+ migrationBuilder.CreateTable(name: "TokenRevocations", columns: table => new { Id = table.Column<Guid>(nullable: false) });
|
|
1488
|
+
+ migrationBuilder.AddColumn<string>(
|
|
1489
|
+
+ name: "Note",
|
|
1490
|
+
+ table: "Quotes",
|
|
1491
|
+
+ nullable: true);
|
|
1492
|
+
+ }
|
|
1493
|
+
+ protected override void Down(MigrationBuilder migrationBuilder)
|
|
1494
|
+
+ {
|
|
1495
|
+
+ migrationBuilder.DropTable(name: "TokenRevocations");
|
|
1496
|
+
+ }'
|
|
1497
|
+
migration_case "a data rewrite" owner '+ protected override void Up(MigrationBuilder migrationBuilder)
|
|
1498
|
+
+ {
|
|
1499
|
+
+ migrationBuilder.Sql("UPDATE Bots SET Instructions = NULL");
|
|
1500
|
+
+ }'
|
|
1501
|
+
migration_case "a renamed column" owner '+ protected override void Up(MigrationBuilder migrationBuilder)
|
|
1502
|
+
+ {
|
|
1503
|
+
+ migrationBuilder.RenameColumn(name: "A", table: "T", newName: "B");
|
|
1504
|
+
+ }'
|
|
1505
|
+
migration_case "a required column with no default" owner '+ protected override void Up(MigrationBuilder migrationBuilder)
|
|
1506
|
+
+ {
|
|
1507
|
+
+ migrationBuilder.AddColumn<int>(
|
|
1508
|
+
+ name: "Level",
|
|
1509
|
+
+ table: "Quotes",
|
|
1510
|
+
+ nullable: false);
|
|
1511
|
+
+ }'
|
|
1512
|
+
migration_case "a required column with a default" additive '+ protected override void Up(MigrationBuilder migrationBuilder)
|
|
1513
|
+
+ {
|
|
1514
|
+
+ migrationBuilder.AddColumn<int>(
|
|
1515
|
+
+ name: "Level",
|
|
1516
|
+
+ table: "Quotes",
|
|
1517
|
+
+ nullable: false,
|
|
1518
|
+
+ defaultValue: 0);
|
|
1519
|
+
+ }'
|
|
1520
|
+
migration_case "no Up() in the patch" owner '+// generated'
|
|
1521
|
+
if [ "$MIGRATION_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
1419
1522
|
fi
|
|
1420
1523
|
|
|
1421
1524
|
# apply-review's validator compares the unresolved review threads on a pull request against the
|
|
@@ -1724,6 +1827,28 @@ else
|
|
|
1724
1827
|
fi
|
|
1725
1828
|
ev_absent 'secrets\.' "$EVIDENCE_YML" 'evidence.yml must need no secret; GITHUB_TOKEN is enough'
|
|
1726
1829
|
ev_absent 'gh (issue|pr) comment.*--attach' "$EVIDENCE_YML" 'gh --attach refuses installation tokens; commit the screenshots beside their plans'
|
|
1830
|
+
# An unmerged pull request's preview goes to its own branch, never to the pull request's branch
|
|
1831
|
+
# (a commit there invalidates the gate's verdict) and never to the default branch.
|
|
1832
|
+
preview_step="$(sed -n '/name: Commit a preview of the screenshots/,/name: Write the evidence comment/p' "$EVIDENCE_YML")"
|
|
1833
|
+
grep -q "if: steps.pr.outputs.merged != 'true'" <<<"$preview_step" ||
|
|
1834
|
+
{ EV_OK=0; echo "FAIL: the preview step must run only for an unmerged pull request" >&2; }
|
|
1835
|
+
grep -qF 'git push -q origin "HEAD:refs/heads/$PREVIEW"' <<<"$preview_step" ||
|
|
1836
|
+
{ EV_OK=0; echo "FAIL: the preview must be pushed to EVIDENCE_PREVIEW_BRANCH and nowhere else" >&2; }
|
|
1837
|
+
ev '^ EVIDENCE_PREVIEW_BRANCH: ' "$EVIDENCE_YML" 'evidence.yml does not declare EVIDENCE_PREVIEW_BRANCH'
|
|
1838
|
+
fi
|
|
1839
|
+
|
|
1840
|
+
# The gate replays the plan for a person it hands the pull request to, never on auto-merge: a
|
|
1841
|
+
# dispatch next to the merge is the race that sank agent-visual-verify.
|
|
1842
|
+
if worker_installed merge-gate; then
|
|
1843
|
+
ev '^ EVIDENCE_BEFORE_REVIEW: ' "$MERGE_GATE_WORKER_MD" 'merge-gate does not declare EVIDENCE_BEFORE_REVIEW'
|
|
1844
|
+
review_dispatch="$(sed -n '/name: Replay the verification plan for the reviewer/,/inputs\[pr-number\]/p' "$MERGE_GATE_WORKER_MD")"
|
|
1845
|
+
if ! grep -q '"human-review","owner-review"' <<<"$review_dispatch" || grep -q 'auto-merge' <<<"$review_dispatch" ||
|
|
1846
|
+
! grep -q 'inputs\[operation\]=evidence' <<<"$review_dispatch"; then
|
|
1847
|
+
EV_OK=0
|
|
1848
|
+
echo "FAIL: the gate must dispatch evidence for human-review and owner-review only" >&2
|
|
1849
|
+
fi
|
|
1850
|
+
grep -q 'name: Replay the verification plan for the owner' "$MERGE_GATE_WORKER_MD" ||
|
|
1851
|
+
{ EV_OK=0; echo "FAIL: a protected-path hold never reaches conclude, so it must dispatch evidence itself" >&2; }
|
|
1727
1852
|
fi
|
|
1728
1853
|
|
|
1729
1854
|
# The retired worker stays retired: nothing dispatches it and nothing waits for it.
|
|
@@ -2366,31 +2491,18 @@ for worker_md in "${WORKFLOWS_DIR}"/agent-*.md; do
|
|
|
2366
2491
|
done
|
|
2367
2492
|
if [ "$INPUT_WIRING_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
2368
2493
|
|
|
2369
|
-
#
|
|
2370
|
-
#
|
|
2371
|
-
#
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
if [ -z "$belt_dispatch_block" ]; then
|
|
2382
|
-
BELT_ONE_PER_TICK_OK=0
|
|
2383
|
-
echo "FAIL: the merge belt's gate dispatch could not be located, so its one-per-tick guard cannot be checked" >&2
|
|
2384
|
-
elif ! printf '%s\n' "$belt_dispatch_block" | grep -qE '^ *break$'; then
|
|
2385
|
-
BELT_ONE_PER_TICK_OK=0
|
|
2386
|
-
echo "FAIL: the merge belt dispatches a gate per eligible pull request and never breaks; only the last stays pending and the rest are cancelled in silence" >&2
|
|
2387
|
-
fi
|
|
2388
|
-
# And the group it relies on must still be the serialising one.
|
|
2389
|
-
# Anchored to the whole line: a substring search matched `merge-belt-renamed` and stayed green
|
|
2390
|
-
# through a mutation that removed the serialisation the break depends on.
|
|
2391
|
-
grep -qE '^ *group: merge-belt *$' "$ROUTER_YML" ||
|
|
2392
|
-
{ BELT_ONE_PER_TICK_OK=0; echo "FAIL: the merge gate is no longer serialised on the merge-belt concurrency group" >&2; }
|
|
2393
|
-
if [ "$BELT_ONE_PER_TICK_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
2494
|
+
# Several paths dispatch gates at once (the reconcile tick, CI's dispatch-merge-gate, the
|
|
2495
|
+
# conflict rescan). Without `queue: max` GitHub keeps one pending run in the group and cancels the
|
|
2496
|
+
# rest with no comment and no recorded attempt; every dispatch still returns 204.
|
|
2497
|
+
BELT_QUEUE_OK=1
|
|
2498
|
+
belt_caller=$(awk '/^ call-merge-gate:/ { found = 1; print; next } found && /^ [a-z]/ { exit } found { print }' "$ROUTER_YML")
|
|
2499
|
+
printf '%s
|
|
2500
|
+
' "$belt_caller" | grep -qE '^ *group: merge-belt *$' ||
|
|
2501
|
+
{ BELT_QUEUE_OK=0; echo "FAIL: the merge gate is no longer serialised on the merge-belt concurrency group" >&2; }
|
|
2502
|
+
printf '%s
|
|
2503
|
+
' "$belt_caller" | grep -qE '^ *queue: max *$' ||
|
|
2504
|
+
{ BELT_QUEUE_OK=0; echo "FAIL: call-merge-gate must queue its waiting gates (queue: max); a single-slot group cancels every gate but the last" >&2; }
|
|
2505
|
+
if [ "$BELT_QUEUE_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
2394
2506
|
|
|
2395
2507
|
# The specific wiring that broke, asserted end to end: the router resolves the run ID, the gate
|
|
2396
2508
|
# forwards it, and the action seeds from it rather than only from its own lookup.
|
package/loops/docs/diagrams.md
CHANGED
|
@@ -282,7 +282,7 @@ flowchart TD
|
|
|
282
282
|
gateFix("Repair<br/>read the failure, fix, verify.<br/>Empty evidence on a conflicting<br/>PR means the conflict is the fault") --> gatePush
|
|
283
283
|
gatePush(("Pushed<br/>CI runs again, gate re-enters"))
|
|
284
284
|
gateFix -.->|"cannot fix"| gateHuman
|
|
285
|
-
gateProtected{"protected_changes (rung 4b)<br/>blast radius from paths
|
|
285
|
+
gateProtected{"protected_changes (rung 4b)<br/>blast radius from paths, CODEOWNERS<br/>and the size of the code that ships"}
|
|
286
286
|
gateProtected -.->|"protected path"| gateOwner
|
|
287
287
|
gateProtected -->|"measured low, medium or high"| gateAssess
|
|
288
288
|
gateAssess("Agent (rung 5)<br/>find defects, verify them,<br/>rate recoverability") --> gateDisposition
|
|
@@ -294,6 +294,10 @@ flowchart TD
|
|
|
294
294
|
gateMerge(("AUTO-MERGE<br/>squash, issue closed,<br/>pr-pending removed"))
|
|
295
295
|
gateHuman(("HUMAN REVIEW<br/>review label,<br/>implement label kept"))
|
|
296
296
|
gateOwner(("OWNER REVIEW<br/>review + owner-review,<br/>CODEOWNERS asked when named"))
|
|
297
|
+
gateHuman --> gatePreview
|
|
298
|
+
gateOwner --> gatePreview
|
|
299
|
+
gatePreview("operation=evidence<br/>screenshots of the head<br/>before the person decides") --> gateApproved
|
|
300
|
+
gateApproved(("Approved by a writer<br/>merge-approved merges<br/>the approved commit"))
|
|
297
301
|
gateBlocked(("BLOCKED<br/>review + blocked,<br/>the belt does not retry"))
|
|
298
302
|
gateIdle(("Idle<br/>not ours, or not open"))
|
|
299
303
|
gateFix -.->|"attempt 6 of 6"| gateBlocked
|
|
@@ -305,11 +309,11 @@ flowchart TD
|
|
|
305
309
|
classDef failure fill:#fff0f0,stroke:#ef2929,stroke-width:2px,color:#8b1a1a
|
|
306
310
|
classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
|
|
307
311
|
class gateStart start
|
|
308
|
-
class gateBranch,gateFacts,gateFix,gateAssess action
|
|
312
|
+
class gateBranch,gateFacts,gateFix,gateAssess,gatePreview action
|
|
309
313
|
class gateSubject,gateCi,gateProtected,gateDisposition decision
|
|
310
314
|
class gateIdle idle
|
|
311
315
|
class gateHuman,gateOwner,gateBlocked failure
|
|
312
|
-
class gateMerge,gatePush success
|
|
316
|
+
class gateMerge,gatePush,gateApproved success
|
|
313
317
|
```
|
|
314
318
|
|
|
315
319
|
A protected-path match holds the merge but does not stop the repair path: the agent may still fix
|
|
@@ -321,6 +325,12 @@ agent ran. A category the diff touches is no longer, on its own, a reason to par
|
|
|
321
325
|
only a verified high or critical finding, a sensitive path, or a change that would be hard to undo
|
|
322
326
|
sends it to a person.
|
|
323
327
|
|
|
328
|
+
Size counts only code that ships: tests, docs, specs, translations and generated migration code
|
|
329
|
+
(`BLAST_IGNORE_PATHS`) are left out, and a new EF Core migration whose `Up()` only creates and adds
|
|
330
|
+
is a medium change rather than an owner one. When the gate parks a pull request for a person it
|
|
331
|
+
dispatches `operation=evidence`, so the screenshots arrive before the decision, and an approval
|
|
332
|
+
from anyone with write access merges the approved commit (`merge-approved` in the router).
|
|
333
|
+
|
|
324
334
|
---
|
|
325
335
|
|
|
326
336
|
## agent-apply-review.md
|
|
@@ -628,14 +638,16 @@ Archives what a merged pull request looks like. Not an agent: the plan was writt
|
|
|
628
638
|
implemented the change (`pc-repo-verify`), and this replays it. The router calls it when a pull
|
|
629
639
|
request merges, and `operation=evidence` runs it by hand. Nothing waits on it. The screenshots are
|
|
630
640
|
committed beside each plan, in an `evidence/` folder on the default branch, so the record stays with
|
|
631
|
-
the change it proves; the comment embeds a manifest that Project Health reads.
|
|
632
|
-
|
|
641
|
+
the change it proves; the comment embeds a manifest that Project Health reads. The merge gate also
|
|
642
|
+
runs it when it hands a pull request to a person: that pull request is not merged, so its
|
|
643
|
+
screenshots go to a preview folder (`pr-<number>/`) on the `evidence-previews` branch, and the
|
|
644
|
+
merge replaces them with the archived ones.
|
|
633
645
|
The capture job runs the pull request's code with a read-only token; only the publish job writes,
|
|
634
646
|
and it runs no repository code.
|
|
635
647
|
|
|
636
648
|
```mermaid
|
|
637
649
|
flowchart TD
|
|
638
|
-
evStart("router: pull request merged
|
|
650
|
+
evStart("router: pull request merged,<br/>the gate parked it for a person,<br/>or operation=evidence") --> evPlans
|
|
639
651
|
evPlans{"verification-plan.md<br/>in the pull request?"}
|
|
640
652
|
evPlans -.->|"no"| evIdle
|
|
641
653
|
evPlans -->|"yes"| evInspect
|
|
@@ -648,8 +660,11 @@ flowchart TD
|
|
|
648
660
|
evUp -.->|"no, after the timeout"| evUnreached
|
|
649
661
|
evReplay["replay each waypoint<br/>in headless Chromium,<br/>one JPEG per waypoint"] --> evPublish
|
|
650
662
|
evUnreached["record why nothing<br/>was captured"] --> evPublish
|
|
651
|
-
evPublish
|
|
652
|
-
|
|
663
|
+
evPublish{"publish (GITHUB_TOKEN)<br/>merged?"}
|
|
664
|
+
evPublish -->|"yes"| evDone
|
|
665
|
+
evPublish -->|"not yet"| evPreview
|
|
666
|
+
evDone(("Archived beside the plan<br/>on the default branch,<br/>one comment upserted"))
|
|
667
|
+
evPreview(("Preview on evidence-previews<br/>pr-number folder,<br/>same comment"))
|
|
653
668
|
evIdle(("Nothing to capture"))
|
|
654
669
|
|
|
655
670
|
classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
|
|
@@ -658,8 +673,8 @@ flowchart TD
|
|
|
658
673
|
classDef idle fill:#f4f4f6,stroke:#5b5b66,stroke-width:2px,color:#2b2b33
|
|
659
674
|
classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
|
|
660
675
|
class evStart start
|
|
661
|
-
class evApp,evReplay,evUnreached
|
|
662
|
-
class evPlans,evInspect,evUp decision
|
|
676
|
+
class evApp,evReplay,evUnreached action
|
|
677
|
+
class evPlans,evInspect,evUp,evPublish decision
|
|
663
678
|
class evIdle idle
|
|
664
|
-
class evDone success
|
|
679
|
+
class evDone,evPreview success
|
|
665
680
|
```
|
|
@@ -43,9 +43,16 @@ env:
|
|
|
43
43
|
BLAST_HIGH_LINES: "800"
|
|
44
44
|
BLAST_MEDIUM_FILES: "5"
|
|
45
45
|
BLAST_MEDIUM_LINES: "200"
|
|
46
|
+
# Paths that change no runtime behaviour: tests, docs, specs, translations and generated
|
|
47
|
+
# migration code. They count toward neither size nor owner paths, so a feature's tests do not
|
|
48
|
+
# double its blast radius. Protected paths are still matched against every file.
|
|
49
|
+
BLAST_IGNORE_PATHS: '(^|/)([Tt]ests?|__tests__|__snapshots__|e2e)/|(^|/)[^/]+\.[A-Za-z]*Tests?/|\.(test|spec)\.[cm]?[jt]sx?$|\.snap$|\.feature$|(^|/)openspec/|\.md$|(^|/)evidence/|(^|/)(i18n|locales?|translations?)/[^/]*\.json$|\.Designer\.cs$|ModelSnapshot\.cs$'
|
|
46
50
|
# Agent confidence below which the pull request goes to a human. The agent reports the number;
|
|
47
51
|
# this decides what it means.
|
|
48
52
|
CONFIDENCE_THRESHOLD: "0.8"
|
|
53
|
+
# Replay the verification plan when the gate hands the pull request to a person, so the
|
|
54
|
+
# reviewer sees the screenshots before deciding. "false" leaves evidence to the merge.
|
|
55
|
+
EVIDENCE_BEFORE_REVIEW: "true"
|
|
49
56
|
WORKING_LABEL: bot-working
|
|
50
57
|
IMPLEMENT_LABEL: implement
|
|
51
58
|
REVIEW_LABEL: review
|
|
@@ -203,6 +210,8 @@ jobs:
|
|
|
203
210
|
required_owners: ${{ steps.blast.outputs.required_owners }}
|
|
204
211
|
files_changed: ${{ steps.blast.outputs.files_changed }}
|
|
205
212
|
lines_changed: ${{ steps.blast.outputs.lines_changed }}
|
|
213
|
+
files_total: ${{ steps.blast.outputs.files_total }}
|
|
214
|
+
lines_total: ${{ steps.blast.outputs.lines_total }}
|
|
206
215
|
owner_hit: ${{ steps.blast.outputs.owner_hit }}
|
|
207
216
|
sensitive_hit: ${{ steps.blast.outputs.sensitive_hit }}
|
|
208
217
|
# The decision, computed once. A protected path holds the merge for a human, but it must
|
|
@@ -229,6 +238,7 @@ jobs:
|
|
|
229
238
|
high-lines: ${{ env.BLAST_HIGH_LINES }}
|
|
230
239
|
medium-files: ${{ env.BLAST_MEDIUM_FILES }}
|
|
231
240
|
medium-lines: ${{ env.BLAST_MEDIUM_LINES }}
|
|
241
|
+
ignore-paths: ${{ env.BLAST_IGNORE_PATHS }}
|
|
232
242
|
|
|
233
243
|
review_required:
|
|
234
244
|
needs: [subject, protected_changes]
|
|
@@ -291,6 +301,18 @@ jobs:
|
|
|
291
301
|
Required owners: `${{ needs.protected_changes.outputs.required_owners || 'none configured' }}`
|
|
292
302
|
|
|
293
303
|
**Verdict:** owner-review
|
|
304
|
+
- name: Replay the verification plan for the owner
|
|
305
|
+
if: needs.protected_changes.outputs.holds_review == 'true' && env.EVIDENCE_BEFORE_REVIEW == 'true'
|
|
306
|
+
continue-on-error: true
|
|
307
|
+
env:
|
|
308
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
309
|
+
REPO: ${{ github.repository }}
|
|
310
|
+
PR: ${{ needs.subject.outputs.pr }}
|
|
311
|
+
run: |
|
|
312
|
+
set -euo pipefail
|
|
313
|
+
branch=$(gh api "repos/$REPO" --jq .default_branch)
|
|
314
|
+
gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" --method POST \
|
|
315
|
+
-f ref="$branch" -f "inputs[operation]=evidence" -f "inputs[pr-number]=$PR"
|
|
294
316
|
|
|
295
317
|
reserve:
|
|
296
318
|
needs: subject
|
|
@@ -440,7 +462,7 @@ jobs:
|
|
|
440
462
|
| Disposition | `${{ needs.validate_output.outputs.outcome }}` |
|
|
441
463
|
| CI | ${{ needs.subject.outputs.conclusion }} |
|
|
442
464
|
| Blast radius | ${{ needs.protected_changes.outputs.level }} |
|
|
443
|
-
| Files / lines | ${{ needs.protected_changes.outputs.files_changed }} / ${{ needs.protected_changes.outputs.lines_changed }} |
|
|
465
|
+
| Files / lines | ${{ needs.protected_changes.outputs.files_changed }} / ${{ needs.protected_changes.outputs.lines_changed }} counted, ${{ needs.protected_changes.outputs.files_total }} / ${{ needs.protected_changes.outputs.lines_total }} in the diff |
|
|
444
466
|
| Protected paths | ${{ needs.protected_changes.outputs.requires_review == 'true' && 'yes' || 'no' }} |
|
|
445
467
|
| Owner paths | ${{ needs.protected_changes.outputs.owner_hit == 'true' && 'yes' || 'no' }} |
|
|
446
468
|
${{ needs.validate_output.outputs.outcome == 'owner-review'
|
|
@@ -453,6 +475,7 @@ jobs:
|
|
|
453
475
|
```
|
|
454
476
|
|
|
455
477
|
Findings and verification on the linked issue: #${{ needs.subject.outputs.issue }}. [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
|
478
|
+
${{ env.EVIDENCE_BEFORE_REVIEW == 'true' && contains(fromJson('["human-review","owner-review"]'), needs.validate_output.outputs.outcome) && 'Screenshots of this head follow in their own comment if it carries a verification plan. Approving the pull request merges it.' || '' }}
|
|
456
479
|
- name: Merge approved pull request
|
|
457
480
|
if: needs.validate_output.outputs.outcome == 'auto-merge'
|
|
458
481
|
env:
|
|
@@ -512,6 +535,20 @@ jobs:
|
|
|
512
535
|
token: ${{ steps.app-token.outputs.token }}
|
|
513
536
|
issue-number: ${{ needs.subject.outputs.issue }}
|
|
514
537
|
labels: ${{ env.WORKING_LABEL }}
|
|
538
|
+
# A person decides this one, so the screenshots come before the decision, in a router run of
|
|
539
|
+
# their own outside the belt. Never on auto-merge: that dispatch would race the merge.
|
|
540
|
+
- name: Replay the verification plan for the reviewer
|
|
541
|
+
if: env.EVIDENCE_BEFORE_REVIEW == 'true' && contains(fromJson('["human-review","owner-review"]'), needs.validate_output.outputs.outcome)
|
|
542
|
+
continue-on-error: true
|
|
543
|
+
env:
|
|
544
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
545
|
+
REPO: ${{ github.repository }}
|
|
546
|
+
PR: ${{ needs.subject.outputs.pr }}
|
|
547
|
+
run: |
|
|
548
|
+
set -euo pipefail
|
|
549
|
+
branch=$(gh api "repos/$REPO" --jq .default_branch)
|
|
550
|
+
gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" --method POST \
|
|
551
|
+
-f ref="$branch" -f "inputs[operation]=evidence" -f "inputs[pr-number]=$PR"
|
|
515
552
|
- name: Clear merged issue labels
|
|
516
553
|
if: needs.validate_output.outputs.outcome == 'auto-merge'
|
|
517
554
|
uses: ./.github/actions/remove-issue-labels
|
|
@@ -38,9 +38,11 @@ env:
|
|
|
38
38
|
# The SDK when a repository has .NET projects but no global.json to say which.
|
|
39
39
|
EVIDENCE_DOTNET_VERSION: "10.0.x"
|
|
40
40
|
EVIDENCE_MARKER: "<!-- agentic-evidence -->"
|
|
41
|
-
# How long the run keeps the screenshots it could not
|
|
42
|
-
# has no plan on the default branch to keep them beside.
|
|
41
|
+
# How long the run keeps the screenshots it could not commit anywhere.
|
|
43
42
|
EVIDENCE_ARTIFACT_DAYS: "14"
|
|
43
|
+
# Where an unmerged pull request's screenshots go, under pr-<number>/: its plans are not on the
|
|
44
|
+
# default branch yet, and a commit to its own branch would invalidate the gate's verdict.
|
|
45
|
+
EVIDENCE_PREVIEW_BRANCH: "evidence-previews"
|
|
44
46
|
# The capture runs in mcr.microsoft.com/playwright at this tag, pre-pulled by the runner image
|
|
45
47
|
# (runners/versions.env), so the two move together.
|
|
46
48
|
PLAYWRIGHT_VERSION: "1.62.1"
|
|
@@ -393,6 +395,72 @@ jobs:
|
|
|
393
395
|
} >> "$GITHUB_OUTPUT"
|
|
394
396
|
echo "Screenshots kept at ${SERVER}/${REPO}/tree/${sha} beside $(( ${#dirs[@]} - ${#missing[@]} )) plan(s)"
|
|
395
397
|
|
|
398
|
+
# The reviewer of a parked pull request needs the screenshots before the merge. One orphan
|
|
399
|
+
# branch holds every preview, a folder per pull request; GITHUB_TOKEN pushes start no workflow.
|
|
400
|
+
- name: Commit a preview of the screenshots
|
|
401
|
+
id: preview
|
|
402
|
+
if: steps.pr.outputs.merged != 'true'
|
|
403
|
+
env:
|
|
404
|
+
TOKEN: ${{ github.token }}
|
|
405
|
+
REPO: ${{ github.repository }}
|
|
406
|
+
SERVER: ${{ github.server_url }}
|
|
407
|
+
PREVIEW: ${{ env.EVIDENCE_PREVIEW_BRANCH }}
|
|
408
|
+
PR: ${{ inputs.pr-number }}
|
|
409
|
+
run: |
|
|
410
|
+
set -euo pipefail
|
|
411
|
+
src="$RUNNER_TEMP/evidence/files"
|
|
412
|
+
if [ ! -d "$src" ] || [ -z "$(find "$src" -name '*.jpg' -print -quit)" ]; then
|
|
413
|
+
echo "base=" >> "$GITHUB_OUTPUT"
|
|
414
|
+
exit 0
|
|
415
|
+
fi
|
|
416
|
+
|
|
417
|
+
work="$RUNNER_TEMP/evidence-preview"
|
|
418
|
+
rm -rf "$work"
|
|
419
|
+
mkdir -p "$work"
|
|
420
|
+
cd "$work"
|
|
421
|
+
auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
|
|
422
|
+
git init -q
|
|
423
|
+
git config "http.${SERVER}/.extraheader" "$auth"
|
|
424
|
+
git config user.name "github-actions[bot]"
|
|
425
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
426
|
+
git config core.sparseCheckout true
|
|
427
|
+
echo "/pr-${PR}/" > .git/info/sparse-checkout
|
|
428
|
+
git remote add origin "${SERVER}/${REPO}.git"
|
|
429
|
+
|
|
430
|
+
pushed=false
|
|
431
|
+
for attempt in 1 2 3 4 5; do
|
|
432
|
+
if git fetch -q --depth=1 --filter=blob:none origin "refs/heads/$PREVIEW" 2>/dev/null; then
|
|
433
|
+
git checkout -q -f -B "$PREVIEW" FETCH_HEAD
|
|
434
|
+
else
|
|
435
|
+
# No branch yet: start a fresh orphan each attempt, so a failed push is never
|
|
436
|
+
# mistaken for one that landed.
|
|
437
|
+
git checkout -q --orphan "preview-$attempt"
|
|
438
|
+
git rm -rq --cached . >/dev/null 2>&1 || true
|
|
439
|
+
fi
|
|
440
|
+
rm -rf "pr-${PR}"
|
|
441
|
+
mkdir -p "pr-${PR}"
|
|
442
|
+
cp -R "$src/." "pr-${PR}/"
|
|
443
|
+
git add -A "pr-${PR}"
|
|
444
|
+
if git diff --cached --quiet; then
|
|
445
|
+
pushed=true
|
|
446
|
+
break
|
|
447
|
+
fi
|
|
448
|
+
git commit -q -m "evidence: preview for #$PR [skip ci]"
|
|
449
|
+
if git push -q origin "HEAD:refs/heads/$PREVIEW"; then
|
|
450
|
+
pushed=true
|
|
451
|
+
break
|
|
452
|
+
fi
|
|
453
|
+
echo "$PREVIEW moved before the push (attempt $attempt); retrying on its new tip."
|
|
454
|
+
sleep $((attempt * 3))
|
|
455
|
+
done
|
|
456
|
+
if [ "$pushed" != "true" ]; then
|
|
457
|
+
echo "::warning::could not push the preview to $PREVIEW after 5 attempts"
|
|
458
|
+
echo "base=" >> "$GITHUB_OUTPUT"
|
|
459
|
+
exit 0
|
|
460
|
+
fi
|
|
461
|
+
sha="$(git rev-parse HEAD)"
|
|
462
|
+
echo "base=${SERVER}/${REPO}/raw/${sha}/pr-${PR}" >> "$GITHUB_OUTPUT"
|
|
463
|
+
|
|
396
464
|
# GITHUB_TOKEN on purpose: its comment raises no workflow event, so the router does not wake
|
|
397
465
|
# up to read its own record. One comment per pull request, rewritten on a re-run.
|
|
398
466
|
- name: Write the evidence comment
|
|
@@ -401,7 +469,9 @@ jobs:
|
|
|
401
469
|
PR_NUMBER: ${{ inputs.pr-number }}
|
|
402
470
|
MARKER: ${{ env.EVIDENCE_MARKER }}
|
|
403
471
|
MERGED: ${{ steps.pr.outputs.merged }}
|
|
404
|
-
IMAGE_BASE: ${{ steps.archive.outputs.base }}
|
|
472
|
+
IMAGE_BASE: ${{ steps.archive.outputs.base || steps.preview.outputs.base }}
|
|
473
|
+
PREVIEW: ${{ steps.preview.outputs.base != '' && 'true' || 'false' }}
|
|
474
|
+
PREVIEW_BRANCH: ${{ env.EVIDENCE_PREVIEW_BRANCH }}
|
|
405
475
|
MISSING: ${{ steps.archive.outputs.missing }}
|
|
406
476
|
REASON: ${{ steps.archive.outputs.reason }}
|
|
407
477
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
@@ -421,7 +491,12 @@ jobs:
|
|
|
421
491
|
// the run's artifact has it instead.
|
|
422
492
|
source = source.replace(/^!\[[^\]]*\]\(__EVIDENCE_BASE__\/([^)]*)\)\n?/gm, (line, file) => (kept(file) ? line : ''));
|
|
423
493
|
const shots = fs.readdirSync(evidence, { recursive: true }).some((name) => String(name).endsWith('.jpg'));
|
|
424
|
-
if (
|
|
494
|
+
if (process.env.PREVIEW === 'true') {
|
|
495
|
+
source = source.replace(/^The screenshots are kept beside each plan.*$/m,
|
|
496
|
+
`A preview before the merge: the screenshots are on the \`${process.env.PREVIEW_BRANCH}\` branch for now, and are kept beside each plan once the pull request merges.`);
|
|
497
|
+
} else if (!base) {
|
|
498
|
+
source = source.replace(/^The screenshots are kept beside each plan.*\n\n?/m, '');
|
|
499
|
+
}
|
|
425
500
|
if (shots && (!base || missing.length > 0)) {
|
|
426
501
|
const why = process.env.MERGED !== 'true'
|
|
427
502
|
? 'the pull request is not merged, so its plans are not on the default branch'
|
|
@@ -562,12 +562,9 @@ jobs:
|
|
|
562
562
|
# only changes between gates, every merge re-arms the siblings through
|
|
563
563
|
# detect-pr-conflicts's rescan, and the queue drains one pull request at a time.
|
|
564
564
|
#
|
|
565
|
-
# `
|
|
566
|
-
#
|
|
567
|
-
#
|
|
568
|
-
# one sibling per merge. It is a trap for a person dispatching by hand: labelling four pull
|
|
569
|
-
# requests `merge-gate` in one go leaves one running, one pending and two silently cancelled,
|
|
570
|
-
# with nothing on the pull requests to say so. Dispatch one, let it finish, then the next.
|
|
565
|
+
# `queue: max` keeps up to 100 gates waiting in order. Without it GitHub keeps one pending run
|
|
566
|
+
# per group and silently cancels the previous one: in one week Numa cancelled 12 gates and
|
|
567
|
+
# finished 6, and those pull requests were merged by hand without a verdict.
|
|
571
568
|
call-merge-gate:
|
|
572
569
|
needs: classify
|
|
573
570
|
if: needs.classify.outputs.route == 'merge-gate'
|
|
@@ -575,6 +572,7 @@ jobs:
|
|
|
575
572
|
concurrency:
|
|
576
573
|
group: merge-belt
|
|
577
574
|
cancel-in-progress: false
|
|
575
|
+
queue: max
|
|
578
576
|
permissions: write-all
|
|
579
577
|
with:
|
|
580
578
|
pr-number: ${{ needs.classify.outputs.pr-number }}
|
|
@@ -590,6 +588,94 @@ jobs:
|
|
|
590
588
|
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
591
589
|
AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
|
|
592
590
|
|
|
591
|
+
# The decision the gate parked for: a person with write access approves a bot pull request the
|
|
592
|
+
# gate handed to them (human-review or owner-review), and the bot merges the commit they
|
|
593
|
+
# approved. Re-running the gate would only reproduce the same parked verdict.
|
|
594
|
+
merge-approved:
|
|
595
|
+
needs: classify
|
|
596
|
+
if: needs.classify.outputs.route == 'merge-approved'
|
|
597
|
+
runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
|
|
598
|
+
timeout-minutes: 5
|
|
599
|
+
concurrency:
|
|
600
|
+
group: merge-approved-${{ needs.classify.outputs.pr-number }}
|
|
601
|
+
cancel-in-progress: false
|
|
602
|
+
permissions:
|
|
603
|
+
contents: read
|
|
604
|
+
pull-requests: write
|
|
605
|
+
steps:
|
|
606
|
+
- name: Checkout for App token action
|
|
607
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
608
|
+
with:
|
|
609
|
+
persist-credentials: false
|
|
610
|
+
- name: Generate App token
|
|
611
|
+
id: app-token
|
|
612
|
+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
613
|
+
with:
|
|
614
|
+
app-id: ${{ secrets.BOT_APP_ID }}
|
|
615
|
+
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
616
|
+
- name: Merge the approved commit
|
|
617
|
+
id: merge
|
|
618
|
+
env:
|
|
619
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
620
|
+
REPO: ${{ github.repository }}
|
|
621
|
+
PR: ${{ needs.classify.outputs.pr-number }}
|
|
622
|
+
ISSUE: ${{ needs.classify.outputs.linked-issue }}
|
|
623
|
+
REVIEWER: ${{ github.event.review.user.login }}
|
|
624
|
+
REVIEW_COMMIT: ${{ github.event.review.commit_id }}
|
|
625
|
+
run: |
|
|
626
|
+
set -euo pipefail
|
|
627
|
+
skip() { echo "::notice::Not merging #$PR: $1"; exit 0; }
|
|
628
|
+
|
|
629
|
+
permission=$(gh api "repos/$REPO/collaborators/$REVIEWER/permission" --jq '.permission' 2>/dev/null || echo none)
|
|
630
|
+
case "$permission" in admin|maintain|write) ;; *) skip "$REVIEWER has $permission access" ;; esac
|
|
631
|
+
|
|
632
|
+
read -r state author head_sha < <(gh api "repos/$REPO/pulls/$PR" --jq '[.state, .user.login, .head.sha] | @tsv')
|
|
633
|
+
[ "$state" = open ] || skip "it is $state"
|
|
634
|
+
case " $TRUSTED_BOTS " in *" $author "*) ;; *) skip "it was opened by $author, not the bot" ;; esac
|
|
635
|
+
[ "$head_sha" = "$REVIEW_COMMIT" ] || skip "$REVIEWER approved ${REVIEW_COMMIT:0:7}, the head is now ${head_sha:0:7}"
|
|
636
|
+
|
|
637
|
+
# Another reviewer's change request still stands.
|
|
638
|
+
decision=$(gh pr view "$PR" --repo "$REPO" --json reviewDecision --jq '.reviewDecision // ""')
|
|
639
|
+
[ "$decision" = APPROVED ] || skip "the review decision is '${decision:-none}'"
|
|
640
|
+
|
|
641
|
+
# The gate parked it for a person. A protected-path hold comments on the issue rather
|
|
642
|
+
# than the pull request, so the issue's labels speak for it.
|
|
643
|
+
verdict=$(gh api --paginate "repos/$REPO/issues/$PR/comments?per_page=100" \
|
|
644
|
+
--jq '.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .body' |
|
|
645
|
+
grep -oE '\*\*Verdict:\*\* [a-z-]+' | tail -1 | sed 's/.* //' || true)
|
|
646
|
+
if [ -z "$verdict" ] && [ -n "$ISSUE" ]; then
|
|
647
|
+
labels=$(gh api "repos/$REPO/issues/$ISSUE/labels" --jq '[.[].name] | join(" ")')
|
|
648
|
+
case " $labels " in *" blocked "*) ;; *" owner-review "*) verdict=owner-review ;; esac
|
|
649
|
+
fi
|
|
650
|
+
case "$verdict" in human-review|owner-review) ;; *) skip "the gate's verdict is '${verdict:-none}', not a hand-over" ;; esac
|
|
651
|
+
|
|
652
|
+
ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
|
|
653
|
+
--jq '[.workflow_runs[] | select(.name == env.CI_WORKFLOW_NAME)] | sort_by(.created_at) | last | .conclusion // ""')
|
|
654
|
+
[ "$ci" = success ] || skip "$CI_WORKFLOW_NAME on ${head_sha:0:7} is '${ci:-not run}'"
|
|
655
|
+
|
|
656
|
+
gh pr merge "$PR" --repo "$REPO" --squash --match-head-commit "$head_sha"
|
|
657
|
+
echo "merged=true" >> "$GITHUB_OUTPUT"
|
|
658
|
+
- name: Clear the issue's labels
|
|
659
|
+
if: steps.merge.outputs.merged == 'true' && needs.classify.outputs.linked-issue != ''
|
|
660
|
+
env:
|
|
661
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
662
|
+
REPO: ${{ github.repository }}
|
|
663
|
+
ISSUE: ${{ needs.classify.outputs.linked-issue }}
|
|
664
|
+
run: |
|
|
665
|
+
for label in implement bot-working review owner-review blocked pr-pending; do
|
|
666
|
+
gh api -X DELETE "repos/$REPO/issues/$ISSUE/labels/$label" >/dev/null 2>&1 || true
|
|
667
|
+
done
|
|
668
|
+
# GITHUB_TOKEN: its comment raises no event for the router to read.
|
|
669
|
+
- name: Say whose approval merged it
|
|
670
|
+
if: steps.merge.outputs.merged == 'true'
|
|
671
|
+
env:
|
|
672
|
+
GH_TOKEN: ${{ github.token }}
|
|
673
|
+
REPO: ${{ github.repository }}
|
|
674
|
+
PR: ${{ needs.classify.outputs.pr-number }}
|
|
675
|
+
REVIEWER: ${{ github.event.review.user.login }}
|
|
676
|
+
REVIEW_COMMIT: ${{ github.event.review.commit_id }}
|
|
677
|
+
run: gh pr comment "$PR" --repo "$REPO" --body "Merged on @$REVIEWER's approval of \`${REVIEW_COMMIT:0:7}\`."
|
|
678
|
+
|
|
593
679
|
# Screenshots of a merged pull request, replayed from the verification plan its implementation
|
|
594
680
|
# wrote and archived beside that plan. Plain YAML with no model, so no secret is passed and the
|
|
595
681
|
# grant is exactly what the publish job writes: one commit of evidence/ folders and one comment. Its capture
|
|
@@ -751,7 +837,7 @@ jobs:
|
|
|
751
837
|
}
|
|
752
838
|
|
|
753
839
|
# A gate already queued or running for this pull request will read the same CI verdict;
|
|
754
|
-
# a second dispatch
|
|
840
|
+
# a second dispatch would wait in the belt's queue and run the same gate twice. Same
|
|
755
841
|
# live-run test as the reconcile belt's stale-reservation sweep, keyed on the exact
|
|
756
842
|
# title run-name gives a gate dispatch.
|
|
757
843
|
gate_is_live() {
|
|
@@ -942,7 +1028,7 @@ jobs:
|
|
|
942
1028
|
}
|
|
943
1029
|
|
|
944
1030
|
# A gate already queued or running for this pull request will read the same CI verdict;
|
|
945
|
-
# a second dispatch
|
|
1031
|
+
# a second dispatch would wait in the belt's queue and run the same gate twice.
|
|
946
1032
|
gate_is_live() {
|
|
947
1033
|
local pr="$1" status live
|
|
948
1034
|
for status in queued in_progress waiting pending; do
|
|
@@ -1103,19 +1189,9 @@ jobs:
|
|
|
1103
1189
|
-f "inputs[ci-run-id]=$ci_run_id" \
|
|
1104
1190
|
-f "inputs[attempts_so_far]=$attempts"
|
|
1105
1191
|
|
|
1106
|
-
#
|
|
1107
|
-
#
|
|
1108
|
-
#
|
|
1109
|
-
# that has already started. A tick that dispatched three gates therefore ran the
|
|
1110
|
-
# last and silently cancelled the other two: no comment, no attempt recorded, and
|
|
1111
|
-
# nothing on the pull request to say it had been passed over. Seen on Pliny-Bot,
|
|
1112
|
-
# where dispatching a gate for PR #135 cancelled the one queued for PR #136.
|
|
1113
|
-
#
|
|
1114
|
-
# Stopping here loses nothing: every pull request eligible this tick is still
|
|
1115
|
-
# eligible next tick, and a run that never started consumes no attempt. The belt
|
|
1116
|
-
# now does one thing and says so, instead of appearing to start work it discarded.
|
|
1117
|
-
echo "One gate dispatched this tick; any other eligible pull request waits for the next one."
|
|
1118
|
-
break
|
|
1192
|
+
# Every eligible pull request in one tick. The belt queues them first in, first out
|
|
1193
|
+
# (`queue: max` on call-merge-gate); before it did, each dispatch cancelled the one
|
|
1194
|
+
# pending ahead of it, so this loop stopped after one and the rest waited an hour.
|
|
1119
1195
|
done
|
|
1120
1196
|
|
|
1121
1197
|
audit-close:
|