@plainconceptsplatform/workflows 1.2.3 → 1.3.1

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.
Files changed (45) hide show
  1. package/dist/route-processing.js +1 -0
  2. package/loops/actions/add-issue-labels/action.yml +50 -50
  3. package/loops/actions/agent-output.cjs +17 -17
  4. package/loops/actions/apply-agent-bundle/action.yml +24 -24
  5. package/loops/actions/apply-agent-comments/action.yml +42 -42
  6. package/loops/actions/apply-agent-labels/action.yml +55 -55
  7. package/loops/actions/apply-agent-output/action.yml +108 -108
  8. package/loops/actions/assess-blast-radius/action.yml +47 -8
  9. package/loops/actions/assess-blast-radius/assess-blast-radius.sh +27 -5
  10. package/loops/actions/assess-blast-radius/migration-is-additive.sh +38 -0
  11. package/loops/actions/classify-route/action.yml +2 -1
  12. package/loops/actions/classify-route/classify-route.sh +7 -1
  13. package/loops/actions/cleanup-artifacts/action.yml +91 -91
  14. package/loops/actions/close-agent-issues/action.yml +43 -43
  15. package/loops/actions/create-agent-issues/action.yml +52 -52
  16. package/loops/actions/create-issue-comment/action.yml +29 -29
  17. package/loops/actions/download-agent-output/action.yml +53 -53
  18. package/loops/actions/link-pr-to-issue/action.yml +40 -40
  19. package/loops/actions/list-open-issues/action.yml +33 -33
  20. package/loops/actions/load-issue-context/action.yml +45 -45
  21. package/loops/actions/merge-agent-pr/action.yml +49 -49
  22. package/loops/actions/push-agent-branch/action.yml +45 -45
  23. package/loops/actions/remove-issue-labels/action.yml +37 -37
  24. package/loops/actions/update-agent-issues/action.yml +58 -58
  25. package/loops/actions/validate-merge-gate-output/action.yml +6 -1
  26. package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +9 -2
  27. package/loops/actions/validate-review-output/action.yml +35 -35
  28. package/loops/actions/validate-triage-output/action.yml +36 -36
  29. package/loops/actions/verify-composite-actions/action.yml +9 -9
  30. package/loops/actions/verify-refine-output/action.yml +9 -9
  31. package/loops/actions/verify-route-matrix/action.yml +9 -9
  32. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +149 -29
  33. package/loops/docs/diagrams.md +26 -11
  34. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  35. package/loops/templates/issues/bug_report.yml +109 -109
  36. package/loops/templates/issues/feature_request.yml +75 -75
  37. package/loops/templates/release/github-release.yml +30 -30
  38. package/loops/workflows/agent-apply-review.md +5 -0
  39. package/loops/workflows/agent-implement.md +6 -0
  40. package/loops/workflows/agent-merge-gate.md +100 -3
  41. package/loops/workflows/authorize-bot-work.yml +105 -105
  42. package/loops/workflows/evidence.yml +79 -4
  43. package/loops/workflows/shared/platform-defaults.md +23 -23
  44. package/loops/workflows/work-router.yml +97 -21
  45. package/package.json +1 -1
@@ -1,105 +1,105 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/authorize-bot-work.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
- # Human adds implement/refine/direct/feature label → validates permission → bot adds bot-working
3
- # This ensures the bot is the actor for all agentic workflows.
4
- #
5
- # IMPORTANT: Only triggers for HUMAN actors. When the bot transitions refine→implement,
6
- # it adds bot-working itself, so authorize-bot-work must not fire again.
7
- name: "Authorize Bot Work"
8
-
9
- run-name: "Authorizing: ${{ github.event.issue.title }} (#${{ github.event.issue.number }})"
10
-
11
- on:
12
- issues:
13
- types: [labeled]
14
-
15
- permissions:
16
- contents: read
17
-
18
- jobs:
19
- authorize:
20
- # Only trigger for work labels from HUMANS (not bots), and only if another bot run does not
21
- # already own the issue.
22
- #
23
- # `review` is deliberately NOT excluded. It used to be, and that made the label a one-way
24
- # door: the classifier refuses to route while `review` is set, so a person adding `refine`
25
- # to a parked issue got no run, no comment and no error anywhere. Triage's own
26
- # needs-maintainer verdict tells the maintainer to add `refine`, which could not work.
27
- #
28
- # A person adding a work label IS the human review the label was waiting for, so this
29
- # workflow clears it below before handing the issue to the bot. The classifier's own guard
30
- # stays as it is: it exists to stop the *bot* re-triggering itself, and by the time
31
- # bot-working is added `review` is already gone.
32
- if: >
33
- (github.event.label.name == 'implement' ||
34
- github.event.label.name == 'refine') &&
35
- !contains(github.event.issue.labels.*.name, 'bot-working') &&
36
- !endsWith(github.actor, '[bot]')
37
- runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
38
- timeout-minutes: 5
39
- concurrency:
40
- group: authorize-${{ github.event.issue.number }}
41
- cancel-in-progress: false
42
- permissions:
43
- contents: read
44
- issues: write
45
- steps:
46
- - name: Check actor permission
47
- id: check
48
- env:
49
- GH_TOKEN: ${{ github.token }}
50
- ACTOR: ${{ github.actor }}
51
- run: |
52
- set -euo pipefail
53
-
54
- permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" \
55
- --jq '.permission' 2>/dev/null || echo "none")
56
-
57
- case "$permission" in
58
- admin | maintain | write)
59
- echo "authorized=true" >> "$GITHUB_OUTPUT"
60
- echo "::notice::$ACTOR has $permission permission - authorized"
61
- ;;
62
- *)
63
- echo "authorized=false" >> "$GITHUB_OUTPUT"
64
- echo "::warning::$ACTOR has '$permission' permission - not authorized"
65
- ;;
66
- esac
67
-
68
- - name: Checkout for App token action
69
- if: steps.check.outputs.authorized == 'true'
70
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
71
- with:
72
- persist-credentials: false
73
-
74
- - name: Generate App token
75
- if: steps.check.outputs.authorized == 'true'
76
- id: app-token
77
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
78
- with:
79
- app-id: ${{ secrets.BOT_APP_ID }}
80
- private-key: ${{ secrets.BOT_PRIVATE_KEY }}
81
-
82
- - name: Bot takes the issue over
83
- if: steps.check.outputs.authorized == 'true'
84
- env:
85
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
86
- ISSUE_NUMBER: ${{ github.event.issue.number }}
87
- LABEL: ${{ github.event.label.name }}
88
- run: |
89
- set -euo pipefail
90
-
91
- # Clear review FIRST. A person adding a work label is the human review the label was
92
- # waiting for, and the classifier refuses to route an issue that still carries it, so
93
- # removing it here is what makes the hand-off work at all. It also has to happen
94
- # before bot-working, because that is the event the classifier reads: adding
95
- # bot-working first would raise an event whose payload still shows review.
96
- # --remove-label on an absent label is a no-op, so this is safe on a fresh issue.
97
- # `stalled` goes with it: a person taking the issue on restarts the work, so the
98
- # marker the janitor retries on must not survive into the new run.
99
- gh issue edit "$ISSUE_NUMBER" --remove-label "review" --remove-label "stalled" 2>/dev/null ||
100
- echo "::notice::no review or stalled label to clear on #$ISSUE_NUMBER"
101
-
102
- echo "Adding bot-working label to issue #$ISSUE_NUMBER (triggered by $LABEL)"
103
- gh issue edit "$ISSUE_NUMBER" --add-label "bot-working"
104
-
105
- echo "::notice::bot-working label added - bot will now own the $LABEL workflow"
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/authorize-bot-work.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ # Human adds implement/refine/direct/feature label → validates permission → bot adds bot-working
3
+ # This ensures the bot is the actor for all agentic workflows.
4
+ #
5
+ # IMPORTANT: Only triggers for HUMAN actors. When the bot transitions refine→implement,
6
+ # it adds bot-working itself, so authorize-bot-work must not fire again.
7
+ name: "Authorize Bot Work"
8
+
9
+ run-name: "Authorizing: ${{ github.event.issue.title }} (#${{ github.event.issue.number }})"
10
+
11
+ on:
12
+ issues:
13
+ types: [labeled]
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ authorize:
20
+ # Only trigger for work labels from HUMANS (not bots), and only if another bot run does not
21
+ # already own the issue.
22
+ #
23
+ # `review` is deliberately NOT excluded. It used to be, and that made the label a one-way
24
+ # door: the classifier refuses to route while `review` is set, so a person adding `refine`
25
+ # to a parked issue got no run, no comment and no error anywhere. Triage's own
26
+ # needs-maintainer verdict tells the maintainer to add `refine`, which could not work.
27
+ #
28
+ # A person adding a work label IS the human review the label was waiting for, so this
29
+ # workflow clears it below before handing the issue to the bot. The classifier's own guard
30
+ # stays as it is: it exists to stop the *bot* re-triggering itself, and by the time
31
+ # bot-working is added `review` is already gone.
32
+ if: >
33
+ (github.event.label.name == 'implement' ||
34
+ github.event.label.name == 'refine') &&
35
+ !contains(github.event.issue.labels.*.name, 'bot-working') &&
36
+ !endsWith(github.actor, '[bot]')
37
+ runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
38
+ timeout-minutes: 5
39
+ concurrency:
40
+ group: authorize-${{ github.event.issue.number }}
41
+ cancel-in-progress: false
42
+ permissions:
43
+ contents: read
44
+ issues: write
45
+ steps:
46
+ - name: Check actor permission
47
+ id: check
48
+ env:
49
+ GH_TOKEN: ${{ github.token }}
50
+ ACTOR: ${{ github.actor }}
51
+ run: |
52
+ set -euo pipefail
53
+
54
+ permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" \
55
+ --jq '.permission' 2>/dev/null || echo "none")
56
+
57
+ case "$permission" in
58
+ admin | maintain | write)
59
+ echo "authorized=true" >> "$GITHUB_OUTPUT"
60
+ echo "::notice::$ACTOR has $permission permission - authorized"
61
+ ;;
62
+ *)
63
+ echo "authorized=false" >> "$GITHUB_OUTPUT"
64
+ echo "::warning::$ACTOR has '$permission' permission - not authorized"
65
+ ;;
66
+ esac
67
+
68
+ - name: Checkout for App token action
69
+ if: steps.check.outputs.authorized == 'true'
70
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
71
+ with:
72
+ persist-credentials: false
73
+
74
+ - name: Generate App token
75
+ if: steps.check.outputs.authorized == 'true'
76
+ id: app-token
77
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
78
+ with:
79
+ app-id: ${{ secrets.BOT_APP_ID }}
80
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
81
+
82
+ - name: Bot takes the issue over
83
+ if: steps.check.outputs.authorized == 'true'
84
+ env:
85
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
86
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
87
+ LABEL: ${{ github.event.label.name }}
88
+ run: |
89
+ set -euo pipefail
90
+
91
+ # Clear review FIRST. A person adding a work label is the human review the label was
92
+ # waiting for, and the classifier refuses to route an issue that still carries it, so
93
+ # removing it here is what makes the hand-off work at all. It also has to happen
94
+ # before bot-working, because that is the event the classifier reads: adding
95
+ # bot-working first would raise an event whose payload still shows review.
96
+ # --remove-label on an absent label is a no-op, so this is safe on a fresh issue.
97
+ # `stalled` goes with it: a person taking the issue on restarts the work, so the
98
+ # marker the janitor retries on must not survive into the new run.
99
+ gh issue edit "$ISSUE_NUMBER" --remove-label "review" --remove-label "stalled" 2>/dev/null ||
100
+ echo "::notice::no review or stalled label to clear on #$ISSUE_NUMBER"
101
+
102
+ echo "Adding bot-working label to issue #$ISSUE_NUMBER (triggered by $LABEL)"
103
+ gh issue edit "$ISSUE_NUMBER" --add-label "bot-working"
104
+
105
+ echo "::notice::bot-working label added - bot will now own the $LABEL workflow"
@@ -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'
@@ -1,26 +1,26 @@
1
- ---
2
- # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/shared/platform-defaults.md. Update with `workflows update --force`; consumer edits may be overwritten.
3
- description: Shared network and safe-output defaults for catalog agent workflows, plus the verification rules every worker that builds or pushes has to follow. The body below is runtime-imported into each worker's prompt ahead of its own steps.
4
-
5
- network:
6
- allowed:
7
- - defaults
8
- - forge.plainconcepts.com
9
- # opencode fetches its model registry from models.opencode.ai at startup; without
10
- # this host on the allowlist the fetch blocks bootstrap and the run stalls after
11
- # "init" until the runner kills it. The apex "opencode.ai" is not enough.
12
- - models.opencode.ai
13
- - node
14
- - github
15
- - dotnet
16
- - fonts
17
- - login.microsoftonline.com
18
- # centralised AgentMemory (App Service); the MCP shim needs it through the egress proxy
19
- - agentmemory-pro-01.azurewebsites.net
20
-
21
- safe-outputs:
22
- threat-detection: false
23
- ---
1
+ ---
2
+ # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/shared/platform-defaults.md. Update with `workflows update --force`; consumer edits may be overwritten.
3
+ description: Shared network and safe-output defaults for catalog agent workflows, plus the verification rules every worker that builds or pushes has to follow. The body below is runtime-imported into each worker's prompt ahead of its own steps.
4
+
5
+ network:
6
+ allowed:
7
+ - defaults
8
+ - forge.plainconcepts.com
9
+ # opencode fetches its model registry from models.opencode.ai at startup; without
10
+ # this host on the allowlist the fetch blocks bootstrap and the run stalls after
11
+ # "init" until the runner kills it. The apex "opencode.ai" is not enough.
12
+ - models.opencode.ai
13
+ - node
14
+ - github
15
+ - dotnet
16
+ - fonts
17
+ - login.microsoftonline.com
18
+ # centralised AgentMemory (App Service); the MCP shim needs it through the egress proxy
19
+ - agentmemory-pro-01.azurewebsites.net
20
+
21
+ safe-outputs:
22
+ threat-detection: false
23
+ ---
24
24
 
25
25
  ## Verification, for every worker that builds or pushes
26
26
 
@@ -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.3",
3
+ "version": "1.3.1",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",