@plainconceptsplatform/workflows 1.10.0 → 1.11.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/README.md +1 -1
- package/dist/catalog-installation.js +5 -0
- package/dist/catalog-listing.js +6 -1
- package/dist/index.js +27 -4
- package/dist/template-bundles.d.ts +21 -0
- package/dist/template-bundles.js +108 -0
- package/dist/workflow-catalog.d.ts +5 -1
- package/dist/workflow-catalog.js +8 -0
- package/loops/actions/report-workflow-errors/action.yml +63 -16
- package/loops/actions/report-workflow-errors/report-policy.cjs +89 -5
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +18 -0
- package/loops/templates/ci/app-ci-dotnet-next.yml +776 -117
- package/loops/templates/platform-template/.github/workflows/platform-template-sync.yml +171 -0
- package/loops/templates/platform-template/scripts/check-platform-conformance.mjs +178 -0
- package/loops/templates/quality-gates/.jscpd.json +36 -0
- package/loops/templates/quality-gates/.jscpd.json.md +7 -0
- package/loops/templates/quality-gates/.semgrep/platform-hygiene.yml +88 -0
- package/loops/templates/quality-gates/.zap/rules.tsv +26 -0
- package/loops/templates/quality-gates/scripts/check-complexity.mjs +86 -0
- package/loops/templates/quality-gates/scripts/check-coverage.mjs +89 -0
- package/loops/templates/quality-gates/scripts/check-duplication.mjs +67 -0
- package/loops/templates/quality-gates/scripts/check-mutation.mjs +67 -0
- package/loops/templates/quality-gates/scripts/dast-wait-stable.sh +53 -0
- package/loops/templates/quality-gates/scripts/engineering-excellence.mjs +188 -0
- package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json +895 -0
- package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json.md +5 -0
- package/loops/templates/quality-gates/scripts/lib/complexity-gate.mjs +80 -0
- package/loops/templates/quality-gates/scripts/lib/complexity-gate.test.mjs +105 -0
- package/loops/templates/quality-gates/scripts/lib/duplication-gate.mjs +50 -0
- package/loops/templates/quality-gates/scripts/lib/duplication-gate.test.mjs +47 -0
- package/loops/templates/quality-gates/scripts/lib/engineering-excellence.mjs +674 -0
- package/loops/templates/quality-gates/scripts/lib/engineering-excellence.test.mjs +284 -0
- package/loops/templates/quality-gates/scripts/lib/hygiene-facet.test.mjs +179 -0
- package/loops/templates/quality-gates/scripts/lib/quality-config.mjs +65 -0
- package/loops/templates/quality-gates/scripts/lib/quality-config.test.mjs +29 -0
- package/loops/templates/quality-gates/scripts/lib/source-files.mjs +26 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ npx --yes --package @plainconceptsplatform/workflows@latest workflows search "ci
|
|
|
81
81
|
|
|
82
82
|
## Templates
|
|
83
83
|
|
|
84
|
-
Install optional standalone templates with `add --template`. Available templates are `agentics-checks`, `agentics-error-report`, `agentics-maintenance`, `app-ci-dotnet-next`, `app-ci-node-monorepo`, `bug-report`, `feature-request`, `github-release`,
|
|
84
|
+
Install optional standalone templates with `add --template`. Available templates are `agentics-checks`, `agentics-error-report`, `agentics-maintenance`, `app-ci-dotnet-next`, `app-ci-node-monorepo`, `bug-report`, `feature-request`, `github-release`, `opencode.ci.json`, `platform-template` and `quality-gates`. CI templates are stack-specific copies, not a combined template. `quality-gates` and `platform-template` are bundles for the platform family: they stay package-managed and `update` refreshes them where they are installed; see `docs/family-templates.md` in the repository. `github-release` publishes generated release notes when a `v*` tag is pushed. Edit their top-level `env:` values for repository paths, package names, and commands.
|
|
85
85
|
|
|
86
86
|
`agentics-error-report` is worth installing everywhere. Once a day it classifies how *this package's* workflows behaved in the repository and files what broke upstream, so the package gets fixed instead of every repository working around the same bug. It is deterministic no model runs, because a model asked to summarise a failure paraphrases whatever the log held and it sends only the names this package gives its own workflows, jobs and steps, a conclusion, a bucketed runner label, a count and a classification id. No log line, branch, title, path or issue number leaves the repository, the repository's own workflows are counted and never inspected, and a leak scanner withholds any report mentioning the repository or its owner and fails the run rather than filing it. Clear `UPSTREAM_NAME` in its `env:` to compute the report into the job summary and file nothing.
|
|
87
87
|
|
|
@@ -10,6 +10,7 @@ import { processRoutes, excludedWorkerFiles } from "./route-processing.js";
|
|
|
10
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
|
+
import { installBundle, isBundleTemplate } from "./template-bundles.js";
|
|
13
14
|
const execFileAsync = promisify(execFile);
|
|
14
15
|
const sourceMappings = [
|
|
15
16
|
["actions", ".github/actions"],
|
|
@@ -286,6 +287,10 @@ const templateCompanions = {
|
|
|
286
287
|
};
|
|
287
288
|
export async function installTemplate(repositoryPath, template, options = {}) {
|
|
288
289
|
const sourcePath = options.sourcePath ?? catalogSourcePath();
|
|
290
|
+
if (isBundleTemplate(template)) {
|
|
291
|
+
const bundle = await installBundle(repositoryPath, template, sourcePath, { force: options.force });
|
|
292
|
+
return { installed: bundle.installed, conflicts: [] };
|
|
293
|
+
}
|
|
289
294
|
const meta = catalogTemplateMeta(template);
|
|
290
295
|
const source = join(sourcePath, "templates", meta.directory, meta.file);
|
|
291
296
|
const target = meta.target;
|
package/dist/catalog-listing.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { access, constants } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { catalogSourcePath } from "./catalog-installation.js";
|
|
4
|
+
import { installedBundles } from "./template-bundles.js";
|
|
3
5
|
import { catalogTemplates, workflowRoutes } from "./workflow-catalog.js";
|
|
4
6
|
export async function listCatalog(options = {}) {
|
|
5
7
|
const basePath = options.installedPath ?? process.cwd();
|
|
@@ -10,12 +12,15 @@ export async function listCatalog(options = {}) {
|
|
|
10
12
|
file: route.worker,
|
|
11
13
|
installed: await isFileInstalled(basePath, route.worker),
|
|
12
14
|
})));
|
|
15
|
+
// A bundle counts as installed by its ownership headers, not by its paths: a repository may keep
|
|
16
|
+
// its own headerless files at the same places.
|
|
17
|
+
const bundles = await installedBundles(basePath, catalogSourcePath());
|
|
13
18
|
const templates = await Promise.all(catalogTemplates.map(async (template) => ({
|
|
14
19
|
kind: "template",
|
|
15
20
|
name: template.name,
|
|
16
21
|
description: template.description,
|
|
17
22
|
file: template.file,
|
|
18
|
-
installed: await isFileInstalled(basePath, template.file, template.target),
|
|
23
|
+
installed: template.bundle === true ? bundles.includes(template.name) : await isFileInstalled(basePath, template.file, template.target),
|
|
19
24
|
})));
|
|
20
25
|
return [...routes, ...templates];
|
|
21
26
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { inspectRepository, parseVisibility, resolveVisibility } from "./repository-inspection.js";
|
|
3
|
-
import { installCatalog, installedRoutes, installTemplate, isTemplateName, removeRouteFiles } from "./catalog-installation.js";
|
|
3
|
+
import { catalogSourcePath, installCatalog, installedRoutes, installTemplate, isTemplateName, removeRouteFiles } from "./catalog-installation.js";
|
|
4
|
+
import { installBundle, installedBundles, isBundleTemplate } from "./template-bundles.js";
|
|
4
5
|
import { formatCatalog, listCatalog, searchCatalog } from "./catalog-listing.js";
|
|
5
6
|
import { packageVersion } from "./package-baseline.js";
|
|
6
7
|
import { routeNames, templateNames } from "./workflow-catalog.js";
|
|
@@ -48,7 +49,9 @@ What an update does to a package-managed file:
|
|
|
48
49
|
follows the package when its default changes. The agent runner pool and the engine gateway URL
|
|
49
50
|
are kept as well. Everything else in the file is the package's.
|
|
50
51
|
A file whose ownership header was removed is yours and is left alone unless --force is passed.
|
|
51
|
-
|
|
52
|
+
Single-file templates are yours after installation and are replaced only with --force.
|
|
53
|
+
Bundle templates (quality-gates, platform-template) stay package-managed like the catalog:
|
|
54
|
+
add --template installs one, and update refreshes it only where its files carry the header.
|
|
52
55
|
|
|
53
56
|
Options:
|
|
54
57
|
--dry-run Print what add or update would change, write nothing.
|
|
@@ -118,15 +121,25 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
|
118
121
|
const allInstalled = [];
|
|
119
122
|
let catalog;
|
|
120
123
|
const templatesPlanned = [];
|
|
124
|
+
const bundles = [];
|
|
125
|
+
const sourcePath = catalogSourcePath();
|
|
121
126
|
// The installed set is always part of the target set: adding a route never drops another,
|
|
122
|
-
// and a plain update refreshes exactly what is there.
|
|
127
|
+
// and a plain update refreshes exactly what is there, bundle templates included. A bundle
|
|
128
|
+
// nobody installed is never added by an update.
|
|
123
129
|
if (routes.length > 0 || template === undefined) {
|
|
124
130
|
const selectedRoutes = unionRoutes(routes, await installedRoutes(repositoryPath));
|
|
125
131
|
catalog = await installCatalog(repositoryPath, { force, dryRun, selectedRoutes, inspection, baseline });
|
|
126
132
|
allConflicts.push(...catalog.conflicts);
|
|
127
133
|
allInstalled.push(...catalog.installed);
|
|
134
|
+
for (const name of await installedBundles(repositoryPath, sourcePath)) {
|
|
135
|
+
if (name !== template)
|
|
136
|
+
bundles.push(await installBundle(repositoryPath, name, sourcePath, { force, dryRun }));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (template !== undefined && isBundleTemplate(template)) {
|
|
140
|
+
bundles.push(await installBundle(repositoryPath, template, sourcePath, { force, dryRun }));
|
|
128
141
|
}
|
|
129
|
-
if (template !== undefined) {
|
|
142
|
+
else if (template !== undefined) {
|
|
130
143
|
if (dryRun) {
|
|
131
144
|
templatesPlanned.push(template);
|
|
132
145
|
}
|
|
@@ -142,11 +155,14 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
|
142
155
|
}
|
|
143
156
|
for (const warning of catalog?.warnings ?? [])
|
|
144
157
|
console.error(`warning: ${warning}`);
|
|
158
|
+
for (const bundle of bundles)
|
|
159
|
+
allInstalled.push(...bundle.installed);
|
|
145
160
|
console.log(JSON.stringify({
|
|
146
161
|
command,
|
|
147
162
|
dryRun,
|
|
148
163
|
...(catalog === undefined ? {} : summarize(catalog)),
|
|
149
164
|
...(templatesPlanned.length > 0 ? { templatesPlanned } : {}),
|
|
165
|
+
...(bundles.length > 0 ? { templates: bundles.map(summarizeBundle) } : {}),
|
|
150
166
|
installed: allInstalled.sort(),
|
|
151
167
|
conflicts: allConflicts,
|
|
152
168
|
}, null, 2));
|
|
@@ -183,6 +199,13 @@ function summarize(result) {
|
|
|
183
199
|
...(result.warnings.length > 0 ? { warnings: result.warnings } : {}),
|
|
184
200
|
};
|
|
185
201
|
}
|
|
202
|
+
function summarizeBundle(result) {
|
|
203
|
+
return {
|
|
204
|
+
template: result.template,
|
|
205
|
+
changes: result.changes.filter((change) => change.status !== "unchanged"),
|
|
206
|
+
unchanged: result.changes.filter((change) => change.status === "unchanged").length,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
186
209
|
// A fixed baseline for every recorded version, instead of asking npm. Rolling out a version that
|
|
187
210
|
// is not published yet leaves the merge two-way, which keeps every consumer value, including the
|
|
188
211
|
// package defaults a consumer never chose and would rather have replaced.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FileChange } from "./catalog-installation.js";
|
|
2
|
+
import { type TemplateName } from "./workflow-catalog.js";
|
|
3
|
+
export interface BundleInstallOptions {
|
|
4
|
+
/** Also overwrite files whose ownership header was removed. */
|
|
5
|
+
readonly force?: boolean;
|
|
6
|
+
/** Compute the plan and write nothing. */
|
|
7
|
+
readonly dryRun?: boolean;
|
|
8
|
+
readonly sourcePath?: string;
|
|
9
|
+
/** The version stamped into headers. Defaults to this package's. */
|
|
10
|
+
readonly packageVersion?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface BundleInstallResult {
|
|
13
|
+
readonly template: TemplateName;
|
|
14
|
+
/** Every target of the bundle, whether it changed or not. */
|
|
15
|
+
readonly installed: readonly string[];
|
|
16
|
+
readonly changes: readonly FileChange[];
|
|
17
|
+
}
|
|
18
|
+
export declare function isBundleTemplate(template: TemplateName): boolean;
|
|
19
|
+
/** The bundles whose files carry our ownership header in this repository. */
|
|
20
|
+
export declare function installedBundles(repositoryPath: string, sourcePath: string): Promise<TemplateName[]>;
|
|
21
|
+
export declare function installBundle(repositoryPath: string, template: TemplateName, sourcePath: string, options?: BundleInstallOptions): Promise<BundleInstallResult>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// A bundle template stays package-managed after installation, under the catalog's header rules. It
|
|
2
|
+
// counts as installed only where one of its files carries our header, so `update` never reaches a
|
|
3
|
+
// repository that keeps its own headerless copies of the same paths.
|
|
4
|
+
import { access, chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
|
+
import { dirname, join, relative } from "node:path";
|
|
7
|
+
import { hasOwnershipHeader, installedVersion, packageVersion, stampVersion } from "./package-baseline.js";
|
|
8
|
+
import { bundleTemplateNames, catalogTemplates } from "./workflow-catalog.js";
|
|
9
|
+
const CONSUMER_OWNED = "consumer-owned: the ownership header was removed; pass --force to reclaim it";
|
|
10
|
+
const carriesHeader = (target) => /\.(ya?ml|md|sh|mjs|cjs|tsv)$/.test(target);
|
|
11
|
+
const normalizeEol = (text) => text.replaceAll("\r\n", "\n");
|
|
12
|
+
export function isBundleTemplate(template) {
|
|
13
|
+
return bundleTemplateNames.includes(template);
|
|
14
|
+
}
|
|
15
|
+
/** The bundles whose files carry our ownership header in this repository. */
|
|
16
|
+
export async function installedBundles(repositoryPath, sourcePath) {
|
|
17
|
+
const installed = [];
|
|
18
|
+
for (const template of bundleTemplateNames) {
|
|
19
|
+
for (const target of await bundleTargets(sourcePath, template)) {
|
|
20
|
+
if (!carriesHeader(target))
|
|
21
|
+
continue;
|
|
22
|
+
const current = await readOptional(join(repositoryPath, target));
|
|
23
|
+
if (current !== undefined && hasOwnershipHeader(current)) {
|
|
24
|
+
installed.push(template);
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return installed;
|
|
30
|
+
}
|
|
31
|
+
export async function installBundle(repositoryPath, template, sourcePath, options = {}) {
|
|
32
|
+
const version = options.packageVersion ?? await packageVersion();
|
|
33
|
+
const root = bundleRoot(sourcePath, template);
|
|
34
|
+
const targets = await bundleTargets(sourcePath, template);
|
|
35
|
+
const changes = [];
|
|
36
|
+
const writes = [];
|
|
37
|
+
for (const target of targets) {
|
|
38
|
+
const current = await readOptional(join(repositoryPath, target));
|
|
39
|
+
const text = stampVersion(normalizeEol(await readFile(join(root, target), "utf8")), version);
|
|
40
|
+
const recorded = current === undefined ? undefined : installedVersion(current);
|
|
41
|
+
const base = recorded === undefined ? { target } : { target, installedVersion: recorded };
|
|
42
|
+
if (current === text) {
|
|
43
|
+
changes.push({ ...base, status: "unchanged" });
|
|
44
|
+
}
|
|
45
|
+
else if (current !== undefined && !options.force && await consumerOwned(repositoryPath, target, current, targets)) {
|
|
46
|
+
changes.push({ target, status: "skipped", reason: CONSUMER_OWNED });
|
|
47
|
+
}
|
|
48
|
+
else if (current === undefined && documentsSkippedJson(target, changes)) {
|
|
49
|
+
// The JSON this note documents stays the consumer's, so the note would claim a file we do not own.
|
|
50
|
+
changes.push({ target, status: "skipped", reason: `documents the consumer-owned ${target.slice(0, -".md".length)}` });
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
changes.push({ ...base, status: current === undefined ? "added" : "updated" });
|
|
54
|
+
writes.push({ target, content: text });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!options.dryRun) {
|
|
58
|
+
for (const { target, content } of writes) {
|
|
59
|
+
const destination = join(repositoryPath, target);
|
|
60
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
61
|
+
await writeFile(destination, content, "utf8");
|
|
62
|
+
// A script a workflow runs by its path needs the executable bit, which a plain write never sets.
|
|
63
|
+
if (content.startsWith("#!"))
|
|
64
|
+
await chmod(destination, 0o755);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { template, installed: targets, changes };
|
|
68
|
+
}
|
|
69
|
+
// A JSON file's header lives in its companion `<file>.md`; without that note the JSON is the consumer's.
|
|
70
|
+
async function consumerOwned(repositoryPath, target, current, targets) {
|
|
71
|
+
if (carriesHeader(target))
|
|
72
|
+
return !hasOwnershipHeader(current);
|
|
73
|
+
const companion = `${target}.md`;
|
|
74
|
+
if (!targets.includes(companion))
|
|
75
|
+
return false;
|
|
76
|
+
const note = await readOptional(join(repositoryPath, companion));
|
|
77
|
+
return note === undefined || !hasOwnershipHeader(note);
|
|
78
|
+
}
|
|
79
|
+
// Targets are sorted, so a JSON file is always decided before the `<file>.md` that documents it.
|
|
80
|
+
function documentsSkippedJson(target, changes) {
|
|
81
|
+
if (!target.endsWith(".json.md"))
|
|
82
|
+
return false;
|
|
83
|
+
const json = target.slice(0, -".md".length);
|
|
84
|
+
return changes.some((change) => change.target === json && change.status === "skipped");
|
|
85
|
+
}
|
|
86
|
+
function bundleRoot(sourcePath, template) {
|
|
87
|
+
const entry = catalogTemplates.find((item) => item.name === template);
|
|
88
|
+
if (entry?.bundle !== true || entry.directory === undefined)
|
|
89
|
+
throw new Error(`${template} is not a bundle template`);
|
|
90
|
+
return join(sourcePath, "templates", entry.directory);
|
|
91
|
+
}
|
|
92
|
+
async function bundleTargets(sourcePath, template) {
|
|
93
|
+
const root = bundleRoot(sourcePath, template);
|
|
94
|
+
const entries = await readdir(root, { recursive: true, withFileTypes: true });
|
|
95
|
+
return entries
|
|
96
|
+
.filter((entry) => entry.isFile())
|
|
97
|
+
.map((entry) => relative(root, join(entry.parentPath, entry.name)).replaceAll("\\", "/"))
|
|
98
|
+
.sort();
|
|
99
|
+
}
|
|
100
|
+
async function readOptional(path) {
|
|
101
|
+
try {
|
|
102
|
+
await access(path, constants.F_OK);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
return normalizeEol(await readFile(path, "utf8"));
|
|
108
|
+
}
|
|
@@ -19,7 +19,7 @@ export interface MandatoryFile {
|
|
|
19
19
|
}
|
|
20
20
|
export declare const mandatoryFiles: readonly MandatoryFile[];
|
|
21
21
|
export declare const generatedConsumerTargets: readonly [".github/workflows/agent-*.lock.yml", ".github/aw/actions-lock.json"];
|
|
22
|
-
export declare const templateNames: readonly ["agentics-app-errors", "agentics-checks", "agentics-error-report", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "feature-request", "github-release", "opencode.ci.json"];
|
|
22
|
+
export declare const templateNames: readonly ["agentics-app-errors", "agentics-checks", "agentics-error-report", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "feature-request", "github-release", "opencode.ci.json", "platform-template", "quality-gates"];
|
|
23
23
|
export type TemplateName = (typeof templateNames)[number];
|
|
24
24
|
export interface CatalogTemplate {
|
|
25
25
|
readonly name: TemplateName;
|
|
@@ -28,5 +28,9 @@ export interface CatalogTemplate {
|
|
|
28
28
|
readonly target?: string;
|
|
29
29
|
/** Subdirectory of `templates/` holding the file. Defaults to "agentics". */
|
|
30
30
|
readonly directory?: string;
|
|
31
|
+
/** Several files under `templates/<directory>/`, package-managed and refreshed by `update` where installed. */
|
|
32
|
+
readonly bundle?: boolean;
|
|
31
33
|
}
|
|
32
34
|
export declare const catalogTemplates: readonly CatalogTemplate[];
|
|
35
|
+
/** Bundle templates: several package-managed files, refreshed by `update` where installed. */
|
|
36
|
+
export declare const bundleTemplateNames: readonly TemplateName[];
|
package/dist/workflow-catalog.js
CHANGED
|
@@ -60,6 +60,8 @@ export const templateNames = [
|
|
|
60
60
|
"feature-request",
|
|
61
61
|
"github-release",
|
|
62
62
|
"opencode.ci.json",
|
|
63
|
+
"platform-template",
|
|
64
|
+
"quality-gates",
|
|
63
65
|
];
|
|
64
66
|
export const catalogTemplates = [
|
|
65
67
|
{ name: "agentics-app-errors", file: "agentics-app-errors.yml", description: "Daily application error report: asks Application Insights what the deployed app threw, groups it, and files one issue per distinct problem HERE, labelled bug + refine so the belt picks it up. Deterministic, no model; the report never leaves this repository, and a leak scanner withholds anything carrying an identifier." },
|
|
@@ -72,4 +74,10 @@ export const catalogTemplates = [
|
|
|
72
74
|
{ name: "feature-request", file: "feature_request.yml", description: "Feature request issue template scoped to small, well-scoped improvements (Small/Medium only; large work belongs in a planning issue).", target: ".github/ISSUE_TEMPLATE/feature_request.yml" },
|
|
73
75
|
{ name: "github-release", file: "github-release.yml", description: "Publishes or updates a GitHub Release with generated notes whenever a v* tag is pushed." },
|
|
74
76
|
{ name: "opencode.ci.json", file: "opencode.ci.json", description: "Standalone OpenCode CI config: plainconcepts provider, GLM model registration, ci-workflow-agent, and LSP defaults for consumer repositories." },
|
|
77
|
+
{ name: "platform-template", file: "scripts/check-platform-conformance.mjs", directory: "platform-template", bundle: true, description: "Platform family template sync: a weekly workflow that runs copier update against the Kitchen-Sink template and opens a pull request per new tag, and a CI guard that keeps .platform/manifest paths unchanged outside a sync. Does nothing without .copier-answers.yml." },
|
|
78
|
+
{ name: "quality-gates", file: "scripts/check-coverage.mjs", directory: "quality-gates", bundle: true, description: "Platform family quality gates: coverage, complexity, duplication and mutation gates, the Engineering Excellence facets, the Semgrep hygiene rules, jscpd and ZAP triage. Floors come from the app's quality.config.json; baselines stay app files." },
|
|
75
79
|
];
|
|
80
|
+
/** Bundle templates: several package-managed files, refreshed by `update` where installed. */
|
|
81
|
+
export const bundleTemplateNames = catalogTemplates
|
|
82
|
+
.filter((template) => template.bundle === true)
|
|
83
|
+
.map((template) => template.name);
|
|
@@ -29,6 +29,12 @@ inputs:
|
|
|
29
29
|
occurrence is a blip; an issue that is already open is always appended to.
|
|
30
30
|
required: false
|
|
31
31
|
default: '2'
|
|
32
|
+
min-occurrences-unknown:
|
|
33
|
+
description: >-
|
|
34
|
+
The same, for a failure the catalogue could not classify. With no diagnosis behind it, a
|
|
35
|
+
cause has to repeat more before it is worth a person's time.
|
|
36
|
+
required: false
|
|
37
|
+
default: '3'
|
|
32
38
|
max-findings:
|
|
33
39
|
description: The most issues one run opens, the most frequent first. The rest stay in the job summary.
|
|
34
40
|
required: false
|
|
@@ -58,6 +64,7 @@ runs:
|
|
|
58
64
|
UPSTREAM_REPO: ${{ inputs.upstream-repo }}
|
|
59
65
|
LOOKBACK_HOURS: ${{ inputs.lookback-hours }}
|
|
60
66
|
MIN_OCCURRENCES: ${{ inputs.min-occurrences }}
|
|
67
|
+
MIN_UNKNOWN: ${{ inputs.min-occurrences-unknown }}
|
|
61
68
|
MAX_FINDINGS: ${{ inputs.max-findings }}
|
|
62
69
|
QUIET_DAYS: ${{ inputs.quiet-days }}
|
|
63
70
|
POLICY_PATH: ${{ github.action_path }}/report-policy.cjs
|
|
@@ -118,7 +125,7 @@ runs:
|
|
|
118
125
|
// line carries repository paths, branch names and issue titles.
|
|
119
126
|
const CATALOGUE = [
|
|
120
127
|
{ id: 'runner-never-assigned', test: /waiting for a runner|no runner matching|requested labels/i, note: 'A job waited for a self-hosted runner that never arrived.' },
|
|
121
|
-
{ id: 'runner-lost-mid-job', test: /lost communication with the server|runner has received a shutdown signal|The self-hosted runner:.*lost/i, note: 'The runner disappeared while a job was running.' },
|
|
128
|
+
{ id: 'runner-lost-mid-job', test: /lost communication with the server|runner has received a shutdown signal|The self-hosted runner:.*lost|exit code 143/i, note: 'The runner disappeared while a job was running.' },
|
|
122
129
|
{ id: 'job-cancelled-by-timeout', test: /The job running on runner .* has exceeded the maximum execution time|timeout-minutes/i, note: 'A job hit its timeout instead of finishing.' },
|
|
123
130
|
{ id: 'out-of-disk', test: /no space left on device|You are running out of disk space/i, note: 'The runner ran out of disk.' },
|
|
124
131
|
{ id: 'out-of-memory', test: /JavaScript heap out of memory|Killed\s+process|OOMKilled/i, note: 'A step was killed for memory.' },
|
|
@@ -187,7 +194,7 @@ runs:
|
|
|
187
194
|
// Things that happened and are worth a number in the job summary, but that nobody can
|
|
188
195
|
// fix by editing a workflow, so they never open an issue: a cancelled job with no step
|
|
189
196
|
// that failed, a log that could not be read, and the fleet's own transient causes.
|
|
190
|
-
const quiet = { cancelledNoStep: 0, unreadable: 0, infra: new Map() };
|
|
197
|
+
const quiet = { cancelledNoStep: 0, unreadable: 0, retried: 0, reviewChecks: 0, infra: new Map() };
|
|
191
198
|
let queueWaitTotal = 0;
|
|
192
199
|
let queueWaitCount = 0;
|
|
193
200
|
let queueWaitWorst = 0;
|
|
@@ -213,6 +220,9 @@ runs:
|
|
|
213
220
|
if (!['failure', 'cancelled', 'timed_out', 'startup_failure'].includes(run.conclusion)) continue;
|
|
214
221
|
ownedFailures += 1;
|
|
215
222
|
|
|
223
|
+
// A lint check failing on a pull request has done its job on somebody's change.
|
|
224
|
+
if (policy.isReviewCheck({ workflow: file, event: run.event })) { quiet.reviewChecks += 1; continue; }
|
|
225
|
+
|
|
216
226
|
let jobs = [];
|
|
217
227
|
try {
|
|
218
228
|
jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRunAttempt, {
|
|
@@ -220,6 +230,10 @@ runs:
|
|
|
220
230
|
});
|
|
221
231
|
} catch { continue; }
|
|
222
232
|
|
|
233
|
+
// The worker already caught this one and sent the work back through the router; the
|
|
234
|
+
// attempt that parks does not retry, so a failure no retry got past is still read.
|
|
235
|
+
if (policy.wasRetried(jobs)) { quiet.retried += 1; continue; }
|
|
236
|
+
|
|
223
237
|
for (const job of jobs) {
|
|
224
238
|
if (job.started_at && job.created_at) {
|
|
225
239
|
const wait = (new Date(job.started_at) - new Date(job.created_at)) / 1000;
|
|
@@ -251,20 +265,24 @@ runs:
|
|
|
251
265
|
annotationText = notes.map((note) => note.message ?? '').join('\n');
|
|
252
266
|
} catch { /* annotations are an extra; the log can still classify */ }
|
|
253
267
|
|
|
254
|
-
|
|
268
|
+
// A step whose name already says what failed is not read further: a missing secret
|
|
269
|
+
// is the repository's to set, whatever the log says around it.
|
|
270
|
+
let patternId = policy.causeFromStep(step?.name) ?? 'unrecognised';
|
|
255
271
|
let logRead = true;
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
272
|
+
if (patternId === 'unrecognised') {
|
|
273
|
+
try {
|
|
274
|
+
const { data: log } = await github.rest.actions.downloadJobLogsForWorkflowRun({
|
|
275
|
+
...context.repo, job_id: job.id,
|
|
276
|
+
});
|
|
277
|
+
const text = typeof log === 'string' ? log : Buffer.from(log).toString('utf8');
|
|
278
|
+
// The tail is where the failure is, and reading less of the log is less to get
|
|
279
|
+
// wrong. Only the matched entry's id crosses into the report.
|
|
280
|
+
const tail = `${annotationText}\n${text.slice(-20000)}`;
|
|
281
|
+
patternId = CATALOGUE.find((entry) => entry.test.test(tail))?.id ?? 'unrecognised';
|
|
282
|
+
} catch {
|
|
283
|
+
logRead = false;
|
|
284
|
+
patternId = CATALOGUE.find((entry) => entry.test.test(annotationText))?.id ?? 'log-unavailable';
|
|
285
|
+
}
|
|
268
286
|
}
|
|
269
287
|
|
|
270
288
|
// A log nobody could read says nothing, so it is counted and not filed.
|
|
@@ -303,7 +321,8 @@ runs:
|
|
|
303
321
|
}
|
|
304
322
|
|
|
305
323
|
const catalogueNote = (id) => CATALOGUE.find((entry) => entry.id === id)?.note
|
|
306
|
-
?? (id === '
|
|
324
|
+
?? (id === 'missing-secret' ? 'A workflow stopped at a check for a secret this repository has not set.'
|
|
325
|
+
: id === 'log-unavailable' ? 'The job log could not be read, so the failure could not be classified.'
|
|
307
326
|
: 'The log matched no known failure shape. Worth adding one to the catalogue.');
|
|
308
327
|
|
|
309
328
|
const ranked = [...findings.values()].sort((a, b) => b.count - a.count);
|
|
@@ -313,6 +332,7 @@ runs:
|
|
|
313
332
|
// person's time, and a bad night opens a handful of issues rather than forty.
|
|
314
333
|
const selection = policy.select(ranked, {
|
|
315
334
|
minOccurrences: Number(process.env.MIN_OCCURRENCES),
|
|
335
|
+
minUnknown: Number(process.env.MIN_UNKNOWN),
|
|
316
336
|
maxFindings: Number(process.env.MAX_FINDINGS),
|
|
317
337
|
});
|
|
318
338
|
|
|
@@ -385,6 +405,8 @@ runs:
|
|
|
385
405
|
`Distinct failure signatures: ${ranked.length}`,
|
|
386
406
|
`Filed or appended: ${selection.file.length}; held back: ${selection.held.length}`,
|
|
387
407
|
`Cancelled jobs with no failing step, not reported: ${quiet.cancelledNoStep}`,
|
|
408
|
+
`Runs whose failure the worker retried through the router, not reported: ${quiet.retried}`,
|
|
409
|
+
`Checks that failed on a pull request, not reported: ${quiet.reviewChecks}`,
|
|
388
410
|
`Jobs whose log could not be read, not reported: ${quiet.unreadable}`,
|
|
389
411
|
`Infrastructure causes, counted and not reported: ${[...quiet.infra].map(([id, n]) => `${id} ${n}`).join(', ') || 'none'}`,
|
|
390
412
|
queueWaitCount > 0
|
|
@@ -397,6 +419,12 @@ runs:
|
|
|
397
419
|
])
|
|
398
420
|
.write();
|
|
399
421
|
|
|
422
|
+
// The repository's own configuration is said here, to the people who can change it, and
|
|
423
|
+
// nowhere else. The step name is the package's (or gh-aw's) own words.
|
|
424
|
+
for (const { finding } of selection.held.filter(({ finding }) => finding.severity === 'consumer')) {
|
|
425
|
+
core.warning(`${finding.count} job(s) of ${finding.workflow} stopped at "${finding.step}": set the secret it checks for, or the work it guards never runs.`);
|
|
426
|
+
}
|
|
427
|
+
|
|
400
428
|
if (ranked.length === 0) {
|
|
401
429
|
core.info('No failures in the window for any workflow this package ships.');
|
|
402
430
|
core.setOutput('filed', '');
|
|
@@ -427,6 +455,18 @@ runs:
|
|
|
427
455
|
const upstream = getOctokit(upstreamToken);
|
|
428
456
|
const filed = [];
|
|
429
457
|
|
|
458
|
+
// The latest release, to tell a failure a release may already have fixed from one that
|
|
459
|
+
// is still live. Best effort: a token that cannot read tags files as before.
|
|
460
|
+
let latestRelease = '';
|
|
461
|
+
try {
|
|
462
|
+
const { data: tags } = await upstream.rest.repos.listTags({
|
|
463
|
+
owner: upstreamOwner, repo: upstreamName, per_page: 100,
|
|
464
|
+
});
|
|
465
|
+
latestRelease = policy.latestVersion(tags.map((tag) => tag.name));
|
|
466
|
+
} catch (error) {
|
|
467
|
+
core.info(`could not read the upstream tags: HTTP ${error.status ?? '?'}`);
|
|
468
|
+
}
|
|
469
|
+
|
|
430
470
|
for (const { finding, title, body } of reports) {
|
|
431
471
|
const marker = body.split('\n')[0];
|
|
432
472
|
// One issue per signature, updated rather than repeated. Searching by the marker
|
|
@@ -450,6 +490,13 @@ runs:
|
|
|
450
490
|
core.warning(`could not search upstream for an existing report: HTTP ${error.status ?? '?'}`);
|
|
451
491
|
}
|
|
452
492
|
|
|
493
|
+
// An install a minor or more behind does not open a new issue: update it first. It
|
|
494
|
+
// still appends to one that is open, because then the bug is live on newer versions too.
|
|
495
|
+
if (!existing && policy.isOutdated(packageVersion, latestRelease)) {
|
|
496
|
+
core.info(`left alone, ${packageVersion} is behind ${latestRelease}; update first: ${title}`);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
|
|
453
500
|
if (dryRun) {
|
|
454
501
|
core.info(`[dry-run] would ${existing ? `append to #${existing.number}` : 'open an issue'}: ${title}`);
|
|
455
502
|
continue;
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
// a runner shut down under a job, 47% re-filed a signature seen before, every one was closed as
|
|
11
11
|
// not planned and none had a comment. What follows is the filter that would have kept about a
|
|
12
12
|
// hundred of them out of that pile and left the rest as a count in the job summary.
|
|
13
|
+
//
|
|
14
|
+
// The 11 that still got through in the week to 2026-11-03 were the next layer: 39 jobs a day failing
|
|
15
|
+
// gh-aw's check for an engine secret the repository never set, agent failures the worker had
|
|
16
|
+
// already sent back through the router and recovered, a shellcheck step failing on a pull request
|
|
17
|
+
// it was right to fail, and installs several minors behind reporting what a release had fixed.
|
|
13
18
|
|
|
14
19
|
'use strict';
|
|
15
20
|
|
|
@@ -27,6 +32,13 @@ const INFRA = new Set([
|
|
|
27
32
|
'out-of-memory',
|
|
28
33
|
]);
|
|
29
34
|
|
|
35
|
+
// Causes that belong to the repository that installed the package: a secret it never set. They are
|
|
36
|
+
// warned about in that repository's own job summary, where the person who can set it will look,
|
|
37
|
+
// and never filed upstream, where nobody can.
|
|
38
|
+
const CONSUMER = new Set([
|
|
39
|
+
'missing-secret',
|
|
40
|
+
]);
|
|
41
|
+
|
|
30
42
|
// Causes that are a defect in something this package ships.
|
|
31
43
|
const PACKAGE_BUG = new Set([
|
|
32
44
|
'jq-error',
|
|
@@ -39,13 +51,75 @@ const PACKAGE_BUG = new Set([
|
|
|
39
51
|
'gh-api-409-conflict',
|
|
40
52
|
]);
|
|
41
53
|
|
|
42
|
-
/** infra, package-bug, or unknown (a cause nobody classified yet, worth a look when it repeats). */
|
|
54
|
+
/** infra, consumer, package-bug, or unknown (a cause nobody classified yet, worth a look when it repeats). */
|
|
43
55
|
function severityOf(patternId) {
|
|
44
56
|
if (INFRA.has(patternId)) return 'infra';
|
|
57
|
+
if (CONSUMER.has(patternId)) return 'consumer';
|
|
45
58
|
if (PACKAGE_BUG.has(patternId)) return 'package-bug';
|
|
46
59
|
return 'unknown';
|
|
47
60
|
}
|
|
48
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Some steps say what failed by their name alone, and their log says it in words the catalogue
|
|
64
|
+
* cannot safely match: gh-aw's secret check names the variable, and a pattern for that name would
|
|
65
|
+
* put an engine name in a file that asserts it never reaches for a model. Step names are the
|
|
66
|
+
* package's own (or gh-aw's, compiled into the package's lock files), so they are safe to read.
|
|
67
|
+
*/
|
|
68
|
+
function causeFromStep(step) {
|
|
69
|
+
if (/^Validate .* secrets?$/i.test(step ?? '')) return 'missing-secret';
|
|
70
|
+
if (/^Check workflow lock file$/i.test(step ?? '')) return 'lock-out-of-date';
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A worker that hits a short run, a runner shutdown or a cut-off sends the work back through the
|
|
76
|
+
* router (decide-agent-retry) and the next attempt usually lands. The failed agent job stays red in
|
|
77
|
+
* the run that retried it, and used to be filed as if nothing had caught it. A run whose retry step
|
|
78
|
+
* succeeded has had its failure answered; the attempt that finally parks does not retry, so a
|
|
79
|
+
* failure the retries never got past is still counted.
|
|
80
|
+
*/
|
|
81
|
+
function wasRetried(jobs) {
|
|
82
|
+
return (jobs ?? []).some((job) => (job.steps ?? []).some((step) =>
|
|
83
|
+
/^Send the (work|issue) back through the router$/.test(step.name ?? '') && step.conclusion === 'success'));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* agentics-checks lints the repository's workflows and actions. On a pull request a red check is
|
|
88
|
+
* the check doing its job on somebody's change; only on the default branch, where the package's
|
|
89
|
+
* own files are what is there, can its failure be the package's.
|
|
90
|
+
*/
|
|
91
|
+
function isReviewCheck({ workflow, event }) {
|
|
92
|
+
return workflow === 'agentics-checks.yml' && (event === 'pull_request' || event === 'pull_request_target');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether the reporting install is a minor or more behind the latest release. Its failure may be
|
|
97
|
+
* one the release already fixed, so it does not open an issue; it still appends to one that is
|
|
98
|
+
* open. Patch versions are left alone: the fleet is rolled out the same day and a patch behind is
|
|
99
|
+
* the normal state for hours after every release.
|
|
100
|
+
*/
|
|
101
|
+
function isOutdated(reporting, latest) {
|
|
102
|
+
const parse = (v) => /^v?(\d+)\.(\d+)\./.exec(v ?? '')?.slice(1, 3).map(Number);
|
|
103
|
+
const a = parse(reporting);
|
|
104
|
+
const b = parse(latest);
|
|
105
|
+
if (!a || !b) return false;
|
|
106
|
+
return a[0] < b[0] || (a[0] === b[0] && a[1] < b[1]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The newest release among the upstream tags. Tags, not GitHub releases or the npm dist-tag: the
|
|
111
|
+
* fleet is rolled out from tags, and both of the others have lagged them by several minors.
|
|
112
|
+
*/
|
|
113
|
+
function latestVersion(tags) {
|
|
114
|
+
let best = '';
|
|
115
|
+
for (const tag of tags ?? []) {
|
|
116
|
+
if (!/^v?\d+\.\d+\.\d+$/.test(tag)) continue;
|
|
117
|
+
const [a, b] = [tag, best].map((v) => v.replace(/^v/, '').split('.').map(Number));
|
|
118
|
+
if (!best || a[0] > b[0] || (a[0] === b[0] && (a[1] > b[1] || (a[1] === b[1] && a[2] > b[2])))) best = tag;
|
|
119
|
+
}
|
|
120
|
+
return best;
|
|
121
|
+
}
|
|
122
|
+
|
|
49
123
|
/**
|
|
50
124
|
* One cause is one issue. A specific cause is the same defect whichever job of the router tripped
|
|
51
125
|
* on it, so the job and the step are not part of its identity: conclude, safe_outputs and reserve
|
|
@@ -65,19 +139,26 @@ function markerKey({ workflow, step, conclusion, patternId }) {
|
|
|
65
139
|
/**
|
|
66
140
|
* Which findings become issues, and why the others do not.
|
|
67
141
|
* - infra causes are held: they are a rate, not a bug.
|
|
142
|
+
* - consumer causes are held: the repository that has them is the one that can fix them.
|
|
68
143
|
* - a finding seen fewer than `minOccurrences` times in the window is held: once is a blip.
|
|
144
|
+
* - an unrecognised cause needs `minUnknown`: with no diagnosis, twice in a day was mostly a
|
|
145
|
+
* provider hiccup nobody could act on.
|
|
69
146
|
* - at most `maxFindings` are filed per run, the most frequent first, so a bad night opens a
|
|
70
147
|
* handful of issues and not forty.
|
|
71
148
|
*/
|
|
72
|
-
function select(findings, { minOccurrences = 2, maxFindings = 5 } = {}) {
|
|
149
|
+
function select(findings, { minOccurrences = 2, minUnknown = 3, maxFindings = 5 } = {}) {
|
|
73
150
|
const ranked = [...findings].sort((a, b) => b.count - a.count);
|
|
74
151
|
const file = [];
|
|
75
152
|
const held = [];
|
|
153
|
+
const neededFor = (finding) => (finding.severity === 'unknown' ? Math.max(minOccurrences, minUnknown) : minOccurrences);
|
|
76
154
|
for (const finding of ranked) {
|
|
77
155
|
if (finding.severity === 'infra') {
|
|
78
156
|
held.push({ finding, reason: 'infrastructure cause, counted not filed' });
|
|
79
|
-
} else if (finding.
|
|
80
|
-
held.push({ finding, reason:
|
|
157
|
+
} else if (finding.severity === 'consumer') {
|
|
158
|
+
held.push({ finding, reason: "this repository's own configuration, warned here not filed" });
|
|
159
|
+
} else if (finding.count < neededFor(finding)) {
|
|
160
|
+
const needed = neededFor(finding);
|
|
161
|
+
held.push({ finding, reason: `seen ${finding.count} time(s), under the ${needed} needed to file` });
|
|
81
162
|
} else if (file.length >= maxFindings) {
|
|
82
163
|
held.push({ finding, reason: `over the ${maxFindings} filed per run` });
|
|
83
164
|
} else {
|
|
@@ -107,4 +188,7 @@ function parseVersion(header) {
|
|
|
107
188
|
return match ? match[1] : 'unknown';
|
|
108
189
|
}
|
|
109
190
|
|
|
110
|
-
module.exports = {
|
|
191
|
+
module.exports = {
|
|
192
|
+
INFRA, CONSUMER, PACKAGE_BUG, severityOf, causeFromStep, wasRetried, isReviewCheck, isOutdated, latestVersion,
|
|
193
|
+
signatureOf, markerKey, select, isQuiet, parseVersion,
|
|
194
|
+
};
|