@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.
@@ -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
- result = result.replace(new RegExp(`^ - ${escapeRegex(route)}\\n`, "gm"), "");
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) {
@@ -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 a bot-authored pull request once CI has reported: merge, hand to a human, or fix CI.", defaultEnabled: true },
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. detect-pr-conflicts reacts to this same
160
- # event as its own event-gated job, not as a route, so taking the route here costs it
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
- # Only a FAILED CI run on an attached pull request auto-dispatches the gate. A green
175
- # run reaches the gate through the consumer CI's dispatch-merge-gate job and the
176
- # reconcile belt, so routing success here would double-fire the gate for every passing
177
- # pull request. GitHub delivers workflow_run only for CI runs whose actor is a human;
178
- # a bot pull request's CI never arrives here at all, and the same two paths cover it.
179
- if [ "${RUN_CONCLUSION:-}" != "failure" ]; then
180
- error="CI concluded '${RUN_CONCLUSION:-}'; the gate auto-triggers only on failure"
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-gate"
182
+ route="merge-train"
183
183
  pr_number="${RUN_PR_NUMBER}"
184
- ci_conclusion="failure"
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
- if is_issue_number "${INPUT_PR_NUMBER:-}"; then
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 we authored, resolve the issue it closes, and check that issue carries the required label.
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
- REQUIRE_LABEL: ${{ inputs.require-label }}
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
- [ -n "$issue" ] || none "PR #$PR_NUMBER does not close a numbered issue"
84
-
85
- if [ -n "$REQUIRE_LABEL" ]; then
86
- labels="$(gh issue view "$issue" --repo "$REPO" --json labels \
87
- --jq '[.labels[].name]')"
88
-
89
- if ! jq -e --arg name "$REQUIRE_LABEL" 'index($name)' >/dev/null <<<"$labels"; then
90
- none "issue #$issue does not carry $REQUIRE_LABEL (has: $(jq -r 'join(", ")' <<<"$labels"))"
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"