@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.
- package/dist/catalog-installation.d.ts +2 -0
- package/dist/catalog-installation.js +44 -2
- package/dist/index.js +3 -0
- package/dist/route-processing.js +9 -2
- package/dist/stack-defaults.d.ts +1 -0
- package/dist/stack-defaults.js +13 -1
- 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/validate-merge-gate-output/action.yml +5 -0
- package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +8 -2
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +131 -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-implement.md +6 -1
- package/loops/workflows/agent-merge-gate.md +102 -56
- package/loops/workflows/work-router.yml +246 -464
- package/package.json +8 -9
|
@@ -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
|
-
|
|
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
|
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/stack-defaults.d.ts
CHANGED
|
@@ -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;
|
package/dist/stack-defaults.js
CHANGED
|
@@ -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 = "
|
|
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.
|
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"
|