@plainconceptsplatform/workflows 1.7.0 → 1.8.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.
@@ -27,6 +27,7 @@ head_ref=$(jq -r .headRefName <<<"$pr_json")
27
27
 
28
28
  owner=${REPO%%/*}
29
29
  name=${REPO#*/}
30
+ # shellcheck disable=SC2016
30
31
  queue=$(gh api graphql -f query='query($o:String!,$n:String!,$b:String!){repository(owner:$o,name:$n){mergeQueue(branch:$b){id}}}' \
31
32
  -f o="$owner" -f n="$name" -f b="$base" --jq '.data.repository.mergeQueue.id // ""' 2>/dev/null || true)
32
33
 
@@ -50,6 +51,7 @@ pr_id=$(gh pr view "$PR" --repo "$REPO" --json id --jq .id)
50
51
  # GraphQL first: the asynchronous merge endpoint refuses a GitHub App with "Enqueuer is not
51
52
  # authorized to merge" (Agentic-Workflow-Dogfood, 2026-10-20), while enqueuePullRequest takes the
52
53
  # same token. A stacked pull request refuses GraphQL and only the asynchronous endpoint takes it.
54
+ # shellcheck disable=SC2016
53
55
  enqueued=$(gh api graphql -f query='mutation($id:ID!,$sha:GitObjectID!){enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$sha}){mergeQueueEntry{state}}}' \
54
56
  -f id="$pr_id" -f sha="$HEAD_SHA" 2>&1 || true)
55
57
  if grep -q '"mergeQueueEntry":{' <<<"$enqueued"; then
@@ -63,7 +65,11 @@ if ! grep -q 'part of a stack' <<<"$enqueued"; then
63
65
  exit 1
64
66
  fi
65
67
 
66
- answer=$(gh api -X PUT "repos/$REPO/pulls/$PR/merge-async" -f merge_action=merge_queue -f sha="$HEAD_SHA")
68
+ # A stack enters the queue only through merge-async, which refuses a GitHub App there. The App lands
69
+ # it directly instead, through the ruleset's bypass list (it must be on it): every layer has passed its
70
+ # own checks and the gate, and the whole stack lands as one operation.
71
+ answer=$(gh api -X PUT "repos/$REPO/pulls/$PR/merge-async" -f merge_action=direct_merge -f merge_method=squash \
72
+ -F bypass_rules=true -f sha="$HEAD_SHA")
67
73
  status=$(jq -r .status <<<"$answer")
68
74
  uuid=$(jq -r '.details.uuid // ""' <<<"$answer")
69
75
  for _ in $(seq 1 12); do
@@ -85,8 +91,7 @@ case "$status" in
85
91
  ;;
86
92
  *)
87
93
  message=$(jq -r '.details.message // ""' <<<"$answer")
88
- # A stack has no other way in, and GitHub refuses an App on this endpoint: a person enqueues it.
89
- echo "::error::Stack #$PR did not reach the $base merge queue (status '${status}'${message:+: $message}). GitHub lets a GitHub App enqueue a stack only through this endpoint, which refuses Apps today; a person adds the stack to the queue from the pull request."
94
+ echo "::error::Stack #$PR was not merged (status '${status}'${message:+: $message}). The App lands a stack by bypassing the base branch's ruleset, so it must be on that ruleset's bypass list."
90
95
  out ""
91
96
  exit 1
92
97
  ;;
@@ -219,8 +219,7 @@ deepen_ci() {
219
219
  [ -n "$run" ] || return 1
220
220
  mark_full_ci "$pr" || return 1
221
221
  # A CI handover reaches the train from inside the run, and a run still going cannot be re-run.
222
- local i
223
- for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
222
+ for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do
224
223
  [ "$(gh api "repos/$REPO/actions/runs/$run" --jq .status 2>/dev/null)" = completed ] && break
225
224
  sleep 10
226
225
  done
@@ -231,6 +230,7 @@ deepen_ci() {
231
230
  # UNMERGEABLE or MERGEABLE, or empty when it is not in one.
232
231
  queue_state() {
233
232
  local pr="$1"
233
+ # shellcheck disable=SC2016
234
234
  gh api graphql -f query='query($o:String!,$n:String!,$p:Int!){repository(owner:$o,name:$n){pullRequest(number:$p){mergeQueueEntry{state}}}}' \
235
235
  -f o="${REPO%%/*}" -f n="${REPO#*/}" -F p="$pr" \
236
236
  --jq '.data.repository.pullRequest.mergeQueueEntry.state // ""' 2>/dev/null || true
@@ -76,6 +76,7 @@ fi
76
76
  git push --quiet "$remote" "HEAD:refs/heads/${rescued_branch}"
77
77
 
78
78
  grep -qiE "(closes|fixes|resolves) #${ISSUE}\b" "$work/body.md" || printf '\n\nCloses #%s\n' "$ISSUE" >> "$work/body.md"
79
+ # shellcheck disable=SC2016
79
80
  printf '\n\nRebased onto `%s` by the workflow: the agent'"'"'s commits no longer applied after `%s` moved during the run.\n' \
80
81
  "$base" "$base" >> "$work/body.md"
81
82
  [[ "$title" == "[bot] "* ]] || title="[bot] $title"
@@ -31,7 +31,14 @@ inputs:
31
31
  description: "check-diff result on the pull request: pass, fail or absent. Empty means it did not report."
32
32
  required: false
33
33
  default: absent
34
+ unattended:
35
+ description: "true lets a judgement call (low confidence, unmet acceptance criteria, hard to undo) merge instead of going to a person. Facts the workflow measured still hold the pull request."
36
+ required: false
37
+ default: 'false'
34
38
  outputs:
39
+ flags:
40
+ description: "What an unattended merge accepted, on one line, for the pull request and the final report. Empty when nothing was."
41
+ value: ${{ steps.validate.outputs.flags }}
35
42
  valid:
36
43
  description: Whether the output produced a usable disposition.
37
44
  value: ${{ steps.validate.outputs.valid }}
@@ -59,13 +66,29 @@ runs:
59
66
  OWNER_HIT: ${{ inputs.owner-hit }}
60
67
  CONFIDENCE_THRESHOLD: ${{ inputs.confidence-threshold }}
61
68
  CODE_RULES: ${{ inputs.code-rules }}
69
+ UNATTENDED: ${{ inputs.unattended }}
62
70
  run: |
63
71
  set -euo pipefail
64
72
 
65
73
  outcome="$(bash "${{ github.action_path }}/validate-merge-gate-output.sh" \
66
74
  "$OUTPUT_FILE" "$ISSUE_NUMBER" "$CI_CONCLUSION" \
67
- "$BLAST_LEVEL" "$PROTECTED_HIT" "$OWNER_HIT" "$CONFIDENCE_THRESHOLD" "$CODE_RULES")"
75
+ "$BLAST_LEVEL" "$PROTECTED_HIT" "$OWNER_HIT" "$CONFIDENCE_THRESHOLD" "$CODE_RULES" "$UNATTENDED")"
68
76
  echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
77
+
78
+ # What the unattended merge let through, named so the person reading the report later knows
79
+ # where to look. It is read from the same report the decision used, never from the prose.
80
+ flags=""
81
+ if [ "$UNATTENDED" = true ] && [ "$outcome" = auto-merge ]; then
82
+ flags=$(jq -r --arg threshold "$CONFIDENCE_THRESHOLD" --arg blast "$BLAST_LEVEL" '
83
+ [.items[]? | select(.type == "add_comment") | .body | strings
84
+ | [scan("```json\\s*(.*?)```"; "m") | .[0]] | last | select(. != null) | fromjson?] | last // {}
85
+ | [ (if (.confidence // 1) < ($threshold | tonumber) then "confidence \(.confidence)" else empty end),
86
+ (if .acceptanceCriteriaMet == false then "acceptance criteria reported unmet" else empty end),
87
+ (if ((.recoverability // "") | ascii_downcase) == "low" then "hard to undo" else empty end),
88
+ (if $blast == "medium" then "medium blast radius" else empty end) ]
89
+ | join("; ")' "$OUTPUT_FILE" 2>/dev/null || true)
90
+ fi
91
+ echo "flags=$flags" >> "$GITHUB_OUTPUT"
69
92
  echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
70
93
  pushed=$(jq -r '[.items[]? | select(.type == "push_to_pull_request_branch")] | length > 0' "$OUTPUT_FILE" 2>/dev/null || echo false)
71
94
  echo "pushed=$pushed" >> "$GITHUB_OUTPUT"
@@ -19,7 +19,7 @@
19
19
  # Usage:
20
20
  # validate-merge-gate-output.sh OUTPUT_FILE ISSUE CI_CONCLUSION \
21
21
  # BLAST_LEVEL PROTECTED_HIT OWNER_HIT CONFIDENCE_THRESHOLD \
22
- # CODE_RULES
22
+ # CODE_RULES [UNATTENDED]
23
23
 
24
24
  set -euo pipefail
25
25
 
@@ -37,6 +37,12 @@ confidence_threshold="${7-0.8}"
37
37
  # pass, fail or absent (the repository has no check-diff). An empty value is a job that did not
38
38
  # report, and like every other unmeasured fact it goes to a person.
39
39
  code_rules="${8-absent}"
40
+ # "true" lets a pull request whose only reason for a person is a judgement call merge anyway: low
41
+ # agent confidence, acceptance criteria reported unmet, or a change reported hard to undo. The
42
+ # facts that stop a merge stay: CI, verified critical or high findings, protected and owner paths,
43
+ # a high blast radius, a failing check-diff and anything the workflow could not measure. The
44
+ # caller records what was accepted so a person can read it afterwards.
45
+ unattended="${9-false}"
40
46
 
41
47
  # An empty measured level is not a low-risk pull request, it is a job that did not report. The
42
48
  # defaults above exist for a caller that genuinely has nothing to say; an empty string arriving
@@ -61,7 +67,7 @@ jq -r \
61
67
  --arg protected "$protected_hit" \
62
68
  --arg owner "$owner_hit" \
63
69
  --arg threshold "$confidence_threshold" \
64
- --arg code "$code_rules" '
70
+ --arg code "$code_rules" --arg unattended "$unattended" '
65
71
 
66
72
  def level_rank: {"low": 0, "medium": 1, "high": 2}[.];
67
73
  def is_bool: type == "boolean";
@@ -161,9 +167,10 @@ jq -r \
161
167
  # pull request goes to a person rather than through on a default.
162
168
  elif $protected != "false" or $owner != "false" then "human-review"
163
169
  elif $code != "pass" and $code != "absent" then "human-review"
164
- elif $level == 1 and $recoverability == "low" then "human-review"
165
- elif ($report | has("acceptanceCriteriaMet")) and $report.acceptanceCriteriaMet == false then "human-review"
166
- elif $confidence < ($threshold | tonumber) then "human-review"
170
+ elif ($level == 1 and $recoverability == "low")
171
+ or (($report | has("acceptanceCriteriaMet")) and $report.acceptanceCriteriaMet == false)
172
+ or $confidence < ($threshold | tonumber)
173
+ then (if $unattended == "true" then "auto-merge" else "human-review" end)
167
174
  else "auto-merge"
168
175
  end
169
176
  end
@@ -1608,10 +1608,10 @@ if [ -f "$GATE_VALIDATOR" ] && worker_installed merge-gate; then
1608
1608
 
1609
1609
  gate_case() {
1610
1610
  local name="$1" want="$2" json="$3" conclusion="$4"
1611
- local blast="${5:-low}" protected="${6:-false}" owner="${7:-false}" code="${8-pass}"
1611
+ local blast="${5:-low}" protected="${6:-false}" owner="${7:-false}" code="${8-pass}" unattended="${9:-false}"
1612
1612
  printf '%s' "$json" > "$gate_fixture"
1613
1613
  local got
1614
- got=$(bash "$GATE_VALIDATOR" "$gate_fixture" 7 "$conclusion" "$blast" "$protected" "$owner" 0.8 "$code" 2>&1)
1614
+ got=$(bash "$GATE_VALIDATOR" "$gate_fixture" 7 "$conclusion" "$blast" "$protected" "$owner" 0.8 "$code" "$unattended" 2>&1)
1615
1615
  if [ "$got" != "$want" ]; then
1616
1616
  VALIDATOR_OK=0
1617
1617
  echo "FAIL: the merge-gate validator called '${name}' ${got}, expected ${want}" >&2
@@ -1681,6 +1681,21 @@ if [ -f "$GATE_VALIDATOR" ] && worker_installed merge-gate; then
1681
1681
  gate_case "confidence below the threshold needs a person" \
1682
1682
  human-review "$(gate_items "$(gate_comment assessed "$gate_unsure")")" success low
1683
1683
 
1684
+ # Unattended, a judgement call merges and is reported; a measured fact still stops the merge.
1685
+ gate_case "unattended, low confidence merges" auto-merge "$(gate_items "$(gate_comment assessed "$gate_unsure")")" success low false false pass true
1686
+ gate_case "unattended, an unmet criterion merges" \
1687
+ auto-merge "$(gate_items "$(gate_comment assessed "$gate_unmet")")" success low false false pass true
1688
+ gate_case "unattended, medium risk that cannot be undone merges" \
1689
+ auto-merge "$(gate_items "$(gate_comment assessed "$gate_fragile")")" success medium false false pass true
1690
+ gate_case "unattended, a verified critical finding still blocks" \
1691
+ blocked "$(gate_items "$(gate_comment assessed "$gate_verified")")" success low false false pass true
1692
+ gate_case "unattended, a protected path still needs the owner" \
1693
+ owner-review "$(gate_items "$(gate_comment assessed "$gate_unsure")")" success low true false pass true
1694
+ gate_case "unattended, a high blast radius still needs the owner" \
1695
+ owner-review "$(gate_items "$(gate_comment assessed "$gate_unsure")")" success high false false pass true
1696
+ gate_case "unattended, a check-diff that did not report still needs a person" \
1697
+ human-review "$(gate_items "$(gate_comment assessed "$gate_unsure")")" success low false false '' true
1698
+
1684
1699
  # The agent may raise the measured blast radius when it sees something the path rules could
1685
1700
  # not. It may never lower it, which is the only direction that can turn a person's review into
1686
1701
  # a machine merge.
@@ -2066,6 +2081,7 @@ if worker_installed merge-gate; then
2066
2081
  assert "a conflict is still the gate's even when main is red" gate "$(move MERGEABLE=CONFLICTING CI_STATUS=completed CI_CONCLUSION=failure SAME_AS_MAIN=true)"
2067
2082
  # The wait for a red base is bounded. Pliny-Bot's main was red from 19:30 and the train waited
2068
2083
  # on it until morning, saying nothing, while five pull requests sat with their repairs unspent.
2084
+ # shellcheck disable=SC2015
2069
2085
  grep -q 'MAIN_RED_WAIT_HOURS' "$TRAIN_SH" && grep -q 'red_for_hours' "$TRAIN_SH" &&
2070
2086
  grep -q 'main-red-wait-hours' "${HERE}/../merge-train/action.yml" ||
2071
2087
  { FAIL=$((FAIL + 1)); echo "FAIL: the merge train waits on a red base branch with no time limit" >&2; }
@@ -23,7 +23,9 @@ Every diagram uses the same shapes and the same palette, so reading one teaches
23
23
  | Dotted arrow | the unhappy path |
24
24
 
25
25
  Each worker diagram also names the rung each stage sits on, from the determinism ladder in
26
- `skills/workflow-author`: the lower the rung, the cheaper and more reproducible the decision.
26
+ `skills/workflow-author`: the lower the rung, the cheaper and more reproducible the decision. A
27
+ rung is a step on that ladder, numbered 0 to 6 from the router's trigger to the safe outputs. The
28
+ term is ours, not gh-aw's, so it does not appear in the gh-aw documentation.
27
29
 
28
30
  ---
29
31
 
@@ -188,6 +190,9 @@ of them runs exactly the pipeline above and nothing else. The root is held while
188
190
  Once every child has landed and the feature branch is green, the gate decides it like any pull
189
191
  request and merges it, unless the blast radius, a protected path or a finding sends it to a person
190
192
  (`STACK_ROOT_AUTOMERGE: "false"` restores a human merge for every root).
193
+ With `UNATTENDED: "true"` a feature blocked by another waits for that feature's root to merge;
194
+ the router's `stack-landed` job then brings the default branch into its branch, releases its
195
+ children and opens an Unattended report issue.
191
196
 
192
197
  ---
193
198
 
@@ -66,6 +66,14 @@ env:
66
66
  # Agent confidence below which the pull request goes to a human. The agent reports the number;
67
67
  # this decides what it means.
68
68
  CONFIDENCE_THRESHOLD: "0.8"
69
+ # "true" is for a repository that is left to work alone, overnight or across a stack of features.
70
+ # A pull request whose only reason for a person is a judgement call (agent confidence under the
71
+ # threshold, acceptance criteria reported unmet, a change reported hard to undo) then merges, and
72
+ # the pull request and the stack's final report name what was accepted. What still stops a merge
73
+ # is what was measured, not judged: CI, a verified critical or high finding, a protected or owner
74
+ # path, a high blast radius, a failing check-diff and anything the workflow could not measure.
75
+ # "false" sends every one of them to a person.
76
+ UNATTENDED: "false"
69
77
  # Replay the verification plan when the gate hands the pull request to a person, so the
70
78
  # reviewer sees the screenshots before deciding. "false" leaves evidence to the merge.
71
79
  EVIDENCE_BEFORE_REVIEW: "true"
@@ -501,6 +509,7 @@ jobs:
501
509
  outcome: ${{ steps.validate.outputs.outcome }}
502
510
  pushed: ${{ steps.validate.outputs.pushed }}
503
511
  empty: ${{ steps.validate.outputs.empty }}
512
+ flags: ${{ steps.validate.outputs.flags }}
504
513
  steps:
505
514
  - name: Checkout workflow actions
506
515
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -525,6 +534,7 @@ jobs:
525
534
  owner-hit: ${{ needs.protected_changes.outputs.owner_hit }}
526
535
  confidence-threshold: ${{ env.CONFIDENCE_THRESHOLD }}
527
536
  code-rules: ${{ needs.code_rules.outputs.result }}
537
+ unattended: ${{ env.UNATTENDED }}
528
538
  conclude:
529
539
  needs: [activation, subject, protected_changes, agent, safe_outputs, validate_output]
530
540
  # `protected_changes.result == 'success'` is stated rather than relied on. GitHub skips a job
@@ -599,6 +609,9 @@ jobs:
599
609
  | 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 |
600
610
  | Protected paths | ${{ needs.protected_changes.outputs.requires_review == 'true' && 'yes' || 'no' }} |
601
611
  | Owner paths | ${{ needs.protected_changes.outputs.owner_hit == 'true' && 'yes' || 'no' }} |
612
+ ${{ needs.validate_output.outputs.flags != ''
613
+ && format('| Accepted unattended | {0} |', needs.validate_output.outputs.flags)
614
+ || '' }}
602
615
  ${{ needs.validate_output.outputs.outcome == 'owner-review'
603
616
  && format('| Required owners | `{0}` |', needs.protected_changes.outputs.required_owners != '' && needs.protected_changes.outputs.required_owners || 'none configured, see the matched paths above')
604
617
  || '' }}
@@ -161,6 +161,13 @@ env:
161
161
  # same gate as any pull request: a change over the blast radius limit, a protected path or an
162
162
  # unresolved finding still goes to a person. "false" keeps every root for a human.
163
163
  MERGE_TRAIN_STACK_ROOT_AUTOMERGE: "true"
164
+ # "true" lets a chain of features run without a person between them. A feature whose issue is
165
+ # blocked by another (GitHub's "blocked by" relation, or a `Blocked by #N` line in its body) keeps
166
+ # its children held until that feature's stack root merges; the merge brings the default branch
167
+ # into the waiting feature's branch, releases its children, and leaves an "Unattended report"
168
+ # issue naming every pull request that merged on a judgement call. Set the merge gate's own
169
+ # UNATTENDED to the same value. "false" leaves features to be started and read by a person.
170
+ UNATTENDED: "false"
164
171
  # "true" logs the move the train would make and makes none. For trying the train on a
165
172
  # repository before letting it act.
166
173
  MERGE_TRAIN_DRY_RUN: "false"
@@ -660,15 +667,30 @@ jobs:
660
667
  [ "${landed:-0}" -gt 0 ] && continue
661
668
  waiting="$waiting #$b"
662
669
  done
670
+ # Unattended, a whole feature can wait on another one: its issue is blocked by a feature
671
+ # whose stack root has not merged into the default branch. Every child of it waits, whatever
672
+ # its own siblings say, and stack-landed releases them when that root merges.
673
+ if [ "${UNATTENDED:-false}" = true ]; then
674
+ feature=$(printf '%s' "$body" | grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
675
+ if [ -n "$feature" ]; then
676
+ for b in $( { gh api "repos/$REPO/issues/$feature/dependencies/blocked_by" --jq '.[].number' 2>/dev/null || true
677
+ gh issue view "$feature" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
678
+ | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' || true
679
+ } | sort -u ); do
680
+ gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open || continue
681
+ waiting="$waiting #$b"
682
+ done
683
+ fi
684
+ fi
663
685
  [ -n "$waiting" ] || exit 0
664
- echo "::notice::#$ISSUE_NUMBER is blocked by$waiting, not yet landed on $STACK_BRANCH; holding it."
686
+ echo "::notice::#$ISSUE_NUMBER is blocked by$waiting, not yet landed; holding it."
665
687
  echo "stack-blocked=true" >> "$GITHUB_OUTPUT"
666
688
  gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-label bot-working >/dev/null 2>&1 || true
667
689
  marker='<!-- stack-held -->'
668
690
  if ! gh api "repos/$REPO/issues/$ISSUE_NUMBER/comments?per_page=100" --jq '.[].body' 2>/dev/null | grep -qF "$marker"; then
669
691
  {
670
692
  printf '%s\n' "$marker"
671
- printf "Held until%s have landed on \`%s\`: this child builds on them. It keeps \`implement\` and is released when they merge.\n" "$waiting" "$STACK_BRANCH"
693
+ printf "Held until%s have landed: this child builds on them. It keeps \`implement\` and is released when they merge (a sibling into \`%s\`, a feature it waits on into the default branch).\n" "$waiting" "$STACK_BRANCH"
672
694
  } > /tmp/held.md
673
695
  gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body-file /tmp/held.md >/dev/null || true
674
696
  fi
@@ -1292,6 +1314,181 @@ jobs:
1292
1314
  fi
1293
1315
  fi
1294
1316
 
1317
+ # A stack root merged into the default branch: the feature is in. When the repository works
1318
+ # unattended two things follow: the next feature in a chain starts, and a report is left for the
1319
+ # person who comes back. Event-gated, like land-stack-child, because a merge already routes to
1320
+ # evidence. It NEVER checks out or runs pull request code; it reads and writes through the API.
1321
+ stack-landed:
1322
+ needs: classify
1323
+ if: >
1324
+ github.event_name == 'pull_request_target' &&
1325
+ github.event.action == 'closed' &&
1326
+ github.event.pull_request.merged == true &&
1327
+ github.event.pull_request.base.ref == github.event.repository.default_branch
1328
+ runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
1329
+ timeout-minutes: 10
1330
+ permissions:
1331
+ contents: read
1332
+ pull-requests: read
1333
+ steps:
1334
+ - name: Checkout for App token action
1335
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1336
+ with:
1337
+ persist-credentials: false
1338
+ - name: Generate App token
1339
+ id: app-token
1340
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
1341
+ with:
1342
+ app-id: ${{ secrets.BOT_APP_ID }}
1343
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
1344
+ - name: Start the next feature in the chain and write the report
1345
+ env:
1346
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
1347
+ REPO: ${{ github.repository }}
1348
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
1349
+ EVENT_PR: ${{ github.event.pull_request.number }}
1350
+ run: |
1351
+ set -euo pipefail
1352
+ if [ "${UNATTENDED:-false}" != true ]; then
1353
+ echo "UNATTENDED is not true: features are chained and reported by a person."
1354
+ exit 0
1355
+ fi
1356
+
1357
+ # The feature this root delivered: a closed issue that names this pull request as its
1358
+ # stack root. A merge that is not a stack root (every ordinary pull request) ends here.
1359
+ parent=""
1360
+ for issue in $(gh pr view "$EVENT_PR" --repo "$REPO" --json closingIssuesReferences \
1361
+ --jq '.closingIssuesReferences[].number' 2>/dev/null || true); do
1362
+ root=$(gh issue view "$issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
1363
+ | grep -oE '<!-- stack-root-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
1364
+ if [ "$root" = "$EVENT_PR" ]; then parent="$issue"; break; fi
1365
+ done
1366
+ if [ -z "$parent" ]; then
1367
+ echo "PR #$EVENT_PR is not a stack root; nothing to do."
1368
+ exit 0
1369
+ fi
1370
+ root_branch=$(gh pr view "$EVENT_PR" --repo "$REPO" --json headRefName --jq '.headRefName')
1371
+ echo "Stack #$parent landed on $DEFAULT_BRANCH through #$EVENT_PR ($root_branch)."
1372
+
1373
+ # What a feature waits on is read from the issue's native "blocked by" relation and from
1374
+ # `Blocked by #N` lines in its body, because refine rewrites a body and the relation
1375
+ # survives that. Open ones only; the feature that just landed counts as done even before
1376
+ # GitHub has closed its issue.
1377
+ blockers_of() {
1378
+ {
1379
+ gh api "repos/$REPO/issues/$1/dependencies/blocked_by" --jq '.[] | select(.state == "open") | .number' 2>/dev/null || true
1380
+ for b in $(gh issue view "$1" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
1381
+ | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true); do
1382
+ gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open && echo "$b" || true
1383
+ done
1384
+ } | grep -vx "$parent" | sort -u || true
1385
+ }
1386
+ depends_on_parent() {
1387
+ { gh api "repos/$REPO/issues/$1/dependencies/blocked_by" --jq '.[].number' 2>/dev/null || true
1388
+ gh issue view "$1" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
1389
+ | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' || true
1390
+ } | grep -qx "$parent"
1391
+ }
1392
+
1393
+ # The held children of every feature that was waiting on this one.
1394
+ declare -A next_branch
1395
+ declare -A next_children
1396
+ candidates=$(gh issue list --repo "$REPO" --state open --label implement --limit 200 \
1397
+ --json number,labels,body \
1398
+ --jq '.[] | select([.labels[].name] | index("bot-working") | not)
1399
+ | select(.body | test("<!-- split-parent: [0-9]+ -->"))
1400
+ | [.number, (.body | capture("<!-- split-parent: (?<p>[0-9]+) -->").p)] | @tsv')
1401
+ while IFS=$'\t' read -r child feature; do
1402
+ [ -n "${child:-}" ] || continue
1403
+ depends_on_parent "$feature" || continue
1404
+ [ -z "$(blockers_of "$feature")" ] || { echo "Feature #$feature still waits on other work; leaving #$child held."; continue; }
1405
+ if [ -z "${next_branch[$feature]:-}" ]; then
1406
+ next_branch[$feature]=$(gh issue view "$feature" --repo "$REPO" --json body --jq '.body // ""' \
1407
+ | grep -oE '<!-- stack-root-branch: [^ ]+ -->' | head -1 | sed -E 's/<!-- stack-root-branch: ([^ ]+) -->/\1/' || true)
1408
+ fi
1409
+ next_children[$feature]="${next_children[$feature]:-} $child"
1410
+ done <<< "$candidates"
1411
+
1412
+ for feature in "${!next_children[@]}"; do
1413
+ branch="${next_branch[$feature]:-}"
1414
+ if ! printf '%s' "$branch" | grep -qE '^[A-Za-z0-9._/-]+$'; then
1415
+ echo "Feature #$feature has no readable stack branch; its children stay held."
1416
+ continue
1417
+ fi
1418
+ # The feature branch was cut from a default branch that did not have the feature that
1419
+ # just landed, and its children build on it. Bring the default branch in first. A
1420
+ # conflict is a person's.
1421
+ # 201 merged, 204 already up to date; both exit 0. A conflict (409) or a missing branch
1422
+ # exits non-zero with the reason on stderr.
1423
+ if ! answer=$(gh api -X POST "repos/$REPO/merges" -f base="$branch" -f head="$DEFAULT_BRANCH" \
1424
+ -f commit_message="Merge $DEFAULT_BRANCH into $branch: #$parent landed" 2>&1 >/dev/null); then
1425
+ answer=$(printf '%s' "$answer" | tr '\n' ' ' | cut -c1-200)
1426
+ echo "::warning::Merging $DEFAULT_BRANCH into $branch failed ($answer); feature #$feature stays held."
1427
+ gh label create needs-human --repo "$REPO" --color B60205 --description "A person has to look" >/dev/null 2>&1 || true
1428
+ gh issue edit "$feature" --repo "$REPO" --add-label needs-human >/dev/null 2>&1 || true
1429
+ gh issue comment "$feature" --repo "$REPO" --body "Feature #$parent landed on $DEFAULT_BRANCH, but \`$DEFAULT_BRANCH\` could not be merged into \`$branch\` ($answer). The children of this feature stay held until a person resolves the branch and re-adds \`implement\` to the first one." >/dev/null || true
1430
+ continue
1431
+ fi
1432
+ for child in ${next_children[$feature]}; do
1433
+ own=$(gh issue view "$child" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
1434
+ | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
1435
+ waiting=""
1436
+ for b in $own; do
1437
+ gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open && waiting="$waiting #$b" || true
1438
+ done
1439
+ if [ -n "$waiting" ]; then
1440
+ echo "#$child still waits on$waiting inside its feature."
1441
+ continue
1442
+ fi
1443
+ gh issue edit "$child" --repo "$REPO" --remove-label implement >/dev/null 2>&1 || true
1444
+ gh issue edit "$child" --repo "$REPO" --add-label implement >/dev/null
1445
+ echo "Released #$child of feature #$feature."
1446
+ done
1447
+ done
1448
+
1449
+ # The report: what merged without a person, and what the gate noted on the way. One issue
1450
+ # per feature, written once; its marker makes a rerun a no-op.
1451
+ marker="<!-- unattended-report: $parent -->"
1452
+ if [ -n "$(gh issue list --repo "$REPO" --state all --search "\"$marker\" in:body" --json number --jq '.[].number')" ]; then
1453
+ echo "Report for #$parent already exists."
1454
+ exit 0
1455
+ fi
1456
+ title=$(gh issue view "$parent" --repo "$REPO" --json title --jq '.title')
1457
+ {
1458
+ echo "$marker"
1459
+ echo "Feature #$parent was delivered without a person: root pull request #$EVENT_PR is in \`$DEFAULT_BRANCH\`."
1460
+ echo
1461
+ echo "## Merged pull requests"
1462
+ echo
1463
+ } > /tmp/report.md
1464
+ flagged=0
1465
+ for pr in $(gh pr list --repo "$REPO" --state merged --base "$root_branch" --limit 100 --json number --jq '.[].number') "$EVENT_PR"; do
1466
+ line=$(gh pr view "$pr" --repo "$REPO" --json title,url --jq '"- [#\(.url | split("/") | last)](\(.url)) \(.title)"')
1467
+ notes=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" --paginate \
1468
+ --jq '.[].body | select(contains("Accepted unattended")) | capture("Accepted unattended \\| (?<f>[^|]+) \\|").f' 2>/dev/null | tail -1 || true)
1469
+ if [ -n "$notes" ]; then
1470
+ flagged=$((flagged + 1))
1471
+ printf '%s: **%s**\n' "$line" "$notes" >> /tmp/report.md
1472
+ else
1473
+ printf '%s\n' "$line" >> /tmp/report.md
1474
+ fi
1475
+ done
1476
+ {
1477
+ echo
1478
+ if [ "$flagged" -gt 0 ]; then
1479
+ echo "## Look at these"
1480
+ echo
1481
+ echo "$flagged pull request(s) above merged on a judgement call, not on a measured fact. Each was bold above with what the gate accepted. Nothing here was a verified critical or high finding, a protected path, a failing check or a high blast radius, because those still stop a merge. Read them when you can, and revert any you would not have merged."
1482
+ else
1483
+ echo "Nothing was merged on a judgement call. Every pull request passed the gate on measured facts."
1484
+ fi
1485
+ } >> /tmp/report.md
1486
+ label=unattended-report
1487
+ [ "$flagged" -gt 0 ] && label=needs-human
1488
+ gh label create needs-human --repo "$REPO" --color B60205 --description "A person has to look" >/dev/null 2>&1 || true
1489
+ gh label create unattended-report --repo "$REPO" --color 0E8A16 --description "What an unattended feature delivered" >/dev/null 2>&1 || true
1490
+ gh issue create --repo "$REPO" --title "Unattended report: $title" --label "$label" --body-file /tmp/report.md
1491
+
1295
1492
  reconcile-bot-pr-runs:
1296
1493
  needs: classify
1297
1494
  if: needs.classify.outputs.route == 'reconcile-bot-pr-runs'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",