@plainconceptsplatform/workflows 1.5.0 → 1.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/route-processing.js +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/decide-agent-retry/action.yml +31 -3
- package/loops/actions/decide-agent-retry/classify-failure.sh +50 -0
- package/loops/actions/housekeeping/action.yml +20 -0
- package/loops/actions/identify-gate-subject/action.yml +52 -17
- package/loops/actions/merge-train/action.yml +71 -0
- package/loops/actions/merge-train/merge-train.sh +495 -0
- package/loops/actions/push-agent-branch/action.yml +73 -45
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +290 -84
- package/loops/docs/diagrams.md +55 -13
- package/loops/scripts/compile-agent-workflows.mjs +20 -3
- 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-implement.md +77 -32
- package/loops/workflows/agent-merge-gate.md +90 -62
- package/loops/workflows/agent-refine.md +9 -0
- package/loops/workflows/work-router.yml +285 -471
- package/package.json +1 -1
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
|
;;
|
|
@@ -25,6 +25,9 @@ outputs:
|
|
|
25
25
|
minutes:
|
|
26
26
|
description: How long the agent job ran, or -1 when that could not be read.
|
|
27
27
|
value: ${{ steps.decide.outputs.minutes }}
|
|
28
|
+
cause:
|
|
29
|
+
description: 'Why a retry was chosen: short-run, runner-shutdown or cut-off. "none" when there is none.'
|
|
30
|
+
value: ${{ steps.decide.outputs.cause }}
|
|
28
31
|
runs:
|
|
29
32
|
using: composite
|
|
30
33
|
steps:
|
|
@@ -33,6 +36,13 @@ runs:
|
|
|
33
36
|
# repeating it buys the same wrong answer half an hour later. Duration is what separates them,
|
|
34
37
|
# and it needs no model and no log parsing to read.
|
|
35
38
|
#
|
|
39
|
+
# Except when the long run was killed rather than wrong. The VM was deleted under a forty
|
|
40
|
+
# minute implement (Pliny-Bot #699), and an AI gateway that answered nothing ended another
|
|
41
|
+
# run one turn after it began (#693's first attempt): neither is a verdict on the work, and
|
|
42
|
+
# both waited six hours for the janitor. classify-failure.sh reads those two from the job's
|
|
43
|
+
# annotations and from the line above the sandbox teardown, and they are repeated whatever
|
|
44
|
+
# the run lasted, within the same attempt budget.
|
|
45
|
+
#
|
|
36
46
|
# Written once here because the rule was in the implement worker alone, and refine, triage and
|
|
37
47
|
# apply-review parked instead -- an Odyssey refine died in three minutes on
|
|
38
48
|
# `Model 'glm-5-2' not found`, a gateway fault that clears in seconds, and waited on the
|
|
@@ -44,15 +54,16 @@ runs:
|
|
|
44
54
|
GH_TOKEN: ${{ inputs.token }}
|
|
45
55
|
REPO: ${{ github.repository }}
|
|
46
56
|
RUN_ID: ${{ github.run_id }}
|
|
57
|
+
ACTION_PATH: ${{ github.action_path }}
|
|
47
58
|
ATTEMPTS: ${{ inputs.attempts-so-far }}
|
|
48
59
|
PARK_AT: ${{ inputs.park-at }}
|
|
49
60
|
UNDER_MINUTES: ${{ inputs.under-minutes }}
|
|
50
61
|
run: |
|
|
51
62
|
set -euo pipefail
|
|
52
63
|
# The agent job belongs to this same run: a called workflow shares the caller's run id.
|
|
53
|
-
read -r started finished <<<"$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
|
|
64
|
+
read -r job_id started finished <<<"$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
|
|
54
65
|
--jq '[.jobs[] | select(.name | endswith("agent"))] | last // empty
|
|
55
|
-
| "\(.started_at // "") \(.completed_at // "")"')"
|
|
66
|
+
| "\(.id // "") \(.started_at // "") \(.completed_at // "")"')"
|
|
56
67
|
minutes=-1
|
|
57
68
|
if [ -n "${started:-}" ] && [ -n "${finished:-}" ]; then
|
|
58
69
|
minutes=$(( ( $(date -u -d "$finished" +%s) - $(date -u -d "$started" +%s) ) / 60 ))
|
|
@@ -60,13 +71,30 @@ runs:
|
|
|
60
71
|
attempts="${ATTEMPTS:-0}"
|
|
61
72
|
[ -n "$attempts" ] || attempts=0
|
|
62
73
|
retry=false
|
|
74
|
+
cause=none
|
|
63
75
|
# An unknown duration is treated as a long run: never retry on a guess.
|
|
64
76
|
if [ "$minutes" -ge 0 ] && [ "$minutes" -lt "$UNDER_MINUTES" ] && [ "$attempts" -lt "$PARK_AT" ]; then
|
|
65
77
|
retry=true
|
|
78
|
+
cause=short-run
|
|
79
|
+
fi
|
|
80
|
+
# A long run is repeated only when it was killed, not when it was wrong. The reads are best
|
|
81
|
+
# effort: a log or an annotation list that cannot be fetched leaves the run parked, exactly
|
|
82
|
+
# as before this check existed.
|
|
83
|
+
if [ "$retry" != true ] && [ "$attempts" -lt "$PARK_AT" ] && [ -n "${job_id:-}" ]; then
|
|
84
|
+
work="$(mktemp -d)"
|
|
85
|
+
gh api "repos/$REPO/check-runs/$job_id/annotations" --paginate --jq '.[].message' >"$work/annotations" 2>/dev/null || true
|
|
86
|
+
gh api "repos/$REPO/actions/jobs/$job_id/logs" >"$work/log" 2>/dev/null || true
|
|
87
|
+
kind="$(bash "$ACTION_PATH/classify-failure.sh" "$work/log" "$work/annotations" || echo none)"
|
|
88
|
+
if [ "$kind" != none ]; then
|
|
89
|
+
retry=true
|
|
90
|
+
cause="$kind"
|
|
91
|
+
fi
|
|
92
|
+
rm -rf "$work"
|
|
66
93
|
fi
|
|
67
94
|
{
|
|
68
95
|
echo "retry=$retry"
|
|
69
96
|
echo "next=$((attempts + 1))"
|
|
70
97
|
echo "minutes=$minutes"
|
|
98
|
+
echo "cause=$cause"
|
|
71
99
|
} >> "$GITHUB_OUTPUT"
|
|
72
|
-
echo "agent job ran for ${minutes}m; attempts so far ${attempts}; retry=${retry}"
|
|
100
|
+
echo "agent job ran for ${minutes}m; attempts so far ${attempts}; retry=${retry}; cause=${cause}"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/decide-agent-retry/classify-failure.sh. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
+
# Say why an agent job died, when the reason is one that repeating the run fixes.
|
|
4
|
+
#
|
|
5
|
+
# Duration alone could not tell the difference. A run that dies in three minutes produced nothing;
|
|
6
|
+
# a run that works for forty minutes and is then killed by its machine produced nothing either, and
|
|
7
|
+
# was parked for the janitor's six-hourly sweep as if the agent had been wrong. Two causes are
|
|
8
|
+
# infrastructure, not judgement:
|
|
9
|
+
#
|
|
10
|
+
# runner-shutdown the VM was taken away mid-job: the process is killed with SIGTERM and GitHub
|
|
11
|
+
# annotates the job "Process completed with exit code 143" (the log has the
|
|
12
|
+
# longer "The runner has received a shutdown signal"), whatever the model was
|
|
13
|
+
# doing. 137, a SIGKILL, is left out: an out-of-memory kill repeats itself
|
|
14
|
+
# cut-off the engine was stopped one turn after it started: the last thing the log shows
|
|
15
|
+
# before the sandbox stops is the header opencode prints when it sends a request
|
|
16
|
+
# to the model, with no reply under it. The AI gateway answered nothing, or an
|
|
17
|
+
# error opencode did not surface, and the run ended with the work unsaid
|
|
18
|
+
#
|
|
19
|
+
# A tool's output in the log can quote either phrase, so neither is searched for in the body: the
|
|
20
|
+
# shutdown is read from the annotations, which only GitHub writes, and the cut-off from the one line
|
|
21
|
+
# immediately above the sandbox teardown.
|
|
22
|
+
#
|
|
23
|
+
# Usage: classify-failure.sh LOG_FILE ANNOTATIONS_FILE
|
|
24
|
+
# Prints one word: runner-shutdown, cut-off or none.
|
|
25
|
+
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
|
|
28
|
+
log="${1:-}"
|
|
29
|
+
annotations="${2:-}"
|
|
30
|
+
|
|
31
|
+
if [ -n "$annotations" ] && [ -f "$annotations" ] && grep -qiE 'exit code 143|runner has received a shutdown signal|lost communication with the server|runner.*(was|has been) (deleted|terminated)' "$annotations"; then
|
|
32
|
+
echo runner-shutdown
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
if [ -n "$log" ] && [ -f "$log" ]; then
|
|
37
|
+
# The line the sandbox teardown starts on. grep exits 1 for none, which is an answer here.
|
|
38
|
+
stop=$(grep -n '\[INFO\] Stopping containers' "$log" | head -1 | cut -d: -f1 || true)
|
|
39
|
+
if [ -n "$stop" ] && [ "$stop" -gt 1 ]; then
|
|
40
|
+
# The last line with any text above it, ignoring the ANSI colour codes opencode wraps its
|
|
41
|
+
# output in and the timestamp every runner line carries.
|
|
42
|
+
last=$(sed -n "1,$((stop - 1))p" "$log" | sed -E 's/\x1b\[[0-9;]*m//g' | grep -vE '^(\S+Z )?[[:space:]]*$' | tail -1 || true)
|
|
43
|
+
if printf '%s' "$last" | grep -qE '^(\S+Z )?> [A-Za-z0-9_.-]+ · [A-Za-z0-9_./:-]+[[:space:]]*$'; then
|
|
44
|
+
echo cut-off
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
47
|
+
fi
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
echo none
|
|
@@ -71,13 +71,33 @@ runs:
|
|
|
71
71
|
// timeout. A stuck pending implement or refine run holds a fleet slot that the retry
|
|
72
72
|
// logic below can never free because the run never reaches a terminal state. This
|
|
73
73
|
// cancel runs on a GitHub-hosted runner, so it works even when the fleet is full.
|
|
74
|
+
//
|
|
75
|
+
// Only a run with nothing live ahead of it. The router queues implement runs FIFO
|
|
76
|
+
// (`queue: max`) so that labelling six issues at once implements all six, one after the
|
|
77
|
+
// other, and each of those takes an hour: the sixth waits five hours legitimately. A
|
|
78
|
+
// flat one-hour cutoff cancelled every run behind the first, which left its issue
|
|
79
|
+
// carrying bot-working with no run until the sweep cleared it (Pliny-Bot #706 and #644,
|
|
80
|
+
// both cancelled while #693 ran). A pending run is stuck when no run of its own kind
|
|
81
|
+
// ("Agent: Implement", "Agent: Merge Gate") is running or queued to hold the group.
|
|
74
82
|
const stuckCutoff = Date.now() - 3600 * 1000; // 1 hour ago
|
|
83
|
+
const runKind = (run) => String(run.display_title || '').split(' - ')[0];
|
|
84
|
+
const liveKinds = new Set();
|
|
85
|
+
for (const status of ['in_progress', 'queued']) {
|
|
86
|
+
for (const run of await github.paginate(github.rest.actions.listWorkflowRuns, {
|
|
87
|
+
owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'work-router.yml',
|
|
88
|
+
status, per_page: 50,
|
|
89
|
+
})) liveKinds.add(runKind(run));
|
|
90
|
+
}
|
|
75
91
|
let cancelledRuns = 0;
|
|
76
92
|
for (const run of await github.paginate(github.rest.actions.listWorkflowRuns, {
|
|
77
93
|
owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'work-router.yml',
|
|
78
94
|
status: 'pending', per_page: 50,
|
|
79
95
|
})) {
|
|
80
96
|
if (new Date(run.created_at).getTime() > stuckCutoff) continue;
|
|
97
|
+
if (liveKinds.has(runKind(run))) {
|
|
98
|
+
core.info(`run #${run.id} (${run.display_title}) waits behind a live ${runKind(run)} run; not stuck`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
81
101
|
await act(`cancelling stuck pending run #${run.id} (queued ${new Date(run.created_at).toISOString()})`, async () => {
|
|
82
102
|
await github.rest.actions.cancelWorkflowRun({
|
|
83
103
|
owner: context.repo.owner, repo: context.repo.repo, run_id: run.id,
|
|
@@ -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 (same repository, opened by a trusted bot or someone with write access) and resolve the open issue it closes, if any.
|
|
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
|
|
@@ -20,10 +24,6 @@ inputs:
|
|
|
20
24
|
description: Issue the pull request closes, when the router already resolved it.
|
|
21
25
|
required: false
|
|
22
26
|
default: ''
|
|
23
|
-
require-label:
|
|
24
|
-
description: Label the closing issue must carry. Empty disables the check.
|
|
25
|
-
required: false
|
|
26
|
-
default: ''
|
|
27
27
|
outputs:
|
|
28
28
|
found:
|
|
29
29
|
description: Whether a valid gate subject was found.
|
|
@@ -32,8 +32,17 @@ outputs:
|
|
|
32
32
|
description: Pull request number.
|
|
33
33
|
value: ${{ steps.identify.outputs.pr }}
|
|
34
34
|
issue:
|
|
35
|
-
description: Issue number the pull request closes.
|
|
35
|
+
description: Issue number the pull request closes. Empty for a person's pull request that closes none.
|
|
36
36
|
value: ${{ steps.identify.outputs.issue }}
|
|
37
|
+
record:
|
|
38
|
+
description: Where the gate writes its verdict and labels. The issue when there is one, otherwise the pull request.
|
|
39
|
+
value: ${{ steps.identify.outputs.record }}
|
|
40
|
+
head-ref:
|
|
41
|
+
description: The pull request's head branch, the only branch the gate may push to.
|
|
42
|
+
value: ${{ steps.identify.outputs.head-ref }}
|
|
43
|
+
closes:
|
|
44
|
+
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."
|
|
45
|
+
value: ${{ steps.identify.outputs.closes }}
|
|
37
46
|
conclusion:
|
|
38
47
|
description: CI conclusion, passed through unchanged or fetched from latest CI run.
|
|
39
48
|
value: ${{ steps.identify.outputs.conclusion }}
|
|
@@ -53,7 +62,7 @@ runs:
|
|
|
53
62
|
CONCLUSION: ${{ inputs.ci-conclusion }}
|
|
54
63
|
CI_RUN_ID: ${{ inputs.ci-run-id }}
|
|
55
64
|
LINKED_ISSUE: ${{ inputs.linked-issue }}
|
|
56
|
-
|
|
65
|
+
TRUSTED_BOTS: ${{ inputs.trusted-bots }}
|
|
57
66
|
run: |
|
|
58
67
|
set -euo pipefail
|
|
59
68
|
|
|
@@ -64,10 +73,31 @@ runs:
|
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
pr="$(gh pr view "$PR_NUMBER" --repo "$REPO" \
|
|
67
|
-
--json state,author,closingIssuesReferences,body)"
|
|
76
|
+
--json state,author,closingIssuesReferences,body,isCrossRepository,headRefName)"
|
|
68
77
|
|
|
69
78
|
[ "$(jq -r '.state' <<<"$pr")" = "OPEN" ] || none "PR #$PR_NUMBER is not open"
|
|
70
79
|
|
|
80
|
+
# The gate checks out the branch, runs the repository's commands on it and pushes to it,
|
|
81
|
+
# with secrets. A fork's branch is someone else's code: never.
|
|
82
|
+
[ "$(jq -r '.isCrossRepository' <<<"$pr")" = "false" ] || none "PR #$PR_NUMBER comes from a fork"
|
|
83
|
+
|
|
84
|
+
# A bot this repository trusts, or a person with write access, who could push the same
|
|
85
|
+
# branch themselves. GraphQL spells a GitHub App `app/<name>`, REST `<name>[bot]`.
|
|
86
|
+
author="$(jq -r '.author.login' <<<"$pr")"
|
|
87
|
+
bot=false
|
|
88
|
+
if [ "$(jq -r '.author.is_bot // false' <<<"$pr")" = "true" ]; then
|
|
89
|
+
for candidate in "$author" "${author#app/}[bot]"; do
|
|
90
|
+
case " $TRUSTED_BOTS " in *" $candidate "*) bot=true ;; esac
|
|
91
|
+
done
|
|
92
|
+
[ "$bot" = true ] || none "PR #$PR_NUMBER was opened by $author, a bot this repository does not trust"
|
|
93
|
+
else
|
|
94
|
+
permission="$(gh api "repos/$REPO/collaborators/$author/permission" --jq '.permission' 2>/dev/null || echo none)"
|
|
95
|
+
case "$permission" in
|
|
96
|
+
admin | maintain | write) ;;
|
|
97
|
+
*) none "PR #$PR_NUMBER was opened by $author, who has $permission access" ;;
|
|
98
|
+
esac
|
|
99
|
+
fi
|
|
100
|
+
|
|
71
101
|
issue="$LINKED_ISSUE"
|
|
72
102
|
|
|
73
103
|
if [ -z "$issue" ]; then
|
|
@@ -80,14 +110,16 @@ runs:
|
|
|
80
110
|
grep -oE '[0-9]+' | head -n 1 || true)"
|
|
81
111
|
fi
|
|
82
112
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
113
|
+
# An issue that is already closed is not the work this pull request is for: the feature
|
|
114
|
+
# landed some other way, and the label on a closed issue says nothing. Comments, labels
|
|
115
|
+
# and the acceptance criteria then come from the pull request itself. A pull request is
|
|
116
|
+
# never refused for lacking an issue: the merge train hands the gate every pull request
|
|
117
|
+
# that can move, and a gate that declines one without a trace is handed it again.
|
|
118
|
+
if [ -n "$issue" ]; then
|
|
119
|
+
issue_state="$(gh issue view "$issue" --repo "$REPO" --json state --jq '.state' 2>/dev/null || echo UNKNOWN)"
|
|
120
|
+
if [ "$issue_state" != OPEN ]; then
|
|
121
|
+
echo "Issue #$issue is $issue_state; gating the pull request on its own description."
|
|
122
|
+
issue=""
|
|
91
123
|
fi
|
|
92
124
|
fi
|
|
93
125
|
|
|
@@ -122,8 +154,11 @@ runs:
|
|
|
122
154
|
echo "found=true"
|
|
123
155
|
echo "pr=$PR_NUMBER"
|
|
124
156
|
echo "issue=$issue"
|
|
157
|
+
echo "record=${issue:-$PR_NUMBER}"
|
|
158
|
+
echo "head-ref=$(jq -r '.headRefName' <<<"$pr")"
|
|
159
|
+
if [ -n "$issue" ]; then echo "closes=closes issue #$issue"; else echo "closes=closes no issue"; fi
|
|
125
160
|
echo "conclusion=$ci_conclusion"
|
|
126
161
|
echo "run-id=$ci_run_id"
|
|
127
162
|
} >> "$GITHUB_OUTPUT"
|
|
128
163
|
|
|
129
|
-
echo "PR #$PR_NUMBER closes #$issue; CI concluded $ci_conclusion"
|
|
164
|
+
echo "PR #$PR_NUMBER by $author closes #${issue:-nothing}; CI concluded $ci_conclusion"
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
max-repairs:
|
|
32
|
+
description: How many pull requests may be in repair at once. Merges stay one at a time.
|
|
33
|
+
required: false
|
|
34
|
+
default: '1'
|
|
35
|
+
stuck-hours:
|
|
36
|
+
description: How long the train waits behind a CI run that never finishes before it passes that pull request by.
|
|
37
|
+
required: false
|
|
38
|
+
default: '3'
|
|
39
|
+
dry-run:
|
|
40
|
+
description: Decide and report the move without making it.
|
|
41
|
+
required: false
|
|
42
|
+
default: 'false'
|
|
43
|
+
ci-run-id:
|
|
44
|
+
description: A CI run that handed its verdict over from inside itself, and so still reads as in progress.
|
|
45
|
+
required: false
|
|
46
|
+
default: ''
|
|
47
|
+
ci-conclusion:
|
|
48
|
+
description: That run's verdict, success or failure, which stands in for its conclusion.
|
|
49
|
+
required: false
|
|
50
|
+
default: ''
|
|
51
|
+
|
|
52
|
+
runs:
|
|
53
|
+
using: composite
|
|
54
|
+
steps:
|
|
55
|
+
- name: Move the train
|
|
56
|
+
shell: bash
|
|
57
|
+
env:
|
|
58
|
+
GH_TOKEN: ${{ inputs.token }}
|
|
59
|
+
REPO: ${{ github.repository }}
|
|
60
|
+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
61
|
+
CI_WORKFLOW_NAME: ${{ inputs.ci-workflow-name }}
|
|
62
|
+
TRUSTED_BOTS: ${{ inputs.trusted-bots }}
|
|
63
|
+
HOLD_LABEL: ${{ inputs.hold-label }}
|
|
64
|
+
MAX_ATTEMPTS: ${{ inputs.max-gate-attempts }}
|
|
65
|
+
MAX_RETRIES: ${{ inputs.max-retries }}
|
|
66
|
+
STUCK_HOURS: ${{ inputs.stuck-hours }}
|
|
67
|
+
MAX_REPAIRS: ${{ inputs.max-repairs }}
|
|
68
|
+
DRY_RUN: ${{ inputs.dry-run }}
|
|
69
|
+
HINT_RUN_ID: ${{ inputs.ci-run-id }}
|
|
70
|
+
HINT_CONCLUSION: ${{ inputs.ci-conclusion }}
|
|
71
|
+
run: bash "${GITHUB_ACTION_PATH}/merge-train.sh"
|