@plainconceptsplatform/workflows 1.2.2 → 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.
@@ -10,6 +10,7 @@ const routeCrons = {
10
10
  const routeHelperJobs = {
11
11
  implement: ["check-implement-pr"],
12
12
  triage: ["dispatch-triage"],
13
+ "merge-gate": ["merge-approved"],
13
14
  };
14
15
  export function stripRouteFromRouter(yaml, route) {
15
16
  let result = yaml;
@@ -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 in the diff.
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
- files=$(jq -r '.[].filename' /tmp/blast-files.json)
98
- files_changed=$(jq -r 'length' /tmp/blast-files.json)
99
- lines_changed=$(jq -r '[.[] | .additions + .deletions] | add // 0' /tmp/blast-files.json)
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
- bash "${{ github.action_path }}/assess-blast-radius.sh" \
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' "$files" | grep -qE "$regex"; then
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 GITHUB_OUTPUT lines on stdout.
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' "$files" | grep -E "$pattern" || true
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: steps.classify.outputs.route == 'merge-gate' || steps.classify.outputs.route == 'apply-review'
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
- route="apply-review"
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 is cancelled by the single-slot merge-belt queue (two cancellations on 2026-09-06).
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
- # The belt dispatches one gate per tick and stops. The gate's concurrency group holds a single
2370
- # pending run, so GitHub cancels every earlier pending dispatch in it -- `cancel-in-progress:
2371
- # false` only protects a run that has already started. A loop without this `break` dispatched one
2372
- # gate per eligible pull request, ran the last, and discarded the rest with no comment, no
2373
- # recorded attempt, and nothing on the pull request to show it had been skipped. Asserted because
2374
- # removing the break produces no error anywhere: the extra dispatches all return 204.
2375
- BELT_ONE_PER_TICK_OK=1
2376
- belt_dispatch_block=$(awk '
2377
- /Dispatching Merge Gate for PR/ { found = 1 }
2378
- found { print }
2379
- found && /^ *done$/ { exit }
2380
- ' "$ROUTER_YML")
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.
@@ -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,<br/>CODEOWNERS and diff shape"}
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. A pull request that
632
- is not merged has no plan on the default branch, so its screenshots stay in the run's artifact.
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<br/>or operation=evidence") --> evPlans
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["publish (GITHUB_TOKEN)<br/>commit beside each plan,<br/>evidence/ on the default branch,<br/>upsert one comment"] --> evDone
652
- evDone(("Archived<br/>beside the plan"))
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,evPublish action
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
  ```
@@ -276,6 +276,9 @@ jobs:
276
276
  with:
277
277
  format: cyclonedx-json
278
278
  artifact-name: ${{ env.SBOM_ARTIFACT_NAME }}
279
+ # On a tag that already has a GitHub Release the action attaches the SBOM to it, which
280
+ # needs contents: write this workflow does not have, and fails the run that gates deploy.
281
+ upload-release-assets: false
279
282
 
280
283
  # A bot pull request is opened with GITHUB_TOKEN, and GitHub delivers no workflow_run event
281
284
  # for the CI runs that follow (the run's actor is the bot). The router's CI-completion route
@@ -206,6 +206,9 @@ jobs:
206
206
  with:
207
207
  format: cyclonedx-json
208
208
  artifact-name: ${{ env.SBOM_ARTIFACT_NAME }}
209
+ # On a tag that already has a GitHub Release the action attaches the SBOM to it, which
210
+ # needs contents: write this workflow does not have, and fails the run that gates deploy.
211
+ upload-release-assets: false
209
212
 
210
213
  # A bot pull request is opened with GITHUB_TOKEN, and GitHub delivers no workflow_run event
211
214
  # for the CI runs that follow (the run's actor is the bot). The router's CI-completion route
@@ -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 archive: a pull request that is not merged
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 (!base) source = source.replace(/^The screenshots are kept beside each plan.*\n\n?/m, '');
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
- # `cancel-in-progress: false` protects the RUNNING gate, not the waiting ones: GitHub keeps
566
- # exactly one pending run per group and cancels the previously pending one when a new run
567
- # arrives. That is fine for the path this belt is built for, where detect-pr-conflicts re-arms
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 queues behind merge-belt and the single-slot queue cancels it. Same
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 queues behind merge-belt and the single-slot queue cancels it.
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
- # One gate per tick, because that is all the concurrency group can hold. The gate
1107
- # runs in `merge-belt`, and GitHub keeps only the most recent *pending* run in a
1108
- # group -- `cancel-in-progress: false` does not change that, it only protects a run
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",