@mcptoolshop/housekeeping 1.3.1 → 1.4.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/CHANGELOG.md +39 -0
- package/README.es.md +113 -70
- package/README.fr.md +145 -65
- package/README.hi.md +125 -65
- package/README.it.md +126 -65
- package/README.ja.md +111 -70
- package/README.md +89 -23
- package/README.pt-BR.md +144 -76
- package/README.zh.md +130 -66
- package/housekeeping.config.example.json +2 -1
- package/package.json +1 -1
- package/rules/atlas-map.md +6 -1
- package/rules/github-actions.md +7 -3
- package/src/analyze.mjs +148 -41
- package/src/collect.mjs +5 -2
- package/src/config.mjs +13 -4
- package/src/load.mjs +7 -3
- package/src/report.mjs +13 -9
- package/src/schema.sql +9 -1
- package/src/workflow-risks.mjs +125 -0
package/src/collect.mjs
CHANGED
|
@@ -14,11 +14,12 @@ import { priceRun, ratesFromUsage, withFallback, runnerClass } from './cost.mjs'
|
|
|
14
14
|
import { pagesDeployJobs, jobEnvironment } from './workflow-risks.mjs';
|
|
15
15
|
import { DATA_DIR } from './paths.mjs';
|
|
16
16
|
|
|
17
|
-
export const COLLECTOR_VERSION = '1.
|
|
17
|
+
export const COLLECTOR_VERSION = '1.5.0';
|
|
18
18
|
|
|
19
19
|
const REPO_FIELDS = `
|
|
20
20
|
id name description url homepageUrl
|
|
21
21
|
isPrivate isArchived isFork isTemplate isEmpty
|
|
22
|
+
parent { owner { login } }
|
|
22
23
|
createdAt updatedAt pushedAt diskUsage
|
|
23
24
|
stargazerCount forkCount
|
|
24
25
|
watchers { totalCount }
|
|
@@ -680,7 +681,9 @@ export async function collectAtlasMaps(org, repos, { get = rest, fetchImpl = fet
|
|
|
680
681
|
}
|
|
681
682
|
const fleet = await atlasFleetVersion(fetchImpl);
|
|
682
683
|
log(`atlas maps: ${targets.length} committed (${fetched} fetched, ${cached} from cache, ${errored} errors); ` +
|
|
683
|
-
|
|
684
|
+
// npm's newest, not the fleet's version: the fleet carries what the repos
|
|
685
|
+
// pin (analyze.mjs fleetPin), which moves by a pin-bump wave.
|
|
686
|
+
`npm latest ${fleet.version ?? `unknown (${fleet.error})`}`);
|
|
684
687
|
return { ok: true, fleet_version: fleet, fetched, cached, repos: out };
|
|
685
688
|
}
|
|
686
689
|
|
package/src/config.mjs
CHANGED
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
// treated as a non-product.
|
|
8
8
|
//
|
|
9
9
|
// housekeeping.config.json
|
|
10
|
-
// { "org": "your-org", "metaRepos": [".github", "design-assets"]
|
|
10
|
+
// { "org": "your-org", "metaRepos": [".github", "design-assets"],
|
|
11
|
+
// "homeOrgs": ["your-org", "your-other-org"] }
|
|
12
|
+
//
|
|
13
|
+
// `homeOrgs` names the organizations whose projects count as the org's own.
|
|
14
|
+
// A fork of a project outside them is exempt from the map rule. Unset, it is
|
|
15
|
+
// the swept org alone.
|
|
11
16
|
//
|
|
12
17
|
// No network and no database here, so every branch is unit-testable.
|
|
13
18
|
import { readFileSync } from 'node:fs';
|
|
@@ -17,7 +22,7 @@ import { WORK_DIR } from './paths.mjs';
|
|
|
17
22
|
|
|
18
23
|
export const CONFIG_PATH = process.env.HK_CONFIG ?? join(WORK_DIR, 'housekeeping.config.json');
|
|
19
24
|
|
|
20
|
-
const KEYS = ['org', 'metaRepos'];
|
|
25
|
+
const KEYS = ['org', 'metaRepos', 'homeOrgs'];
|
|
21
26
|
const invalid = message => userError('CONFIG_INVALID', message,
|
|
22
27
|
'fix housekeeping.config.json; housekeeping.config.example.json shows the shape');
|
|
23
28
|
const DEFAULT_META = ['.github'];
|
|
@@ -47,7 +52,11 @@ export function parseConfig(text) {
|
|
|
47
52
|
if (!Array.isArray(meta) || meta.some(m => typeof m !== 'string' || !m.trim())) {
|
|
48
53
|
throw invalid('housekeeping config: "metaRepos" must be an array of repo names');
|
|
49
54
|
}
|
|
50
|
-
|
|
55
|
+
const home = raw.homeOrgs ?? null;
|
|
56
|
+
if (home !== null && (!Array.isArray(home) || !home.length || home.some(o => typeof o !== 'string' || !o.trim()))) {
|
|
57
|
+
throw invalid('housekeeping config: "homeOrgs" must be a non-empty array of organization names');
|
|
58
|
+
}
|
|
59
|
+
return { org, metaRepos: [...meta], homeOrgs: home ? [...home] : null };
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
/** Read the config file. A missing file is the defaults; an unreadable or malformed one throws. */
|
|
@@ -55,7 +64,7 @@ export function loadConfig(path = CONFIG_PATH) {
|
|
|
55
64
|
let text;
|
|
56
65
|
try { text = readFileSync(path, 'utf8'); }
|
|
57
66
|
catch (e) {
|
|
58
|
-
if (e.code === 'ENOENT') return { org: null, metaRepos: [...DEFAULT_META] };
|
|
67
|
+
if (e.code === 'ENOENT') return { org: null, metaRepos: [...DEFAULT_META], homeOrgs: null };
|
|
59
68
|
throw e;
|
|
60
69
|
}
|
|
61
70
|
return parseConfig(text);
|
package/src/load.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { parse as parseYaml } from 'yaml';
|
|
|
9
9
|
import {
|
|
10
10
|
editsWorkflowFiles, prCreateWithDefaultToken, auditSteps,
|
|
11
11
|
pagesDeployJobs, jobEnvironment, jobRunsOnDefaultBranch, atlasCheckPins,
|
|
12
|
-
resolveFailedStep,
|
|
12
|
+
resolveFailedStep, scheduleGaps,
|
|
13
13
|
} from './workflow-risks.mjs';
|
|
14
14
|
import { safeJson } from './collect.mjs';
|
|
15
15
|
import { reconcileRepos } from './cost.mjs';
|
|
@@ -112,7 +112,7 @@ export function inspectWorkflow(text, path, defaultBranch = null, atlasEnv = nul
|
|
|
112
112
|
on_triggers: '', job_count: 0, has_matrix: 0, size_bytes: text.length, job_names: '',
|
|
113
113
|
edits_workflow_files: 0, pr_create_default_token: 0,
|
|
114
114
|
audit_steps_enforcing: 0, audit_steps_defanged: '',
|
|
115
|
-
pages_deploy_jobs: '', environments: [], atlas_check: '',
|
|
115
|
+
pages_deploy_jobs: '', environments: [], atlas_check: '', schedule_gaps: null,
|
|
116
116
|
};
|
|
117
117
|
let doc;
|
|
118
118
|
try { doc = parseYaml(text, { logLevel: 'silent' }); } catch { out.state = 'parse_error'; return out; }
|
|
@@ -193,6 +193,9 @@ export function inspectWorkflow(text, path, defaultBranch = null, atlasEnv = nul
|
|
|
193
193
|
|
|
194
194
|
out.atlas_check = atlasCheckPins(doc, jobs).join('\n');
|
|
195
195
|
|
|
196
|
+
const gaps = scheduleGaps(doc, jobEntries);
|
|
197
|
+
out.schedule_gaps = gaps ? gaps.join('\n') : null;
|
|
198
|
+
|
|
196
199
|
// The deploy doors, joined in analyze.mjs to the settings they depend on.
|
|
197
200
|
out.pages_deploy_jobs = pagesDeployJobs(jobEntries).join('\n');
|
|
198
201
|
// The environment a job deploys to is a fact Atlas also records, so where
|
|
@@ -598,6 +601,7 @@ function loadSnapshotInner(db, snap) {
|
|
|
598
601
|
sidebarShape(r.siteAstroCfg?.text),
|
|
599
602
|
starlightRange(r.sitePkg?.text),
|
|
600
603
|
hasAtlasMap(r),
|
|
604
|
+
r.isFork ? (r.parent?.owner?.login ?? null) : null,
|
|
601
605
|
);
|
|
602
606
|
|
|
603
607
|
// The trimmed map record, when the atlas pass read one for this repo.
|
|
@@ -695,7 +699,7 @@ function loadSnapshotInner(db, snap) {
|
|
|
695
699
|
w.on_triggers, w.job_count, w.has_matrix, f.byteSize || w.size_bytes,
|
|
696
700
|
w.edits_workflow_files, w.pr_create_default_token, w.job_names ?? '',
|
|
697
701
|
w.audit_steps_enforcing ?? 0, w.audit_steps_defanged ?? '',
|
|
698
|
-
w.pages_deploy_jobs ?? '', w.atlas_check ?? '');
|
|
702
|
+
w.pages_deploy_jobs ?? '', w.atlas_check ?? '', w.schedule_gaps ?? null);
|
|
699
703
|
for (const e of w.environments ?? []) {
|
|
700
704
|
stmts.wfEnv.run(sid, r.name, w.path, e.job, e.environment, e.runs_on_default,
|
|
701
705
|
e.source ?? 'workflow', e.parsed ?? null);
|
package/src/report.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { openDb } from './load.mjs';
|
|
7
|
-
import { healthScores, RUN_IS_LIVE_MAINLINE_SIGNAL, HEAD_CHECKS_RED, doorFindingRows, ATLAS_DOOR_CHECKS_SINCE,
|
|
7
|
+
import { healthScores, RUN_IS_LIVE_MAINLINE_SIGNAL, HEAD_CHECKS_RED, doorFindingRows, ATLAS_DOOR_CHECKS_SINCE, fleetEngineFor } from './analyze.mjs';
|
|
8
8
|
import { exitCodeFor, formatError, userError } from './errors.mjs';
|
|
9
9
|
import { REPORTS_DIR } from './paths.mjs';
|
|
10
10
|
|
|
@@ -287,7 +287,7 @@ export function buildReport(db, sid) {
|
|
|
287
287
|
push(md.table(relMissing));
|
|
288
288
|
|
|
289
289
|
const unpub = q(`SELECT repo, message FROM finding
|
|
290
|
-
WHERE snapshot_id=? AND code IN ('NPM_UNPUBLISHED','
|
|
290
|
+
WHERE snapshot_id=? AND code IN ('NPM_UNPUBLISHED','MONOREPO_ROOT') ORDER BY code, repo`);
|
|
291
291
|
push(md.h3('Other version observations'));
|
|
292
292
|
push(md.table(unpub));
|
|
293
293
|
|
|
@@ -460,7 +460,7 @@ export function buildReport(db, sid) {
|
|
|
460
460
|
SUM(CASE WHEN r.has_atlas_map IS NULL THEN 1 ELSE 0 END) unknown
|
|
461
461
|
FROM repo r WHERE r.snapshot_id=?1 AND r.is_archived=0 AND r.is_empty=0
|
|
462
462
|
AND EXISTS (SELECT 1 FROM workflow w WHERE w.snapshot_id=r.snapshot_id AND w.repo=r.name)`);
|
|
463
|
-
const fleet =
|
|
463
|
+
const fleet = fleetEngineFor(db, sid);
|
|
464
464
|
if (!atlas?.running || atlas.unknown === atlas.running) {
|
|
465
465
|
push(md.p('_Not measured — this snapshot predates the Atlas map pass (collector 1.3.0). Run `npm run refresh`._'));
|
|
466
466
|
} else {
|
|
@@ -468,12 +468,16 @@ export function buildReport(db, sid) {
|
|
|
468
468
|
'`atlas check` in CI (`rules/atlas-map.md`). ' +
|
|
469
469
|
`${atlas.mapped} of ${atlas.running} such repos have \`atlas/structure.json\` on their default branch; ` +
|
|
470
470
|
`${atlas.unmapped} do not` + (atlas.unknown ? `; ${atlas.unknown} could not be read` : '') + '. ' +
|
|
471
|
-
(fleet
|
|
472
|
-
?
|
|
473
|
-
|
|
474
|
-
? '
|
|
475
|
-
|
|
476
|
-
|
|
471
|
+
(fleet.version
|
|
472
|
+
? (fleet.source === 'fleet-pin'
|
|
473
|
+
? `The fleet engine is **${fleet.version}**, the \`atlas check\` pin ${fleet.repos} of ${fleet.pinned} pinning repos carry` +
|
|
474
|
+
(fleet.npmLatest && fleet.npmLatest !== fleet.version ? ` (npm's newest is ${fleet.npmLatest}; the fleet moves to it by a pin-bump wave)` : '') + '. ' +
|
|
475
|
+
'An engine behind the fleet\'s counts as a defect (`low`): the first pin-bump wave ended on 2026-09-30.'
|
|
476
|
+
: `The fleet engine is **${fleet.version}** (latest \`@dogfood-lab/atlas\` on npm). ` +
|
|
477
|
+
'This snapshot predates the end of the first pin-bump wave, so an older engine is reported here and not counted in the health score.')
|
|
478
|
+
: (fleet.source === 'fleet-pin'
|
|
479
|
+
? 'No repository pins an `atlas check` version, so no engine is judged behind.'
|
|
480
|
+
: `The fleet engine version could not be read${fleet.npmError ? ` (${fleet.npmError})` : ''}, so no engine is judged behind.`))));
|
|
477
481
|
push(md.table(q(`SELECT repo, code, message FROM finding
|
|
478
482
|
WHERE snapshot_id=? AND code IN ('ATLAS_MAP_MISSING','ATLAS_CHECK_NOT_IN_CI')
|
|
479
483
|
ORDER BY code, repo`)));
|
package/src/schema.sql
CHANGED
|
@@ -46,6 +46,10 @@ CREATE TABLE IF NOT EXISTS repo (
|
|
|
46
46
|
-- 1 = atlas/structure.json is on the default branch, 0 = it is not, NULL =
|
|
47
47
|
-- not collected (a snapshot before collector 1.3.0). See rules/atlas-map.md.
|
|
48
48
|
has_atlas_map INTEGER,
|
|
49
|
+
-- The owner of the repository a fork was made from. NULL for a repo that is
|
|
50
|
+
-- not a fork, and for any repo in a snapshot before collector 1.5.0, which
|
|
51
|
+
-- did not read it: an unknown parent is never read as "someone else's".
|
|
52
|
+
fork_parent_owner TEXT,
|
|
49
53
|
PRIMARY KEY (snapshot_id, name)
|
|
50
54
|
);
|
|
51
55
|
|
|
@@ -147,7 +151,11 @@ CREATE TABLE IF NOT EXISTS workflow (
|
|
|
147
151
|
pages_deploy_jobs TEXT,
|
|
148
152
|
-- The pin of each `@dogfood-lab/atlas@<version> check` a run step issues,
|
|
149
153
|
-- newline-joined; `unpinned` for a call without a version. Empty: none.
|
|
150
|
-
atlas_check TEXT
|
|
154
|
+
atlas_check TEXT,
|
|
155
|
+
-- For a scheduled workflow, the conditions of rules/github-actions.md
|
|
156
|
+
-- ("Scheduled workflows") it is shown to fail, newline-joined (scheduleGaps).
|
|
157
|
+
-- Empty: none shown to fail. NULL: the workflow has no schedule.
|
|
158
|
+
schedule_gaps TEXT
|
|
151
159
|
);
|
|
152
160
|
|
|
153
161
|
-- One row per job that names a deployment environment, read from the YAML at
|
package/src/workflow-risks.mjs
CHANGED
|
@@ -453,3 +453,128 @@ export function resolveFailedStep(doc, jobName, stepName, stepNumber = null) {
|
|
|
453
453
|
const st = steps[index];
|
|
454
454
|
return { job: jobId, step: typeof st.name === 'string' && st.name.trim() ? st.name : String(index), index, phase };
|
|
455
455
|
}
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// A scheduled workflow against rules/github-actions.md, "Scheduled workflows"
|
|
459
|
+
// (the org rule was amended 2026-09-08).
|
|
460
|
+
//
|
|
461
|
+
// The rule permits a schedule in an org repo when ALL of these hold: it does
|
|
462
|
+
// something a push cannot; it runs weekly or slower (daily needs a stated
|
|
463
|
+
// reason); it is bounded (ubuntu-latest, an explicit timeout-minutes, a
|
|
464
|
+
// concurrency block); it opens a PR and never pushes to a protected branch; and
|
|
465
|
+
// it carries workflow_dispatch. The first is judgment and is not checked here.
|
|
466
|
+
// Everything below is read from the file alone, and every unknown reads as
|
|
467
|
+
// "not shown to fail": an expression we cannot resolve is not a violation.
|
|
468
|
+
|
|
469
|
+
const FIELD_RANGES = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 6]];
|
|
470
|
+
const NAMES = {
|
|
471
|
+
3: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'],
|
|
472
|
+
4: ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'],
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
/** Number of values a cron field admits, or null when it cannot be read. */
|
|
476
|
+
function cronFieldSize(text, i) {
|
|
477
|
+
const [lo, hi] = FIELD_RANGES[i];
|
|
478
|
+
const values = new Set();
|
|
479
|
+
const num = s => {
|
|
480
|
+
const k = NAMES[i]?.indexOf(s.toLowerCase());
|
|
481
|
+
if (k != null && k >= 0) return i === 3 ? k + 1 : k;
|
|
482
|
+
return /^\d+$/.test(s) ? +s : NaN;
|
|
483
|
+
};
|
|
484
|
+
for (const part of text.split(',')) {
|
|
485
|
+
const [range, stepText] = part.split('/');
|
|
486
|
+
const step = stepText == null ? 1 : +stepText;
|
|
487
|
+
let a, b;
|
|
488
|
+
if (range === '*') [a, b] = [lo, hi];
|
|
489
|
+
else if (range.includes('-')) [a, b] = range.split('-').map(num);
|
|
490
|
+
else { a = num(range); b = stepText == null ? a : hi; }
|
|
491
|
+
if (![a, b, step].every(Number.isFinite) || step < 1) return null;
|
|
492
|
+
for (let v = a; v <= b; v += step) values.add(i === 4 ? v % 7 : v);
|
|
493
|
+
}
|
|
494
|
+
return values.size;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* How many times a year a five-field cron fires, approximately. Day-of-month
|
|
499
|
+
* and day-of-week are ORed by cron when both are restricted, so both count.
|
|
500
|
+
*/
|
|
501
|
+
export function cronFiresPerYear(expr) {
|
|
502
|
+
const f = String(expr ?? '').trim().split(/\s+/);
|
|
503
|
+
if (f.length !== 5) return null;
|
|
504
|
+
const n = f.map(cronFieldSize);
|
|
505
|
+
if (n.some(x => x == null)) return null;
|
|
506
|
+
const domAll = f[2] === '*', dowAll = f[4] === '*';
|
|
507
|
+
const daysPerYear = domAll && dowAll ? 365
|
|
508
|
+
: dowAll ? n[2] * 12
|
|
509
|
+
: domAll ? n[4] * 52
|
|
510
|
+
: n[2] * 12 + n[4] * 52;
|
|
511
|
+
return n[0] * n[1] * daysPerYear * (n[3] / 12);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const PUSH = /\bgit\s+push\b/;
|
|
515
|
+
const NEW_BRANCH = /\bgit\s+(?:checkout\s+-[bB]|switch\s+-[cC])\s/;
|
|
516
|
+
const AUTO_COMMIT = /^(?:stefanzweifel\/git-auto-commit-action|EndBug\/add-and-commit|ad-m\/github-push-action)@/;
|
|
517
|
+
|
|
518
|
+
/** Does this job push to the branch it checked out (the default, on a cron)? */
|
|
519
|
+
function pushesCheckedOutBranch(job) {
|
|
520
|
+
const steps = Array.isArray(job?.steps) ? job.steps : [];
|
|
521
|
+
let branched = false;
|
|
522
|
+
for (const st of steps) {
|
|
523
|
+
if (!st || typeof st !== 'object') continue;
|
|
524
|
+
if (typeof st.uses === 'string' && AUTO_COMMIT.test(st.uses)) {
|
|
525
|
+
const w = st.with ?? {};
|
|
526
|
+
if (!w.branch && !w.create_branch && !w.new_branch) return true;
|
|
527
|
+
}
|
|
528
|
+
if (typeof st.run !== 'string') continue;
|
|
529
|
+
if (NEW_BRANCH.test(st.run)) branched = true;
|
|
530
|
+
if (PUSH.test(st.run) && !branched) return true;
|
|
531
|
+
}
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* The rule's mechanical conditions that a scheduled workflow fails, as short
|
|
537
|
+
* phrases. Empty: none shown to fail. null: the workflow has no schedule.
|
|
538
|
+
*/
|
|
539
|
+
export function scheduleGaps(doc, jobEntries) {
|
|
540
|
+
const on = doc?.on ?? doc?.[true];
|
|
541
|
+
const schedule = on && typeof on === 'object' && !Array.isArray(on) ? on.schedule : null;
|
|
542
|
+
if (!Array.isArray(schedule)) return null;
|
|
543
|
+
const gaps = [];
|
|
544
|
+
|
|
545
|
+
const fires = schedule.map(s => cronFiresPerYear(s?.cron)).filter(x => x != null);
|
|
546
|
+
const perYear = fires.reduce((a, b) => a + b, 0);
|
|
547
|
+
if (perYear > 53) {
|
|
548
|
+
const days = 365 / perYear;
|
|
549
|
+
const every = Math.abs(days - 1) < 0.05 ? 'daily'
|
|
550
|
+
: days >= 1 ? `every ${+days.toFixed(1)} days` : `${+(perYear / 365).toFixed(1)} times a day`;
|
|
551
|
+
gaps.push(`runs more often than weekly (${every}, which needs a stated reason)`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const offLinux = new Set();
|
|
555
|
+
const noTimeout = [], noConcurrency = [], pushers = [];
|
|
556
|
+
for (const [id, job] of jobEntries) {
|
|
557
|
+
if (!job || typeof job !== 'object') continue;
|
|
558
|
+
const reusable = typeof job.uses === 'string';
|
|
559
|
+
const ro = job['runs-on'];
|
|
560
|
+
const labels = (Array.isArray(ro) ? ro : [ro]).filter(r => typeof r === 'string');
|
|
561
|
+
const os = job.strategy?.matrix?.os;
|
|
562
|
+
for (const r of labels) {
|
|
563
|
+
if (r.includes('${{')) {
|
|
564
|
+
if (/matrix\.os/.test(r) && Array.isArray(os)) {
|
|
565
|
+
os.filter(o => typeof o === 'string' && !/^ubuntu-/.test(o)).forEach(o => offLinux.add(o));
|
|
566
|
+
}
|
|
567
|
+
} else if (!/^ubuntu-/.test(r)) offLinux.add(r);
|
|
568
|
+
}
|
|
569
|
+
// A job that calls a reusable workflow cannot carry timeout-minutes.
|
|
570
|
+
if (!reusable && job['timeout-minutes'] == null) noTimeout.push(id);
|
|
571
|
+
if (!job.concurrency) noConcurrency.push(id);
|
|
572
|
+
if (pushesCheckedOutBranch(job)) pushers.push(id);
|
|
573
|
+
}
|
|
574
|
+
if (offLinux.size) gaps.push(`runs off Linux (${[...offLinux].join(', ')})`);
|
|
575
|
+
if (noTimeout.length) gaps.push(`no timeout-minutes on ${noTimeout.join(', ')}`);
|
|
576
|
+
if (!doc.concurrency && noConcurrency.length) gaps.push('no concurrency block');
|
|
577
|
+
if (pushers.length) gaps.push(`pushes to the branch it checked out instead of opening a PR (${pushers.join(', ')})`);
|
|
578
|
+
if (!('workflow_dispatch' in on)) gaps.push('no workflow_dispatch');
|
|
579
|
+
return gaps;
|
|
580
|
+
}
|