@plainconceptsplatform/workflows 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +0 -0
- package/dist/route-processing.js +9 -2
- package/dist/workflow-catalog.js +1 -1
- package/loops/actions/apply-agent-output/action.yml +5 -0
- package/loops/actions/classify-route/classify-route.sh +34 -13
- package/loops/actions/identify-gate-subject/action.yml +50 -7
- package/loops/actions/merge-train/action.yml +66 -0
- package/loops/actions/merge-train/merge-train.sh +424 -0
- package/loops/actions/push-agent-branch/action.yml +60 -45
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +124 -80
- package/loops/docs/diagrams.md +55 -13
- package/loops/templates/ci/app-ci-dotnet-next.yml +13 -14
- package/loops/templates/ci/app-ci-node-monorepo.yml +13 -14
- package/loops/workflows/agent-merge-gate.md +75 -57
- package/loops/workflows/work-router.yml +231 -464
- package/package.json +8 -9
package/dist/index.js
CHANGED
|
File without changes
|
package/dist/route-processing.js
CHANGED
|
@@ -10,7 +10,12 @@ const routeCrons = {
|
|
|
10
10
|
const routeHelperJobs = {
|
|
11
11
|
implement: ["check-implement-pr"],
|
|
12
12
|
triage: ["dispatch-triage"],
|
|
13
|
-
"merge-gate": ["merge-approved"],
|
|
13
|
+
"merge-gate": ["merge-approved", "merge-train"],
|
|
14
|
+
};
|
|
15
|
+
// Dispatch operations that exist only to serve one worker route. A router without the worker
|
|
16
|
+
// would offer an operation no job answers, which the route matrix reports.
|
|
17
|
+
const routeHelperOperations = {
|
|
18
|
+
"merge-gate": ["merge-train"],
|
|
14
19
|
};
|
|
15
20
|
export function stripRouteFromRouter(yaml, route) {
|
|
16
21
|
let result = yaml;
|
|
@@ -21,7 +26,9 @@ export function stripRouteFromRouter(yaml, route) {
|
|
|
21
26
|
for (const job of [`call-${route}`, ...(routeHelperJobs[route] ?? [])]) {
|
|
22
27
|
result = removeJobBlock(result, job);
|
|
23
28
|
}
|
|
24
|
-
|
|
29
|
+
for (const operation of [route, ...(routeHelperOperations[route] ?? [])]) {
|
|
30
|
+
result = result.replace(new RegExp(`^ - ${escapeRegex(operation)}\\n`, "gm"), "");
|
|
31
|
+
}
|
|
25
32
|
return result;
|
|
26
33
|
}
|
|
27
34
|
export function processRoutes(files, selectedRoutes) {
|
package/dist/workflow-catalog.js
CHANGED
|
@@ -12,7 +12,7 @@ export const workflowRoutes = [
|
|
|
12
12
|
{ name: "implement", worker: "agent-implement.md", description: "Implements an issue and opens a pull request. Stops there: the merge decision belongs to the merge gate.", defaultEnabled: true },
|
|
13
13
|
{ name: "triage", worker: "agent-triage.md", description: "Triages issues opened from outside the organisation: runs 10 checks (template, security, size, danger, duplicates, clarity, reproducibility, acceptance, cross-cutting, product scope). Loops up to 3 rounds. Passes to refine, asks the author for more, parks out-of-scope work open for a maintainer, or closes what is genuinely rejected.", defaultEnabled: true },
|
|
14
14
|
{ name: "apply-review", worker: "agent-apply-review.md", description: "Applies reviewer feedback to an open pull request the bot authored, then pushes the fixes to the same branch.", defaultEnabled: true },
|
|
15
|
-
{ name: "merge-gate", worker: "agent-merge-gate.md", description: "Decides what happens to
|
|
15
|
+
{ name: "merge-gate", worker: "agent-merge-gate.md", description: "Decides what happens to the pull request the merge train hands it, the bot's or a person's: repair it, merge it, or hand it to a human.", defaultEnabled: true },
|
|
16
16
|
{ name: "audit", worker: "agent-audit.md", description: "Read-only repository audit. Finds 5-7 problems, scores each 1-10, and files a single issue that Refine then sizes and splits.", defaultEnabled: true },
|
|
17
17
|
{ name: "release", worker: "agent-release.md", description: "Writes release notes from the commit log, then a deterministic job opens a version-bump pull request with auto-merge; once it merges, release-tag.yml tags the commit and publishes the GitHub Release. Manual dispatch only (operation=release).", defaultEnabled: true, companions: ["release-tag.yml"] },
|
|
18
18
|
];
|
|
@@ -19,6 +19,10 @@ inputs:
|
|
|
19
19
|
description: Apply push_to_pull_request_branch items.
|
|
20
20
|
required: false
|
|
21
21
|
default: 'false'
|
|
22
|
+
expected-branch:
|
|
23
|
+
description: The only branch a push may target. A push naming any other branch is refused. Empty accepts the branch the item names.
|
|
24
|
+
required: false
|
|
25
|
+
default: ''
|
|
22
26
|
close-issues:
|
|
23
27
|
description: Apply close_issue items.
|
|
24
28
|
required: false
|
|
@@ -73,6 +77,7 @@ runs:
|
|
|
73
77
|
output-file: ${{ steps.output.outputs.output-file }}
|
|
74
78
|
bundle-file: ${{ steps.output.outputs.bundle-file }}
|
|
75
79
|
token: ${{ inputs.token }}
|
|
80
|
+
expected-branch: ${{ inputs.expected-branch }}
|
|
76
81
|
|
|
77
82
|
- name: Close issues
|
|
78
83
|
if: inputs.close-issues == 'true' && steps.output.outputs.item-count != '0'
|
|
@@ -156,9 +156,9 @@ classify_route() {
|
|
|
156
156
|
ci_run_id=""
|
|
157
157
|
elif [ "${ACTION:-}" = "closed" ] && [ "${PR_MERGED:-false}" = "true" ]; then
|
|
158
158
|
# A merge is when the change's verification plan describes what the default branch now
|
|
159
|
-
# does, so the evidence run replays it then.
|
|
160
|
-
# event as
|
|
161
|
-
# nothing.
|
|
159
|
+
# does, so the evidence run replays it then. The merge train and land-stack-child react
|
|
160
|
+
# to this same event as event-gated jobs, not as routes, so taking the route here costs
|
|
161
|
+
# them nothing.
|
|
162
162
|
if [ "${EVIDENCE_ENABLED:-true}" = "true" ]; then
|
|
163
163
|
route="evidence"
|
|
164
164
|
pr_number="${EVENT_PR_NUMBER:-}"
|
|
@@ -171,17 +171,17 @@ classify_route() {
|
|
|
171
171
|
;;
|
|
172
172
|
|
|
173
173
|
workflow_run)
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
if [ "${RUN_CONCLUSION:-}" != "failure" ]; then
|
|
180
|
-
error="CI concluded '${RUN_CONCLUSION:-}';
|
|
174
|
+
# A CI verdict on a pull request moves the merge train, green or red. The train, not this
|
|
175
|
+
# event, decides which pull request the gate takes next: dispatching the gate for this one
|
|
176
|
+
# directly is what let a night of pull requests race each other for the default branch.
|
|
177
|
+
# GitHub delivers workflow_run only for CI runs whose actor is a human; a bot-updated pull
|
|
178
|
+
# request's CI reaches the train through the consumer CI's dispatch job and the schedule.
|
|
179
|
+
if [ "${RUN_CONCLUSION:-}" != "failure" ] && [ "${RUN_CONCLUSION:-}" != "success" ]; then
|
|
180
|
+
error="CI concluded '${RUN_CONCLUSION:-}'; only a success or a failure moves the merge train"
|
|
181
181
|
elif is_issue_number "${RUN_PR_NUMBER:-}"; then
|
|
182
|
-
route="merge-
|
|
182
|
+
route="merge-train"
|
|
183
183
|
pr_number="${RUN_PR_NUMBER}"
|
|
184
|
-
ci_conclusion="
|
|
184
|
+
ci_conclusion="${RUN_CONCLUSION}"
|
|
185
185
|
ci_run_id="${RUN_ID:-}"
|
|
186
186
|
else
|
|
187
187
|
error="CI run has no attached pull request"
|
|
@@ -237,7 +237,17 @@ classify_route() {
|
|
|
237
237
|
fi
|
|
238
238
|
;;
|
|
239
239
|
merge-gate)
|
|
240
|
-
|
|
240
|
+
# A consumer CI hands its verdict over with GITHUB_TOKEN, whose dispatches carry
|
|
241
|
+
# github-actions[bot] as the actor; the train and a person dispatch with other
|
|
242
|
+
# identities. That handover is a CI completion, so it moves the train rather than
|
|
243
|
+
# jumping the queue. The CI templates now dispatch merge-train; this keeps a consumer
|
|
244
|
+
# CI that still dispatches merge-gate in line until it is updated.
|
|
245
|
+
if is_issue_number "${INPUT_PR_NUMBER:-}" && [ "${ACTOR:-}" = "github-actions[bot]" ]; then
|
|
246
|
+
route="merge-train"
|
|
247
|
+
pr_number="${INPUT_PR_NUMBER}"
|
|
248
|
+
ci_conclusion="${INPUT_CI_CONCLUSION:-}"
|
|
249
|
+
ci_run_id="${INPUT_CI_RUN_ID:-}"
|
|
250
|
+
elif is_issue_number "${INPUT_PR_NUMBER:-}"; then
|
|
241
251
|
route="merge-gate"
|
|
242
252
|
pr_number="${INPUT_PR_NUMBER}"
|
|
243
253
|
ci_conclusion="${INPUT_CI_CONCLUSION:-}"
|
|
@@ -260,6 +270,17 @@ classify_route() {
|
|
|
260
270
|
route="${OPERATION}"
|
|
261
271
|
trigger_kind="${INPUT_TRIGGER_KIND:-manual}"
|
|
262
272
|
;;
|
|
273
|
+
merge-train)
|
|
274
|
+
# The train always takes the oldest pull request that can move. A CI handover names its
|
|
275
|
+
# pull request and verdict because it runs inside that CI run, which still reads as in
|
|
276
|
+
# progress when the train looks; the verdict stands in for the run's own conclusion.
|
|
277
|
+
route="merge-train"
|
|
278
|
+
if is_issue_number "${INPUT_PR_NUMBER:-}"; then
|
|
279
|
+
pr_number="${INPUT_PR_NUMBER}"
|
|
280
|
+
ci_conclusion="${INPUT_CI_CONCLUSION:-}"
|
|
281
|
+
ci_run_id="${INPUT_CI_RUN_ID:-}"
|
|
282
|
+
fi
|
|
283
|
+
;;
|
|
263
284
|
audit-close | cleanup-artifacts | reconcile-bot-pr-runs | housekeeping | validate)
|
|
264
285
|
route="${OPERATION}"
|
|
265
286
|
;;
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/identify-gate-subject/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
2
|
name: Identify gate subject
|
|
3
|
-
description: Confirm the pull request is one
|
|
3
|
+
description: Confirm the pull request is one the gate may act on, resolve the issue it closes, and check a bot pull request's issue carries the required label.
|
|
4
4
|
inputs:
|
|
5
5
|
token:
|
|
6
6
|
description: GitHub token with issues and pull-requests read access.
|
|
7
7
|
required: true
|
|
8
|
+
trusted-bots:
|
|
9
|
+
description: Space-separated bot logins whose pull requests the gate gates. Anyone else needs write access.
|
|
10
|
+
required: false
|
|
11
|
+
default: ''
|
|
8
12
|
pr-number:
|
|
9
13
|
description: Pull request number to gate.
|
|
10
14
|
required: true
|
|
@@ -21,7 +25,7 @@ inputs:
|
|
|
21
25
|
required: false
|
|
22
26
|
default: ''
|
|
23
27
|
require-label:
|
|
24
|
-
description: Label
|
|
28
|
+
description: Label a bot pull request's closing issue must carry. Empty disables the check. A person's pull request needs neither an issue nor the label.
|
|
25
29
|
required: false
|
|
26
30
|
default: ''
|
|
27
31
|
outputs:
|
|
@@ -32,8 +36,17 @@ outputs:
|
|
|
32
36
|
description: Pull request number.
|
|
33
37
|
value: ${{ steps.identify.outputs.pr }}
|
|
34
38
|
issue:
|
|
35
|
-
description: Issue number the pull request closes.
|
|
39
|
+
description: Issue number the pull request closes. Empty for a person's pull request that closes none.
|
|
36
40
|
value: ${{ steps.identify.outputs.issue }}
|
|
41
|
+
record:
|
|
42
|
+
description: Where the gate writes its verdict and labels. The issue when there is one, otherwise the pull request.
|
|
43
|
+
value: ${{ steps.identify.outputs.record }}
|
|
44
|
+
head-ref:
|
|
45
|
+
description: The pull request's head branch, the only branch the gate may push to.
|
|
46
|
+
value: ${{ steps.identify.outputs.head-ref }}
|
|
47
|
+
closes:
|
|
48
|
+
description: "What the pull request closes, as the prompt says it: `closes issue #N` or `closes no issue`. gh-aw allows no expression logic in a prompt."
|
|
49
|
+
value: ${{ steps.identify.outputs.closes }}
|
|
37
50
|
conclusion:
|
|
38
51
|
description: CI conclusion, passed through unchanged or fetched from latest CI run.
|
|
39
52
|
value: ${{ steps.identify.outputs.conclusion }}
|
|
@@ -54,6 +67,7 @@ runs:
|
|
|
54
67
|
CI_RUN_ID: ${{ inputs.ci-run-id }}
|
|
55
68
|
LINKED_ISSUE: ${{ inputs.linked-issue }}
|
|
56
69
|
REQUIRE_LABEL: ${{ inputs.require-label }}
|
|
70
|
+
TRUSTED_BOTS: ${{ inputs.trusted-bots }}
|
|
57
71
|
run: |
|
|
58
72
|
set -euo pipefail
|
|
59
73
|
|
|
@@ -64,10 +78,31 @@ runs:
|
|
|
64
78
|
}
|
|
65
79
|
|
|
66
80
|
pr="$(gh pr view "$PR_NUMBER" --repo "$REPO" \
|
|
67
|
-
--json state,author,closingIssuesReferences,body)"
|
|
81
|
+
--json state,author,closingIssuesReferences,body,isCrossRepository,headRefName)"
|
|
68
82
|
|
|
69
83
|
[ "$(jq -r '.state' <<<"$pr")" = "OPEN" ] || none "PR #$PR_NUMBER is not open"
|
|
70
84
|
|
|
85
|
+
# The gate checks out the branch, runs the repository's commands on it and pushes to it,
|
|
86
|
+
# with secrets. A fork's branch is someone else's code: never.
|
|
87
|
+
[ "$(jq -r '.isCrossRepository' <<<"$pr")" = "false" ] || none "PR #$PR_NUMBER comes from a fork"
|
|
88
|
+
|
|
89
|
+
# A bot this repository trusts, or a person with write access, who could push the same
|
|
90
|
+
# branch themselves. GraphQL spells a GitHub App `app/<name>`, REST `<name>[bot]`.
|
|
91
|
+
author="$(jq -r '.author.login' <<<"$pr")"
|
|
92
|
+
bot=false
|
|
93
|
+
if [ "$(jq -r '.author.is_bot // false' <<<"$pr")" = "true" ]; then
|
|
94
|
+
for candidate in "$author" "${author#app/}[bot]"; do
|
|
95
|
+
case " $TRUSTED_BOTS " in *" $candidate "*) bot=true ;; esac
|
|
96
|
+
done
|
|
97
|
+
[ "$bot" = true ] || none "PR #$PR_NUMBER was opened by $author, a bot this repository does not trust"
|
|
98
|
+
else
|
|
99
|
+
permission="$(gh api "repos/$REPO/collaborators/$author/permission" --jq '.permission' 2>/dev/null || echo none)"
|
|
100
|
+
case "$permission" in
|
|
101
|
+
admin | maintain | write) ;;
|
|
102
|
+
*) none "PR #$PR_NUMBER was opened by $author, who has $permission access" ;;
|
|
103
|
+
esac
|
|
104
|
+
fi
|
|
105
|
+
|
|
71
106
|
issue="$LINKED_ISSUE"
|
|
72
107
|
|
|
73
108
|
if [ -z "$issue" ]; then
|
|
@@ -80,9 +115,14 @@ runs:
|
|
|
80
115
|
grep -oE '[0-9]+' | head -n 1 || true)"
|
|
81
116
|
fi
|
|
82
117
|
|
|
83
|
-
|
|
118
|
+
# The bot only opens a pull request for an issue it was given, so one that closes none, or
|
|
119
|
+
# whose issue lost the label, is not work the gate was handed. A person's pull request is
|
|
120
|
+
# gated on its own description when it closes nothing.
|
|
121
|
+
if [ "$bot" = true ]; then
|
|
122
|
+
[ -n "$issue" ] || none "PR #$PR_NUMBER does not close a numbered issue"
|
|
123
|
+
fi
|
|
84
124
|
|
|
85
|
-
if [ -n "$REQUIRE_LABEL" ]; then
|
|
125
|
+
if [ "$bot" = true ] && [ -n "$REQUIRE_LABEL" ]; then
|
|
86
126
|
labels="$(gh issue view "$issue" --repo "$REPO" --json labels \
|
|
87
127
|
--jq '[.labels[].name]')"
|
|
88
128
|
|
|
@@ -122,8 +162,11 @@ runs:
|
|
|
122
162
|
echo "found=true"
|
|
123
163
|
echo "pr=$PR_NUMBER"
|
|
124
164
|
echo "issue=$issue"
|
|
165
|
+
echo "record=${issue:-$PR_NUMBER}"
|
|
166
|
+
echo "head-ref=$(jq -r '.headRefName' <<<"$pr")"
|
|
167
|
+
if [ -n "$issue" ]; then echo "closes=closes issue #$issue"; else echo "closes=closes no issue"; fi
|
|
125
168
|
echo "conclusion=$ci_conclusion"
|
|
126
169
|
echo "run-id=$ci_run_id"
|
|
127
170
|
} >> "$GITHUB_OUTPUT"
|
|
128
171
|
|
|
129
|
-
echo "PR #$PR_NUMBER closes #$issue; CI concluded $ci_conclusion"
|
|
172
|
+
echo "PR #$PR_NUMBER by $author closes #${issue:-nothing}; CI concluded $ci_conclusion"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/merge-train/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
name: Merge train
|
|
3
|
+
description: >-
|
|
4
|
+
Move the oldest open pull request that can move one step towards merging (update it from its
|
|
5
|
+
base, hand it to the merge gate, or approve its CI), and leave every other one alone.
|
|
6
|
+
Deterministic: no model runs here.
|
|
7
|
+
|
|
8
|
+
inputs:
|
|
9
|
+
token:
|
|
10
|
+
description: App token. Its dispatches and branch updates must start workflows, which GITHUB_TOKEN's do not.
|
|
11
|
+
required: true
|
|
12
|
+
ci-workflow-name:
|
|
13
|
+
description: The CI workflow whose verdict the train reads.
|
|
14
|
+
required: true
|
|
15
|
+
trusted-bots:
|
|
16
|
+
description: Space-separated bot logins whose pull requests ride the train and whose CI runs it approves.
|
|
17
|
+
required: false
|
|
18
|
+
default: ''
|
|
19
|
+
hold-label:
|
|
20
|
+
description: A pull request carrying this label stays off the train.
|
|
21
|
+
required: false
|
|
22
|
+
default: hold
|
|
23
|
+
max-gate-attempts:
|
|
24
|
+
description: Failed gate attempts on one head before the train stops dispatching it.
|
|
25
|
+
required: false
|
|
26
|
+
default: '6'
|
|
27
|
+
max-retries:
|
|
28
|
+
description: How many times a blocked verdict, or a merge that did not happen, is retried after the base branch moves.
|
|
29
|
+
required: false
|
|
30
|
+
default: '3'
|
|
31
|
+
stuck-hours:
|
|
32
|
+
description: How long the train waits behind a CI run that never finishes before it passes that pull request by.
|
|
33
|
+
required: false
|
|
34
|
+
default: '3'
|
|
35
|
+
dry-run:
|
|
36
|
+
description: Decide and report the move without making it.
|
|
37
|
+
required: false
|
|
38
|
+
default: 'false'
|
|
39
|
+
ci-run-id:
|
|
40
|
+
description: A CI run that handed its verdict over from inside itself, and so still reads as in progress.
|
|
41
|
+
required: false
|
|
42
|
+
default: ''
|
|
43
|
+
ci-conclusion:
|
|
44
|
+
description: That run's verdict, success or failure, which stands in for its conclusion.
|
|
45
|
+
required: false
|
|
46
|
+
default: ''
|
|
47
|
+
|
|
48
|
+
runs:
|
|
49
|
+
using: composite
|
|
50
|
+
steps:
|
|
51
|
+
- name: Move the train
|
|
52
|
+
shell: bash
|
|
53
|
+
env:
|
|
54
|
+
GH_TOKEN: ${{ inputs.token }}
|
|
55
|
+
REPO: ${{ github.repository }}
|
|
56
|
+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
57
|
+
CI_WORKFLOW_NAME: ${{ inputs.ci-workflow-name }}
|
|
58
|
+
TRUSTED_BOTS: ${{ inputs.trusted-bots }}
|
|
59
|
+
HOLD_LABEL: ${{ inputs.hold-label }}
|
|
60
|
+
MAX_ATTEMPTS: ${{ inputs.max-gate-attempts }}
|
|
61
|
+
MAX_RETRIES: ${{ inputs.max-retries }}
|
|
62
|
+
STUCK_HOURS: ${{ inputs.stuck-hours }}
|
|
63
|
+
DRY_RUN: ${{ inputs.dry-run }}
|
|
64
|
+
HINT_RUN_ID: ${{ inputs.ci-run-id }}
|
|
65
|
+
HINT_CONCLUSION: ${{ inputs.ci-conclusion }}
|
|
66
|
+
run: bash "${GITHUB_ACTION_PATH}/merge-train.sh"
|