@plainconceptsplatform/workflows 1.12.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +18 -5
- package/dist/template-bundles.js +18 -4
- package/dist/workflow-catalog.d.ts +3 -1
- package/dist/workflow-catalog.js +3 -1
- package/loops/actions/file-audit-issues/action.yml +193 -0
- package/loops/actions/file-audit-issues/audit-issues.cjs +245 -0
- package/loops/actions/issue-size.cjs +36 -0
- package/loops/actions/measure-issue-size/action.yml +47 -0
- package/loops/actions/merge-dependabot/action.yml +54 -0
- package/loops/actions/merge-dependabot/merge-dependabot.mjs +343 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +96 -0
- package/loops/docs/diagrams.md +20 -13
- package/loops/templates/ci/app-ci-dotnet-next.yml +4 -0
- package/loops/templates/ci/app-ci-node-monorepo.yml +5 -1
- package/loops/templates/dependabot-merge/.github/workflows/dependabot-merge.yml +78 -0
- package/loops/workflows/agent-audit.md +46 -29
- package/loops/workflows/agent-docs-audit.md +44 -18
- package/loops/workflows/agent-refine.md +17 -30
- package/loops/workflows/work-router.yml +19 -0
- package/package.json +1 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/merge-dependabot/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
name: Merge Dependabot pull requests
|
|
3
|
+
description: >-
|
|
4
|
+
Squash-merge the open Dependabot pull requests whose checks are all green, ask Dependabot to
|
|
5
|
+
rebase the ones that are behind or conflicting, and leave the failing ones with a line in the job
|
|
6
|
+
summary. Only pull requests Dependabot opened and every commit of which Dependabot made are
|
|
7
|
+
touched. API calls only: no pull request code is checked out or run, and no model decides.
|
|
8
|
+
|
|
9
|
+
inputs:
|
|
10
|
+
token:
|
|
11
|
+
description: >-
|
|
12
|
+
Token with contents and pull-requests write. The App token makes the merge start the push
|
|
13
|
+
workflows on the default branch; a merge made with GITHUB_TOKEN starts none.
|
|
14
|
+
required: true
|
|
15
|
+
scope:
|
|
16
|
+
description: "Which updates merge: all, minor (minor and patch), patch, or off."
|
|
17
|
+
required: false
|
|
18
|
+
default: all
|
|
19
|
+
merge-without-checks:
|
|
20
|
+
description: "true merges a pull request with no checks at all, once its head is an hour old. false leaves it."
|
|
21
|
+
required: false
|
|
22
|
+
default: 'true'
|
|
23
|
+
max-merges:
|
|
24
|
+
description: How many pull requests one run merges at most.
|
|
25
|
+
required: false
|
|
26
|
+
default: '5'
|
|
27
|
+
skip-labels:
|
|
28
|
+
description: Space-separated labels that keep a pull request out.
|
|
29
|
+
required: false
|
|
30
|
+
default: do-not-merge blocked human-review hold
|
|
31
|
+
post-merge-event:
|
|
32
|
+
description: repository_dispatch event sent after each merge, the one land-pull-request sends. Empty sends none.
|
|
33
|
+
required: false
|
|
34
|
+
default: agentic-post-merge
|
|
35
|
+
dry-run:
|
|
36
|
+
description: Decide and report, and write nothing.
|
|
37
|
+
required: false
|
|
38
|
+
default: 'false'
|
|
39
|
+
|
|
40
|
+
runs:
|
|
41
|
+
using: composite
|
|
42
|
+
steps:
|
|
43
|
+
- name: Merge Dependabot pull requests
|
|
44
|
+
shell: bash
|
|
45
|
+
env:
|
|
46
|
+
GH_TOKEN: ${{ inputs.token }}
|
|
47
|
+
REPO: ${{ github.repository }}
|
|
48
|
+
SCOPE: ${{ inputs.scope }}
|
|
49
|
+
MERGE_WITHOUT_CHECKS: ${{ inputs.merge-without-checks }}
|
|
50
|
+
MAX_MERGES: ${{ inputs.max-merges }}
|
|
51
|
+
SKIP_LABELS: ${{ inputs.skip-labels }}
|
|
52
|
+
POST_MERGE_EVENT: ${{ inputs.post-merge-event }}
|
|
53
|
+
DRY_RUN: ${{ inputs.dry-run }}
|
|
54
|
+
run: node "${GITHUB_ACTION_PATH}/merge-dependabot.mjs"
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/actions/merge-dependabot/merge-dependabot.mjs. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
//
|
|
3
|
+
// Squash-merges Dependabot pull requests whose checks are green. API calls only: the pull
|
|
4
|
+
// request's code is never checked out or run, and no model decides anything.
|
|
5
|
+
|
|
6
|
+
import { appendFileSync } from "node:fs";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
|
|
9
|
+
export const DEPENDABOT_LOGINS = new Set(["dependabot[bot]", "app/dependabot"]);
|
|
10
|
+
// Dependabot's commits are created through the API: GitHub commits and signs them as web-flow.
|
|
11
|
+
const DEPENDABOT_COMMITTERS = new Set(["web-flow", "dependabot[bot]"]);
|
|
12
|
+
export const SCOPES = ["all", "minor", "patch", "off"];
|
|
13
|
+
export const REBASE_COMMAND = "@dependabot rebase";
|
|
14
|
+
const BUMP_RANK = { patch: 1, minor: 2, major: 3, unknown: 4 };
|
|
15
|
+
const OK_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
|
|
16
|
+
const MAX_EXAMINED = 30;
|
|
17
|
+
const MIN_RATE_REMAINING = 100;
|
|
18
|
+
export const NO_CHECKS_GRACE_MINUTES = 60;
|
|
19
|
+
|
|
20
|
+
export const isDependabot = (login) => DEPENDABOT_LOGINS.has(login ?? "");
|
|
21
|
+
|
|
22
|
+
export function authorshipProblem(pr, commits, repository) {
|
|
23
|
+
if (!isDependabot(pr.user?.login)) return `opened by ${pr.user?.login ?? "unknown"}`;
|
|
24
|
+
if (String(pr.head?.repo?.full_name).toLowerCase() !== repository.toLowerCase()) return "its branch is in another repository";
|
|
25
|
+
if (!String(pr.head?.ref ?? "").startsWith("dependabot/")) return `branch ${pr.head?.ref} is not a Dependabot branch`;
|
|
26
|
+
if (commits.length === 0) return "it has no commits";
|
|
27
|
+
// The commits endpoint stops at 250, so a longer list cannot be proven all Dependabot's.
|
|
28
|
+
if (commits.length >= 250) return "too many commits to verify";
|
|
29
|
+
for (const commit of commits) {
|
|
30
|
+
const sha = String(commit.sha ?? "").slice(0, 7);
|
|
31
|
+
if (!isDependabot(commit.author?.login)) return `commit ${sha} is by ${commit.author?.login ?? "an unknown author"}`;
|
|
32
|
+
if (!DEPENDABOT_COMMITTERS.has(commit.committer?.login ?? "")) return `commit ${sha} was committed by ${commit.committer?.login ?? "an unknown committer"}`;
|
|
33
|
+
if (commit.commit?.verification?.verified !== true) return `commit ${sha} is not verified`;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function labelProblem(pr, skipLabels) {
|
|
39
|
+
const held = (pr.labels ?? []).map((label) => label.name).filter((name) => skipLabels.includes(name));
|
|
40
|
+
return held.length > 0 ? `labelled ${held.join(", ")}` : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const VERSION = "`?(v?[0-9][^\\s`|]*?)`?";
|
|
44
|
+
const TITLE_UPDATE = new RegExp(`\\bbump (\\S+) from ${VERSION} to ${VERSION}(?:\\s+in\\s|\\s*$)`, "i");
|
|
45
|
+
const BODY_UPDATE = new RegExp(`^(?:Bumps|Updates|Updated) \\[?\`?([^\\]\`\\s]+)\`?\\]?(?:\\([^)]*\\))? from ${VERSION} to ${VERSION}\\.?\\s*$`, "i");
|
|
46
|
+
const TABLE_ROW = new RegExp(`^\\|\\s*\\[?([^\\]|]+?)\\]?(?:\\([^)]*\\))?\\s*\\|\\s*${VERSION}\\s*\\|\\s*${VERSION}\\s*\\|\\s*$`);
|
|
47
|
+
|
|
48
|
+
export function parseUpdates(title, body) {
|
|
49
|
+
const updates = [];
|
|
50
|
+
const titleMatch = TITLE_UPDATE.exec(title ?? "");
|
|
51
|
+
if (titleMatch) updates.push({ name: titleMatch[1], from: titleMatch[2], to: titleMatch[3] });
|
|
52
|
+
// Release notes and changelogs sit inside <details>, and they quote versions of their own.
|
|
53
|
+
const text = String(body ?? "").replace(/\r\n/g, "\n").replace(/<details>[\s\S]*?<\/details>/gi, "");
|
|
54
|
+
let inTable = false;
|
|
55
|
+
for (const line of text.split("\n")) {
|
|
56
|
+
const trimmed = line.trim();
|
|
57
|
+
if (/^\|\s*Package\s*\|\s*From\s*\|\s*To\s*\|$/i.test(trimmed)) { inTable = true; continue; }
|
|
58
|
+
if (!trimmed.startsWith("|")) inTable = false;
|
|
59
|
+
const match = BODY_UPDATE.exec(trimmed) ?? (inTable ? TABLE_ROW.exec(trimmed) : null);
|
|
60
|
+
if (match) updates.push({ name: match[1].trim(), from: match[2], to: match[3] });
|
|
61
|
+
}
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
return updates.filter((update) => {
|
|
64
|
+
const key = `${update.name}\0${update.from}\0${update.to}`;
|
|
65
|
+
return seen.has(key) ? false : (seen.add(key), true);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const numeric = (version) => {
|
|
70
|
+
const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(version);
|
|
71
|
+
return match ? [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)] : null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function bumpLevel(from, to) {
|
|
75
|
+
const before = numeric(from);
|
|
76
|
+
const after = numeric(to);
|
|
77
|
+
if (before === null || after === null) return "unknown";
|
|
78
|
+
if (before[0] !== after[0]) return "major";
|
|
79
|
+
if (before[1] !== after[1]) return "minor";
|
|
80
|
+
return "patch";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function highestBump(updates) {
|
|
84
|
+
if (updates.length === 0) return "unknown";
|
|
85
|
+
return updates.map((update) => bumpLevel(update.from, update.to))
|
|
86
|
+
.reduce((highest, level) => (BUMP_RANK[level] > BUMP_RANK[highest] ? level : highest), "patch");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function scopeAllows(scope, bump) {
|
|
90
|
+
if (scope === "all") return true;
|
|
91
|
+
if (scope === "minor") return bump === "patch" || bump === "minor";
|
|
92
|
+
if (scope === "patch") return bump === "patch";
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function summarizeChecks({ checkRuns = [], statuses = [], required = [] }) {
|
|
97
|
+
const latest = new Map();
|
|
98
|
+
for (const run of checkRuns) {
|
|
99
|
+
const known = latest.get(run.name);
|
|
100
|
+
if (known === undefined || run.id > known.id) latest.set(run.name, run);
|
|
101
|
+
}
|
|
102
|
+
const failing = [];
|
|
103
|
+
const pending = [];
|
|
104
|
+
for (const run of latest.values()) {
|
|
105
|
+
if (run.status !== "completed") pending.push(run.name);
|
|
106
|
+
else if (!OK_CONCLUSIONS.has(run.conclusion)) failing.push(`${run.name} (${run.conclusion})`);
|
|
107
|
+
}
|
|
108
|
+
for (const status of statuses) {
|
|
109
|
+
if (status.state === "pending") pending.push(status.context);
|
|
110
|
+
else if (status.state !== "success") failing.push(`${status.context} (${status.state})`);
|
|
111
|
+
}
|
|
112
|
+
const reported = new Set([...latest.keys(), ...statuses.map((status) => status.context)]);
|
|
113
|
+
for (const context of required) {
|
|
114
|
+
if (!reported.has(context)) pending.push(`${context} (required, not reported)`);
|
|
115
|
+
}
|
|
116
|
+
const state = failing.length > 0 ? "failure" : pending.length > 0 ? "pending" : reported.size === 0 ? "none" : "success";
|
|
117
|
+
return { state, failing, pending };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function rebaseAlreadyRequested(comments, headCommittedAt) {
|
|
121
|
+
const since = Date.parse(headCommittedAt ?? "");
|
|
122
|
+
return comments.some((comment) =>
|
|
123
|
+
String(comment.body ?? "").trim().toLowerCase() === REBASE_COMMAND &&
|
|
124
|
+
(Number.isNaN(since) || Date.parse(comment.created_at) >= since));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// One pull request, everything already fetched. The decision is pure so it can be tested.
|
|
128
|
+
export function decide({ pr, commits, defaultBranch, repository, skipLabels, scope, checks, comments, mergeWithoutChecks, now }) {
|
|
129
|
+
const bump = highestBump(parseUpdates(pr.title, pr.body));
|
|
130
|
+
const skip = (reason) => ({ action: "skip", reason, bump });
|
|
131
|
+
if (pr.draft) return skip("draft");
|
|
132
|
+
if (pr.base?.ref !== defaultBranch) return skip(`targets ${pr.base?.ref}, not ${defaultBranch}`);
|
|
133
|
+
const labelled = labelProblem(pr, skipLabels);
|
|
134
|
+
if (labelled) return skip(labelled);
|
|
135
|
+
const authorship = authorshipProblem(pr, commits, repository);
|
|
136
|
+
if (authorship) return skip(authorship);
|
|
137
|
+
if (!scopeAllows(scope, bump)) return skip(`${bump} update, DEPENDABOT_MERGE is ${scope}`);
|
|
138
|
+
|
|
139
|
+
const headCommittedAt = commits.at(-1)?.commit?.committer?.date;
|
|
140
|
+
if (pr.mergeable === false || pr.mergeable_state === "dirty" || pr.mergeable_state === "behind") {
|
|
141
|
+
const why = pr.mergeable_state === "behind" ? "behind its base" : "conflicting";
|
|
142
|
+
if (rebaseAlreadyRequested(comments, headCommittedAt)) return { action: "wait", reason: `${why}; rebase already requested`, bump };
|
|
143
|
+
return { action: "rebase", reason: why, bump };
|
|
144
|
+
}
|
|
145
|
+
if (pr.mergeable !== true) return { action: "wait", reason: "GitHub has not computed mergeability yet", bump };
|
|
146
|
+
|
|
147
|
+
if (checks.state === "failure") return { action: "fail", reason: `failing: ${checks.failing.join(", ")}`, bump };
|
|
148
|
+
if (checks.state === "pending") return { action: "wait", reason: `pending: ${checks.pending.join(", ")}`, bump };
|
|
149
|
+
if (checks.state === "none") {
|
|
150
|
+
if (!mergeWithoutChecks) return skip("no checks, DEPENDABOT_MERGE_WITHOUT_CHECKS is false");
|
|
151
|
+
// CI that has not registered yet looks like no CI at all.
|
|
152
|
+
const ageMinutes = (now - Date.parse(headCommittedAt ?? pr.updated_at)) / 60000;
|
|
153
|
+
if (!(ageMinutes >= NO_CHECKS_GRACE_MINUTES)) return { action: "wait", reason: `no checks yet; merging once the head is ${NO_CHECKS_GRACE_MINUTES} minutes old`, bump };
|
|
154
|
+
return { action: "merge", reason: "no checks configured", bump };
|
|
155
|
+
}
|
|
156
|
+
return { action: "merge", reason: "checks green", bump };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function readConfig(env) {
|
|
160
|
+
const scope = String(env.SCOPE || "all").trim().toLowerCase();
|
|
161
|
+
if (!SCOPES.includes(scope)) throw new Error(`DEPENDABOT_MERGE must be one of ${SCOPES.join(", ")}, not "${env.SCOPE}"`);
|
|
162
|
+
const maxMerges = Number(env.MAX_MERGES || "5");
|
|
163
|
+
if (!Number.isInteger(maxMerges) || maxMerges < 0) throw new Error(`max-merges must be a whole number, not "${env.MAX_MERGES}"`);
|
|
164
|
+
return {
|
|
165
|
+
scope,
|
|
166
|
+
maxMerges,
|
|
167
|
+
mergeWithoutChecks: String(env.MERGE_WITHOUT_CHECKS || "true").trim().toLowerCase() !== "false",
|
|
168
|
+
skipLabels: String(env.SKIP_LABELS ?? "").split(/[\s,]+/).filter(Boolean),
|
|
169
|
+
dryRun: String(env.DRY_RUN || "false").trim().toLowerCase() === "true",
|
|
170
|
+
postMergeEvent: String(env.POST_MERGE_EVENT ?? "").trim(),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function createClient({ token, apiUrl = "https://api.github.com", fetchImpl = fetch }) {
|
|
175
|
+
const state = { remaining: Infinity };
|
|
176
|
+
const request = async (method, path, body) => {
|
|
177
|
+
const response = await fetchImpl(path.startsWith("http") ? path : `${apiUrl}${path}`, {
|
|
178
|
+
method,
|
|
179
|
+
headers: {
|
|
180
|
+
authorization: `Bearer ${token}`,
|
|
181
|
+
accept: "application/vnd.github+json",
|
|
182
|
+
"x-github-api-version": "2022-11-28",
|
|
183
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
184
|
+
},
|
|
185
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
186
|
+
});
|
|
187
|
+
const remaining = Number(response.headers.get("x-ratelimit-remaining"));
|
|
188
|
+
if (!Number.isNaN(remaining)) state.remaining = remaining;
|
|
189
|
+
const text = await response.text();
|
|
190
|
+
let data = null;
|
|
191
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
192
|
+
return { status: response.status, data, link: response.headers.get("link") ?? "" };
|
|
193
|
+
};
|
|
194
|
+
const paginate = async (path, limit = 300) => {
|
|
195
|
+
const items = [];
|
|
196
|
+
let next = path;
|
|
197
|
+
while (next && items.length < limit) {
|
|
198
|
+
const page = await request("GET", next);
|
|
199
|
+
if (page.status >= 400) throw new Error(`GET ${next} answered ${page.status}: ${JSON.stringify(page.data)}`);
|
|
200
|
+
items.push(...(Array.isArray(page.data) ? page.data : page.data?.check_runs ?? []));
|
|
201
|
+
next = /<([^>]+)>;\s*rel="next"/.exec(page.link)?.[1];
|
|
202
|
+
}
|
|
203
|
+
return items;
|
|
204
|
+
};
|
|
205
|
+
return { request, paginate, rateRemaining: () => state.remaining };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
209
|
+
|
|
210
|
+
async function readPullRequest(client, repository, number, wait) {
|
|
211
|
+
for (let attempt = 0; ; attempt++) {
|
|
212
|
+
const { data } = await client.request("GET", `/repos/${repository}/pulls/${number}`);
|
|
213
|
+
if (data?.mergeable !== null || attempt >= 3) return data;
|
|
214
|
+
await wait(2000);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function requiredChecks(client, repository, branch) {
|
|
219
|
+
const contexts = new Set();
|
|
220
|
+
const rules = await client.request("GET", `/repos/${repository}/rules/branches/${encodeURIComponent(branch)}`);
|
|
221
|
+
for (const rule of Array.isArray(rules.data) ? rules.data : []) {
|
|
222
|
+
if (rule.type !== "required_status_checks") continue;
|
|
223
|
+
for (const check of rule.parameters?.required_status_checks ?? []) contexts.add(check.context);
|
|
224
|
+
}
|
|
225
|
+
const protection = await client.request("GET", `/repos/${repository}/branches/${encodeURIComponent(branch)}`);
|
|
226
|
+
for (const context of protection.data?.protection?.required_status_checks?.contexts ?? []) contexts.add(context);
|
|
227
|
+
return [...contexts];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function readChecks(client, repository, sha, required) {
|
|
231
|
+
const checkRuns = await client.paginate(`/repos/${repository}/commits/${sha}/check-runs?per_page=100`);
|
|
232
|
+
const combined = await client.request("GET", `/repos/${repository}/commits/${sha}/status?per_page=100`);
|
|
233
|
+
return summarizeChecks({ checkRuns, statuses: combined.data?.statuses ?? [], required });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function land(client, repository, pr, config) {
|
|
237
|
+
if (pr.mergeable_state === "blocked") {
|
|
238
|
+
// A ruleset may require an approval; one from this token meets it.
|
|
239
|
+
await client.request("POST", `/repos/${repository}/pulls/${pr.number}/reviews`, {
|
|
240
|
+
commit_id: pr.head.sha, event: "APPROVE", body: "Dependabot update with every check green.",
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
const merged = await client.request("PUT", `/repos/${repository}/pulls/${pr.number}/merge`, {
|
|
244
|
+
merge_method: "squash", sha: pr.head.sha,
|
|
245
|
+
});
|
|
246
|
+
if (merged.status === 200) {
|
|
247
|
+
await client.request("DELETE", `/repos/${repository}/git/refs/heads/${pr.head.ref}`);
|
|
248
|
+
if (config.postMergeEvent) {
|
|
249
|
+
// The same payload land-pull-request sends, so a consumer deploying on it needs nothing new.
|
|
250
|
+
await client.request("POST", `/repos/${repository}/dispatches`, {
|
|
251
|
+
event_type: config.postMergeEvent, client_payload: { sha: pr.head.sha, pr: String(pr.number), base: pr.base.ref },
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return { action: "merged", reason: `squash-merged as ${String(merged.data?.sha ?? "").slice(0, 7)}` };
|
|
255
|
+
}
|
|
256
|
+
const message = String(merged.data?.message ?? JSON.stringify(merged.data)).slice(0, 200);
|
|
257
|
+
if (merged.status === 405 && /merge queue/i.test(message)) {
|
|
258
|
+
const enqueued = await client.request("POST", "/graphql", {
|
|
259
|
+
query: "mutation($id:ID!,$sha:GitObjectID!){enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$sha}){mergeQueueEntry{state}}}",
|
|
260
|
+
variables: { id: pr.node_id, sha: pr.head.sha },
|
|
261
|
+
});
|
|
262
|
+
if (enqueued.data?.data?.enqueuePullRequest?.mergeQueueEntry) return { action: "enqueued", reason: "added to the merge queue" };
|
|
263
|
+
return { action: "blocked", reason: `the merge queue refused it: ${JSON.stringify(enqueued.data?.errors ?? enqueued.data).slice(0, 200)}` };
|
|
264
|
+
}
|
|
265
|
+
if (merged.status === 409) return { action: "wait", reason: "the head moved while it was judged; next run" };
|
|
266
|
+
if ([403, 405, 422].includes(merged.status)) return { action: "blocked", reason: `GitHub refused the merge: ${message}` };
|
|
267
|
+
return { action: "error", reason: `merge answered ${merged.status}: ${message}` };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function run({ client, repository, config, now = Date.now(), log = console.log, wait = sleep }) {
|
|
271
|
+
const rows = [];
|
|
272
|
+
if (config.scope === "off") return { rows, off: true };
|
|
273
|
+
|
|
274
|
+
const { data: repo } = await client.request("GET", `/repos/${repository}`);
|
|
275
|
+
const defaultBranch = repo.default_branch;
|
|
276
|
+
const open = await client.paginate(`/repos/${repository}/pulls?state=open&sort=created&direction=asc&per_page=100`);
|
|
277
|
+
const candidates = open.filter((pr) => isDependabot(pr.user?.login)).slice(0, MAX_EXAMINED);
|
|
278
|
+
const required = candidates.length > 0 ? await requiredChecks(client, repository, defaultBranch) : [];
|
|
279
|
+
let merges = 0;
|
|
280
|
+
|
|
281
|
+
for (const listed of candidates) {
|
|
282
|
+
if (client.rateRemaining() < MIN_RATE_REMAINING) {
|
|
283
|
+
rows.push({ number: listed.number, title: listed.title, bump: "", action: "wait", reason: "API rate limit nearly spent" });
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const pr = await readPullRequest(client, repository, listed.number, wait);
|
|
287
|
+
const commits = await client.paginate(`/repos/${repository}/pulls/${pr.number}/commits?per_page=100`, 250);
|
|
288
|
+
const comments = await client.paginate(`/repos/${repository}/issues/${pr.number}/comments?per_page=100`);
|
|
289
|
+
const mergeableNow = pr.mergeable === true && !["dirty", "behind"].includes(pr.mergeable_state);
|
|
290
|
+
const checks = mergeableNow ? await readChecks(client, repository, pr.head.sha, required) : { state: "none", failing: [], pending: [] };
|
|
291
|
+
let outcome = decide({
|
|
292
|
+
pr, commits, defaultBranch, repository, comments, checks, now,
|
|
293
|
+
skipLabels: config.skipLabels, scope: config.scope, mergeWithoutChecks: config.mergeWithoutChecks,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
if (outcome.action === "merge" && merges >= config.maxMerges) {
|
|
297
|
+
outcome = { ...outcome, action: "wait", reason: `${config.maxMerges} merged this run; next run` };
|
|
298
|
+
} else if (outcome.action === "merge") {
|
|
299
|
+
merges++;
|
|
300
|
+
outcome = config.dryRun ? { ...outcome, action: "would merge" } : { ...outcome, ...await land(client, repository, pr, config) };
|
|
301
|
+
} else if (outcome.action === "rebase") {
|
|
302
|
+
if (!config.dryRun) await client.request("POST", `/repos/${repository}/issues/${pr.number}/comments`, { body: REBASE_COMMAND });
|
|
303
|
+
outcome = { ...outcome, action: config.dryRun ? "would ask rebase" : "asked rebase" };
|
|
304
|
+
}
|
|
305
|
+
log(`#${pr.number} ${outcome.action}: ${outcome.reason}`);
|
|
306
|
+
rows.push({ number: pr.number, title: pr.title, bump: outcome.bump, action: outcome.action, reason: outcome.reason });
|
|
307
|
+
}
|
|
308
|
+
return { rows, off: false };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function renderSummary({ rows, off }, config) {
|
|
312
|
+
const lines = [`## Dependabot merge (${config.dryRun ? "dry run, " : ""}scope: ${config.scope})`, ""];
|
|
313
|
+
if (off) return [...lines, "DEPENDABOT_MERGE is off: nothing was examined.", ""].join("\n");
|
|
314
|
+
if (rows.length === 0) return [...lines, "No open Dependabot pull requests.", ""].join("\n");
|
|
315
|
+
lines.push("| PR | Update | Bump | Result | Why |", "|---|---|---|---|---|");
|
|
316
|
+
const cell = (value) => String(value ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
317
|
+
for (const row of rows) lines.push(`| #${row.number} | ${cell(row.title)} | ${cell(row.bump)} | ${cell(row.action)} | ${cell(row.reason)} |`);
|
|
318
|
+
return [...lines, ""].join("\n");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function main() {
|
|
322
|
+
const config = readConfig({
|
|
323
|
+
SCOPE: process.env.SCOPE, MAX_MERGES: process.env.MAX_MERGES, MERGE_WITHOUT_CHECKS: process.env.MERGE_WITHOUT_CHECKS,
|
|
324
|
+
SKIP_LABELS: process.env.SKIP_LABELS, DRY_RUN: process.env.DRY_RUN, POST_MERGE_EVENT: process.env.POST_MERGE_EVENT,
|
|
325
|
+
});
|
|
326
|
+
const client = createClient({ token: process.env.GH_TOKEN, apiUrl: process.env.GITHUB_API_URL || "https://api.github.com" });
|
|
327
|
+
const result = await run({ client, repository: process.env.REPO, config });
|
|
328
|
+
const summary = renderSummary(result, config);
|
|
329
|
+
if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`);
|
|
330
|
+
else console.log(summary);
|
|
331
|
+
const stuck = result.rows.filter((row) => row.action === "fail" || row.action === "blocked").length;
|
|
332
|
+
if (stuck > 0) console.log(`::notice::${stuck} Dependabot pull request(s) left open with failing checks or a refused merge; see the job summary.`);
|
|
333
|
+
const errors = result.rows.filter((row) => row.action === "error");
|
|
334
|
+
for (const row of errors) console.log(`::error::#${row.number}: ${row.reason}`);
|
|
335
|
+
if (errors.length > 0) process.exitCode = 1;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
339
|
+
main().catch((error) => {
|
|
340
|
+
console.log(`::error::${error.message}`);
|
|
341
|
+
process.exitCode = 1;
|
|
342
|
+
});
|
|
343
|
+
}
|
|
@@ -1293,6 +1293,45 @@ if [ -f "$HOUSEKEEPING_YML" ]; then
|
|
|
1293
1293
|
if [ "$HK_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
1294
1294
|
fi
|
|
1295
1295
|
|
|
1296
|
+
# Dependabot merges ride the maintenance run. They merge code nobody on the team wrote, so the
|
|
1297
|
+
# guarantees are asserted: the App token (a GITHUB_TOKEN merge starts no push workflow), the knobs
|
|
1298
|
+
# in the env: block, no checkout or process of the pull request's code, and only verified commits.
|
|
1299
|
+
DEPENDABOT_DIR="${HERE}/../merge-dependabot"
|
|
1300
|
+
if [ -f "${DEPENDABOT_DIR}/action.yml" ] && grep -q '^ housekeeping:$' "$ROUTER_YML"; then
|
|
1301
|
+
DB_OK=1
|
|
1302
|
+
hk_job=$(sed -n '/^ housekeeping:$/,/^ [a-z0-9_-]*:$/p' "$ROUTER_YML")
|
|
1303
|
+
db_step=$(printf '%s\n' "$hk_job" | sed -n '/uses: \.\/\.github\/actions\/merge-dependabot/,/^ - /p')
|
|
1304
|
+
printf '%s' "$db_step" | grep -q 'token: ${{ steps.app-token.outputs.token }}' ||
|
|
1305
|
+
{ DB_OK=0; echo "FAIL: the maintenance run does not merge Dependabot pull requests with the App token" >&2; }
|
|
1306
|
+
for knob in DEPENDABOT_MERGE DEPENDABOT_MERGE_WITHOUT_CHECKS; do
|
|
1307
|
+
if [ -z "$(router_env "$knob")" ] || ! printf '%s' "$db_step" | grep -q "env.${knob}"; then
|
|
1308
|
+
DB_OK=0
|
|
1309
|
+
echo "FAIL: ${knob} is not both declared in the router env: block and read by the Dependabot merge step" >&2
|
|
1310
|
+
fi
|
|
1311
|
+
done
|
|
1312
|
+
if grep -q 'actions/checkout' "${DEPENDABOT_DIR}/action.yml" ||
|
|
1313
|
+
grep -qE 'node:child_process|execFile|spawn\(' "${DEPENDABOT_DIR}/merge-dependabot.mjs"; then
|
|
1314
|
+
DB_OK=0
|
|
1315
|
+
echo "FAIL: merge-dependabot checks out or runs something; it must work through the API alone" >&2
|
|
1316
|
+
fi
|
|
1317
|
+
grep -qF 'verification?.verified !== true' "${DEPENDABOT_DIR}/merge-dependabot.mjs" ||
|
|
1318
|
+
{ DB_OK=0; echo "FAIL: merge-dependabot no longer requires every commit to be verified" >&2; }
|
|
1319
|
+
grep -qF 'merge_method: "squash", sha: pr.head.sha' "${DEPENDABOT_DIR}/merge-dependabot.mjs" ||
|
|
1320
|
+
{ DB_OK=0; echo "FAIL: merge-dependabot must squash-merge the head it judged" >&2; }
|
|
1321
|
+
if [ "$DB_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
1322
|
+
fi
|
|
1323
|
+
# Dependabot's runs get a read-only token, so a CI template's merge-train dispatch fails its pull
|
|
1324
|
+
# request and holds the update forever.
|
|
1325
|
+
for template in "${HERE}/../../templates/ci/"*.yml; do
|
|
1326
|
+
[ -f "$template" ] || continue
|
|
1327
|
+
if ! grep -q 'operation=merge-train' "$template" || grep -q "pull_request.user.login != 'dependabot\[bot\]'" "$template"; then
|
|
1328
|
+
PASS=$((PASS + 1))
|
|
1329
|
+
else
|
|
1330
|
+
FAIL=$((FAIL + 1))
|
|
1331
|
+
echo "FAIL: $(basename "$template") dispatches the merge train from Dependabot's read-only runs" >&2
|
|
1332
|
+
fi
|
|
1333
|
+
done
|
|
1334
|
+
|
|
1296
1335
|
# The audit chain closes its own reports, and every way it can be wrong is silent: a report
|
|
1297
1336
|
# closed as completed with nothing implemented, or a report pinned open forever so the next
|
|
1298
1337
|
# audit never runs. Both happened. Assert the three conditions that decide it.
|
|
@@ -2584,6 +2623,63 @@ if worker_installed audit; then
|
|
|
2584
2623
|
if [ "$AUDIT_EMPTY_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
2585
2624
|
fi
|
|
2586
2625
|
|
|
2626
|
+
# Every audit from 2026-09-21 until 1.12.1 filed one issue over refine's size gate, labelled `refine`,
|
|
2627
|
+
# and was refused. The audit now files a report refine never sees and stories under the gate.
|
|
2628
|
+
if worker_installed audit; then
|
|
2629
|
+
AUDIT_FILE_OK=1
|
|
2630
|
+
AUDIT_WORKER_MD="${WORKFLOWS_DIR}/agent-audit.md"
|
|
2631
|
+
REFINE_WORKER_MD="${WORKFLOWS_DIR}/agent-refine.md"
|
|
2632
|
+
af() { AUDIT_FILE_OK=0; echo "FAIL: the audit $1" >&2; }
|
|
2633
|
+
file_block=$(awk '/uses: \.\/\.github\/actions\/file-audit-issues/{found=1} found{print} found && /^ [a-z_]+:$/{exit}' "$AUDIT_WORKER_MD")
|
|
2634
|
+
[ -n "$file_block" ] || af "does not file through file-audit-issues, so nothing measures what it files"
|
|
2635
|
+
labels_of() { printf '%s\n' "$file_block" | awk -v key="$1:" '$1 == key {f=1; next} /^ *$|^ *#|^ *[a-z_-]+:/{f=0} f' | tr -d ' '; }
|
|
2636
|
+
report_labels=$(labels_of report-labels)
|
|
2637
|
+
story_labels=$(labels_of story-labels)
|
|
2638
|
+
printf '%s\n' "$report_labels" | grep -qx audit || af "report is not labelled audit, so backpressure and audit-close cannot see it"
|
|
2639
|
+
printf '%s\n' "$report_labels" | grep -qx refine && af "report is labelled refine; refine refuses a report of every finding"
|
|
2640
|
+
printf '%s\n' "$story_labels" | grep -qx refine || af "stories are not labelled refine, so nothing refines them"
|
|
2641
|
+
printf '%s\n' "$story_labels" | grep -qx audit && af "stories are labelled audit; three of them fill the backpressure and audit-close treats each as a report"
|
|
2642
|
+
grep -qE '^ staged: true' "$AUDIT_WORKER_MD" || af "is not staged, so gh-aw files the issues before they are measured"
|
|
2643
|
+
audit_env() { sed -n "s/^ $1: \"\([0-9]*\)\"$/\1/p" "$AUDIT_WORKER_MD" | head -1; }
|
|
2644
|
+
if [ -f "$REFINE_WORKER_MD" ]; then
|
|
2645
|
+
refine_chars=$(sed -n 's/^ REFINE_MAX_BODY_CHARS: "\([0-9]*\)"$/\1/p' "$REFINE_WORKER_MD" | head -1)
|
|
2646
|
+
refine_units=$(sed -n 's/^ REFINE_MAX_WORK_UNITS: "\([0-9]*\)"$/\1/p' "$REFINE_WORKER_MD" | head -1)
|
|
2647
|
+
[ "$(audit_env AUDIT_MAX_BODY_CHARS)" -lt "${refine_chars:-0}" ] 2>/dev/null ||
|
|
2648
|
+
af "AUDIT_MAX_BODY_CHARS is not below refine's REFINE_MAX_BODY_CHARS (${refine_chars:-unset})"
|
|
2649
|
+
[ "$(audit_env AUDIT_MAX_WORK_UNITS)" -lt "${refine_units:-0}" ] 2>/dev/null ||
|
|
2650
|
+
af "AUDIT_MAX_WORK_UNITS is not below refine's REFINE_MAX_WORK_UNITS (${refine_units:-unset})"
|
|
2651
|
+
grep -qF 'uses: ./.github/actions/measure-issue-size' "$REFINE_WORKER_MD" ||
|
|
2652
|
+
af "and refine measure with different rules; refine's size_guard must use measure-issue-size"
|
|
2653
|
+
fi
|
|
2654
|
+
if [ "$AUDIT_FILE_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
2655
|
+
fi
|
|
2656
|
+
|
|
2657
|
+
# The docs audit filed the same single refine-labelled report and hit the same gate; it now files
|
|
2658
|
+
# like the audit: a report refine never sees and stories under the gate, measured before filing.
|
|
2659
|
+
if worker_installed docs-audit; then
|
|
2660
|
+
DOCS_FILE_OK=1
|
|
2661
|
+
DOCS_WORKER_MD="${WORKFLOWS_DIR}/agent-docs-audit.md"
|
|
2662
|
+
dfa() { DOCS_FILE_OK=0; echo "FAIL: the docs audit $1" >&2; }
|
|
2663
|
+
docs_block=$(awk '/uses: \.\/\.github\/actions\/file-audit-issues/{found=1} found{print} found && /^ [a-z_]+:$/{exit}' "$DOCS_WORKER_MD")
|
|
2664
|
+
[ -n "$docs_block" ] || dfa "does not file through file-audit-issues, so nothing measures what it files"
|
|
2665
|
+
docs_labels_of() { printf '%s\n' "$docs_block" | awk -v key="$1:" '$1 == key {f=1; next} /^ *$|^ *#|^ *[a-z_-]+:/{f=0} f' | tr -d ' '; }
|
|
2666
|
+
docs_report_labels=$(docs_labels_of report-labels)
|
|
2667
|
+
docs_story_labels=$(docs_labels_of story-labels)
|
|
2668
|
+
printf '%s\n' "$docs_report_labels" | grep -qx docs-audit || dfa "report is not labelled docs-audit, so backpressure and audit-close cannot see it"
|
|
2669
|
+
printf '%s\n' "$docs_report_labels" | grep -qx refine && dfa "report is labelled refine; refine refuses a report of every finding"
|
|
2670
|
+
printf '%s\n' "$docs_story_labels" | grep -qx refine || dfa "stories are not labelled refine, so nothing refines them"
|
|
2671
|
+
printf '%s\n' "$docs_story_labels" | grep -qx docs-audit && dfa "stories are labelled docs-audit; one of them fills the backpressure"
|
|
2672
|
+
grep -qE '^ staged: true' "$DOCS_WORKER_MD" || dfa "is not staged, so gh-aw files the issues before they are measured"
|
|
2673
|
+
grep -q 'add-issue-labels' "$DOCS_WORKER_MD" && dfa "still labels a report gh-aw filed itself"
|
|
2674
|
+
if [ -f "${WORKFLOWS_DIR}/agent-refine.md" ]; then
|
|
2675
|
+
docs_chars=$(sed -n 's/^ DOCS_AUDIT_MAX_BODY_CHARS: "\([0-9]*\)"$/\1/p' "$DOCS_WORKER_MD" | head -1)
|
|
2676
|
+
refine_chars=$(sed -n 's/^ REFINE_MAX_BODY_CHARS: "\([0-9]*\)"$/\1/p' "${WORKFLOWS_DIR}/agent-refine.md" | head -1)
|
|
2677
|
+
[ "${docs_chars:-99999999}" -lt "${refine_chars:-0}" ] 2>/dev/null ||
|
|
2678
|
+
dfa "DOCS_AUDIT_MAX_BODY_CHARS is not below refine's REFINE_MAX_BODY_CHARS (${refine_chars:-unset})"
|
|
2679
|
+
fi
|
|
2680
|
+
if [ "$DOCS_FILE_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
2681
|
+
fi
|
|
2682
|
+
|
|
2587
2683
|
echo "── Retrying a run that died early ────────────────────────────────────────"
|
|
2588
2684
|
|
|
2589
2685
|
# A run that died before producing anything is worth repeating; one that worked and then failed
|
package/loops/docs/diagrams.md
CHANGED
|
@@ -100,7 +100,7 @@ flowchart LR
|
|
|
100
100
|
gate -.->|"CI failed or conflict"| fix("Fix, push, CI again") --> ci
|
|
101
101
|
gate -.->|".github, secrets<br/>or 10000+ lines"| human(("Human review"))
|
|
102
102
|
review("Someone reviews the PR") --> applyReview("apply-review") --> ci
|
|
103
|
-
audit("audit, weekly") -->|"files
|
|
103
|
+
audit("audit, weekly") -->|"files stories"| refine
|
|
104
104
|
|
|
105
105
|
classDef action fill:#eef0ff,stroke:#554cff,stroke-width:2px,color:#172033
|
|
106
106
|
classDef decision fill:#fff8e8,stroke:#c75b00,stroke-width:2px,color:#172033
|
|
@@ -114,9 +114,7 @@ flowchart LR
|
|
|
114
114
|
class merged success
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
Audit creates work rather than consuming it, and files into `refine` rather than straight to
|
|
118
|
-
`implement`, so a report of several unrelated findings becomes one properly sized issue per finding
|
|
119
|
-
instead of one pull request that has to fix them all.
|
|
117
|
+
Audit creates work rather than consuming it, and files into `refine` rather than straight to `implement`: one story issue per top finding, each under refine's size limit, plus a tracking report refine never sees, instead of one pull request that has to fix them all.
|
|
120
118
|
|
|
121
119
|
### The merge train
|
|
122
120
|
|
|
@@ -489,7 +487,7 @@ before anything changes. Applying them one at a time produces contradictory comm
|
|
|
489
487
|
|
|
490
488
|
## agent-audit.md
|
|
491
489
|
|
|
492
|
-
Creates work rather than consuming it. Read-only: it files
|
|
490
|
+
Creates work rather than consuming it. Read-only: it files a tracking report and one story issue per top finding, and changes nothing else.
|
|
493
491
|
|
|
494
492
|
```mermaid
|
|
495
493
|
flowchart TD
|
|
@@ -506,8 +504,9 @@ flowchart TD
|
|
|
506
504
|
auDedupe{"Already tracked, or<br/>previously rejected?"}
|
|
507
505
|
auDedupe -.->|"all of them"| auQuiet
|
|
508
506
|
auDedupe -->|"some are new"| auFile
|
|
509
|
-
auFile("
|
|
510
|
-
|
|
507
|
+
auFile("Report of every finding,<br/>plus one story per top 3") --> auMeasure
|
|
508
|
+
auMeasure("conclude (rung 6)<br/>each body measured with<br/>refine's rule, trimmed to fit") --> auReport
|
|
509
|
+
auReport(("Filed<br/>report: audit, not refine<br/>stories: refine, sub-issues"))
|
|
511
510
|
auQuiet(("Nothing to file<br/>the right outcome on a<br/>clean codebase"))
|
|
512
511
|
auIdle(("Idle<br/>backlog already full"))
|
|
513
512
|
|
|
@@ -517,12 +516,14 @@ flowchart TD
|
|
|
517
516
|
classDef idle fill:#202c40,stroke:#738198,stroke-width:2px,color:#ffffff
|
|
518
517
|
classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
|
|
519
518
|
class auStart start
|
|
520
|
-
class auFacts,auRun,auScore,auFile action
|
|
519
|
+
class auFacts,auRun,auScore,auFile,auMeasure action
|
|
521
520
|
class auBack,auFilter,auDedupe decision
|
|
522
521
|
class auIdle idle
|
|
523
522
|
class auReport,auQuiet success
|
|
524
523
|
```
|
|
525
524
|
|
|
525
|
+
The report never goes to refine: refine's size gate refused every audit filed as one issue of every finding and three stories, from 2026-09-21 until 1.12.1. Each story is filed on its own, labelled `refine`, linked under the report and listed in it, so `audit-close` closes the report once its stories are closed. A story already tracked by an open issue is listed against that issue instead of filed again.
|
|
526
|
+
|
|
526
527
|
Memory matters here: open issues only cover what is still open, so a finding reported weeks ago and
|
|
527
528
|
consciously not acted on would come back every single run. The audit records its dispositions and
|
|
528
529
|
reads them next time.
|
|
@@ -549,8 +550,9 @@ flowchart TD
|
|
|
549
550
|
daDedupe{"New finding, proven<br/>on both sides?"}
|
|
550
551
|
daDedupe -.->|"none left"| daQuiet
|
|
551
552
|
daDedupe -->|"some"| daFile
|
|
552
|
-
daFile("
|
|
553
|
-
|
|
553
|
+
daFile("Report of every finding,<br/>plus one story per top 3") --> daMeasure
|
|
554
|
+
daMeasure("conclude (rung 6)<br/>each body measured with<br/>refine's rule, trimmed to fit") --> daReport
|
|
555
|
+
daReport(("Filed<br/>report: docs-audit, not refine<br/>stories: refine, sub-issues"))
|
|
554
556
|
daQuiet(("Nothing to file<br/>the documentation is current"))
|
|
555
557
|
daIdle(("Idle<br/>no agent, no spend"))
|
|
556
558
|
|
|
@@ -560,7 +562,7 @@ flowchart TD
|
|
|
560
562
|
classDef idle fill:#202c40,stroke:#738198,stroke-width:2px,color:#ffffff
|
|
561
563
|
classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
|
|
562
564
|
class daStart start
|
|
563
|
-
class daFacts,daRun,daFile action
|
|
565
|
+
class daFacts,daRun,daFile,daMeasure action
|
|
564
566
|
class daBack,daMoved,daDedupe decision
|
|
565
567
|
class daIdle idle
|
|
566
568
|
class daReport,daQuiet success
|
|
@@ -569,6 +571,8 @@ flowchart TD
|
|
|
569
571
|
Reports join the audit chain: `audit-close` closes one when its children are closed, and marks it
|
|
570
572
|
`stale-audit` when nobody acted on it, which frees the slot.
|
|
571
573
|
|
|
574
|
+
It files the way the audit does, through the same `file-audit-issues` step: the report never goes to refine, which refused a single report of every finding once it grew past its size gate. Each top story is filed on its own, labelled `refine`, linked under the report and listed in it.
|
|
575
|
+
|
|
572
576
|
---
|
|
573
577
|
|
|
574
578
|
## agent-release.md
|
|
@@ -652,7 +656,8 @@ flowchart TB
|
|
|
652
656
|
hkBranch -->|yes| hkDelete("Delete the branch")
|
|
653
657
|
hkBranch -.->|"open PR, human PR,<br/>or the default branch"| hkDigest
|
|
654
658
|
hkDelete --> hkDigest
|
|
655
|
-
hkDigest("Rewrite one issue:<br/>Needs a human") -->
|
|
659
|
+
hkDigest("Rewrite one issue:<br/>Needs a human") --> hkDependabot
|
|
660
|
+
hkDependabot("Squash-merge Dependabot PRs<br/>whose checks are all green") --> hkEnd
|
|
656
661
|
hkEnd(("One place to look<br/>instead of four repos"))
|
|
657
662
|
|
|
658
663
|
classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
|
|
@@ -660,7 +665,7 @@ flowchart TB
|
|
|
660
665
|
classDef decision fill:#fff8e8,stroke:#c75b00,stroke-width:2px,color:#172033
|
|
661
666
|
classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
|
|
662
667
|
class hkCron start
|
|
663
|
-
class hkScan,hkRetry,hkStrand,hkParent,hkDelete,hkDigest,hkStackClose action
|
|
668
|
+
class hkScan,hkRetry,hkStrand,hkParent,hkDelete,hkDigest,hkStackClose,hkDependabot action
|
|
664
669
|
class hkPark,hkWhy,hkBudget,hkBranch,hkStack,hkStackWait decision
|
|
665
670
|
class hkEnd success
|
|
666
671
|
```
|
|
@@ -681,6 +686,8 @@ held for days after its announcement, a root closed by accident with children op
|
|
|
681
686
|
root pull request each get one line in the digest. No labels move, so nothing the router listens
|
|
682
687
|
for is raised by the sweep itself.
|
|
683
688
|
|
|
689
|
+
The last step merges Dependabot's updates through the `merge-dependabot` action: only pull requests Dependabot opened with every commit verified and its own, squash-merged once every check is green, a stale one asked once to rebase, a failing one listed in the job summary. It reads nothing of the pull request but the API, and `DEPENDABOT_MERGE` sets its scope. See `docs/dependabot-merge.md` in the package.
|
|
690
|
+
|
|
684
691
|
---
|
|
685
692
|
|
|
686
693
|
## agentics-app-errors
|
|
@@ -963,6 +963,9 @@ jobs:
|
|
|
963
963
|
# If a deploy pipeline calls this workflow with `uses:`, that caller must also grant
|
|
964
964
|
# `actions: write`. A called workflow may not request a scope its caller withheld, and the
|
|
965
965
|
# whole run fails to start with zero jobs and no annotation when it does.
|
|
966
|
+
#
|
|
967
|
+
# Not for Dependabot: its runs get a read-only token, so the dispatch would fail its pull request,
|
|
968
|
+
# and the router's maintenance run merges Dependabot updates instead of the train.
|
|
966
969
|
dispatch-merge-gate:
|
|
967
970
|
name: Dispatch merge gate
|
|
968
971
|
needs: [api, mutation-api, web, mutation-web, secret-scan, deps-and-iac-scan, sast-scan, sbom, engineering-excellence, platform-conformance, full-ci]
|
|
@@ -972,6 +975,7 @@ jobs:
|
|
|
972
975
|
github.event_name == 'pull_request' &&
|
|
973
976
|
github.event.pull_request.head.repo.full_name == github.repository &&
|
|
974
977
|
github.event.pull_request.draft == false &&
|
|
978
|
+
github.event.pull_request.user.login != 'dependabot[bot]' &&
|
|
975
979
|
(github.event.pull_request.user.login == 'app/github-actions' || endsWith(github.event.pull_request.user.login, '[bot]'))
|
|
976
980
|
runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
|
|
977
981
|
timeout-minutes: 5
|