@plainconceptsplatform/workflows 1.4.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.
@@ -30,6 +30,8 @@ export interface CatalogInstallResult {
30
30
  readonly baselines: readonly BaselineStatus[];
31
31
  readonly upToDate: boolean;
32
32
  readonly dryRun: boolean;
33
+ /** Problems the update cannot fix on its own, for a person to act on. */
34
+ readonly warnings: readonly string[];
33
35
  }
34
36
  export interface CatalogInstallOptions {
35
37
  /** Also overwrite files whose ownership header was removed, and changed templates. */
@@ -7,7 +7,7 @@ import { promisify } from "node:util";
7
7
  import { parse as parseYaml } from "yaml";
8
8
  import { catalogTemplates, mandatoryFiles, retiredWorkflowFiles, routeNames, templateNames, workflowRoutes } from "./workflow-catalog.js";
9
9
  import { processRoutes, excludedWorkerFiles } from "./route-processing.js";
10
- import { generateOpencodeCi, generateOpencodeConfig, generateStackDefaults, injectStackEnv } from "./stack-defaults.js";
10
+ import { generateOpencodeCi, generateOpencodeConfig, generateStackDefaults, injectStackEnv, shippedVerifyDefaults } from "./stack-defaults.js";
11
11
  import { mergeRouter, mergeWorker } from "./worker-env.js";
12
12
  import { fetchBaseline, hasOwnershipHeader, installedVersion, packageVersion, stampVersion } from "./package-baseline.js";
13
13
  const execFileAsync = promisify(execFile);
@@ -133,6 +133,7 @@ export async function installCatalog(repositoryPath, options = {}) {
133
133
  baselines,
134
134
  upToDate: updates.length === 0 && removals.length === 0,
135
135
  dryRun: options.dryRun ?? false,
136
+ warnings: await verifyScriptWarnings(repositoryPath, updates, existing),
136
137
  };
137
138
  if (options.dryRun)
138
139
  return result;
@@ -158,12 +159,22 @@ export async function installCatalog(repositoryPath, options = {}) {
158
159
  // the empty string as the consumer's deliberate choice. All four consuming repositories were in
159
160
  // that state, so every merge-gate repair pushed a fix with the verification block interpolating
160
161
  // to nothing. An empty value is the absence of a choice, so an update fills it.
162
+ //
163
+ // So is a value the package wrote itself. The implement worker shipped a dotnet-only line, and
164
+ // every full-stack consumer kept it as if someone had chosen it, so the bot never built the web
165
+ // app before opening a pull request. A consumer holding a shipped default gets the current one:
166
+ // its own value is blanked here, which the env merge then reads as no choice.
161
167
  function applyStackDefaults(files, inspection, existing) {
162
168
  const defaults = generateStackDefaults(inspection);
163
169
  const result = new Map(files);
164
170
  for (const [target, content] of result) {
165
- if (isWorker(target) && (!existing.has(target) || hasEmptyVerifyCommands(existing.get(target)))) {
171
+ const current = existing.get(target);
172
+ if (isWorker(target) && (current === undefined || hasEmptyVerifyCommands(current))) {
173
+ result.set(target, injectStackEnv(content, defaults));
174
+ }
175
+ else if (isWorker(target) && hasShippedVerifyCommands(current, defaults.verifyCommands)) {
166
176
  result.set(target, injectStackEnv(content, defaults));
177
+ existing.set(target, current.replace(VERIFY_LINE, ' VERIFY_COMMANDS: ""'));
167
178
  }
168
179
  else if (target.endsWith("opencode-ci.md")) {
169
180
  result.set(target, generateOpencodeCi(content, inspection));
@@ -174,11 +185,42 @@ function applyStackDefaults(files, inspection, existing) {
174
185
  }
175
186
  return result;
176
187
  }
188
+ const VERIFY_LINE = /^ {2}VERIFY_COMMANDS: *(.*?) *$/m;
177
189
  // A worker whose VERIFY_COMMANDS is present but empty. Anything else, including a value the
178
190
  // consumer wrote, is left alone.
179
191
  function hasEmptyVerifyCommands(current) {
180
192
  return typeof current === "string" && /^ {2}VERIFY_COMMANDS: *(""|'')? *$/m.test(current);
181
193
  }
194
+ function verifyCommandsOf(content) {
195
+ const raw = VERIFY_LINE.exec(content)?.[1];
196
+ return raw?.replace(/^(["'])(.*)\1$/, "$2");
197
+ }
198
+ function hasShippedVerifyCommands(current, replacement) {
199
+ if (current === undefined)
200
+ return false;
201
+ const value = verifyCommandsOf(current);
202
+ return value !== undefined && value !== replacement && shippedVerifyDefaults.has(value);
203
+ }
204
+ // `pnpm verify` is only a command if the repository defines it. The update still writes it, since
205
+ // it is the right default, and says what is missing instead of leaving the bot to find out.
206
+ async function verifyScriptWarnings(repositoryPath, updates, existing) {
207
+ const written = new Map(existing);
208
+ for (const update of updates)
209
+ written.set(update.target, update.content);
210
+ const workers = [...written].filter(([target, content]) => isWorker(target) && /\bpnpm verify\b/.test(verifyCommandsOf(content) ?? ""));
211
+ if (workers.length === 0)
212
+ return [];
213
+ let scripts = {};
214
+ try {
215
+ scripts = JSON.parse(await readFile(join(repositoryPath, "package.json"), "utf8")).scripts ?? {};
216
+ }
217
+ catch {
218
+ scripts = {};
219
+ }
220
+ if (typeof scripts.verify === "string")
221
+ return [];
222
+ return [`VERIFY_COMMANDS runs \`pnpm verify\` in ${workers.map(([target]) => target).sort().join(", ")}, but package.json has no "verify" script. Add one that runs what CI blocks on.`];
223
+ }
182
224
  // Directories that belong wholly to the package. A file here that carries our ownership header
183
225
  // and is no longer in the package was deleted upstream, and without this it would sit in every
184
226
  // consumer forever: that is how `stale-recovery` and `update-changelog` outlived the code that
package/dist/index.js CHANGED
@@ -139,6 +139,8 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
139
139
  console.error(`Template conflicts found. Re-run with --force to overwrite consumer-owned copies:\n${allConflicts.join("\n")}`);
140
140
  return 1;
141
141
  }
142
+ for (const warning of catalog?.warnings ?? [])
143
+ console.error(`warning: ${warning}`);
142
144
  console.log(JSON.stringify({
143
145
  command,
144
146
  dryRun,
@@ -177,6 +179,7 @@ function summarize(result) {
177
179
  baselines: result.baselines,
178
180
  changes: result.changes.filter((change) => change.status !== "unchanged"),
179
181
  unchanged: result.changes.filter((change) => change.status === "unchanged").length,
182
+ ...(result.warnings.length > 0 ? { warnings: result.warnings } : {}),
180
183
  };
181
184
  }
182
185
  // A fixed baseline for every recorded version, instead of asking npm. Rolling out a version that
@@ -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) {
@@ -6,6 +6,7 @@ export interface StackDefaults {
6
6
  readonly hasNodeOnly: boolean;
7
7
  }
8
8
  export declare function generateStackDefaults(inspection: RepositoryInspection): StackDefaults;
9
+ export declare const shippedVerifyDefaults: ReadonlySet<string>;
9
10
  export declare function injectStackEnv(content: string, defaults: StackDefaults): string;
10
11
  export declare function generateOpencodeCi(baseContent: string, inspection: RepositoryInspection): string;
11
12
  export declare function generateOpencodeConfig(baseContent: string, inspection: RepositoryInspection): string;
@@ -4,8 +4,12 @@ export function generateStackDefaults(inspection) {
4
4
  const hasNodeOnly = hasNode && !hasDotnet;
5
5
  let verifyCommands;
6
6
  let repoRulesBase;
7
+ // A full-stack repository verifies with its own `pnpm verify`, which runs what its CI blocks on.
8
+ // The command this replaced was prose the agent had to interpret, and the implement worker never
9
+ // got it at all: it kept the package's dotnet-only line, so bot pull requests shipped with the
10
+ // web build broken (Pliny-Bot #639).
7
11
  if (hasDotnet && hasNode) {
8
- verifyCommands = ".NET: dotnet restore && dotnet build -c Release && dotnet test | Web: pnpm lint && pnpm test && pnpm build";
12
+ verifyCommands = "pnpm verify";
9
13
  repoRulesBase = "Full-stack .NET + React/Next.js repository. Follow Clean Architecture layering: API → Application → Domain. Infrastructure implements Application ports. Do not reference EF Core or ASP.NET from Application. Frontend communicates exclusively via HTTP endpoints. Run both .NET and frontend verification.";
10
14
  }
11
15
  else if (hasDotnet) {
@@ -18,6 +22,14 @@ export function generateStackDefaults(inspection) {
18
22
  }
19
23
  return { verifyCommands, repoRulesBase, hasDotnet, hasNodeOnly };
20
24
  }
25
+ // Every VERIFY_COMMANDS value the package itself has ever written. A consumer holding one of these
26
+ // never chose it, so an update may replace it with the current default for the stack.
27
+ export const shippedVerifyDefaults = new Set([
28
+ ".NET: dotnet restore && dotnet build -c Release && dotnet test | Web: pnpm lint && pnpm test && pnpm build",
29
+ "dotnet restore && dotnet build -c Release --no-restore && dotnet test",
30
+ "dotnet restore && dotnet build -c Release --no-restore && dotnet test -c Release --no-build",
31
+ "pnpm verify",
32
+ ]);
21
33
  // Only a worker that prints a verification block declares VERIFY_COMMANDS. Adding the key to
22
34
  // the others made byte-identical workers read as changed on every update, so a worker without
23
35
  // the key is left without it.
@@ -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
  ;;
@@ -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, 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 the closing issue must carry. Empty disables the check.
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
- [ -n "$issue" ] || none "PR #$PR_NUMBER does not close a numbered issue"
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"