ruvnet-brain 4.3.34 → 4.3.35
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/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/scripts/approved-runtime.mjs +247 -26
- package/scripts/corpus-next-seed.mjs +191 -70
- package/scripts/corpus-reconcile.mjs +110 -16
- package/scripts/oracle/retrieval-accuracy.mjs +74 -6
- package/scripts/rehearse-corpus-pipeline.mjs +315 -25
- package/scripts/rehearse-seed-selection.mjs +234 -0
- package/scripts/release.mjs +13 -6
- package/scripts/single-source-check.mjs +15 -2
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// scripts/rehearse-seed-selection.mjs — the ADR-0091 D4 half of the corpus rehearsal.
|
|
2
|
+
//
|
|
3
|
+
// Before D4 the rehearsal handed candidate N straight to generation N+1 and never exercised seed
|
|
4
|
+
// SELECTION at all (the audit's V4 gap). This module lets it do what the nightly does:
|
|
5
|
+
// 1. "publish" candidate N into a LOCAL release registry, from the exact files release.mjs tried to
|
|
6
|
+
// upload (the recorded `gh release create`), so nothing is invented about the asset set;
|
|
7
|
+
// 2. optionally cut a simulated CODE RELEASE in the disposable checkout between generations (a
|
|
8
|
+
// version bump through the repo's own scripts/sync-version.mjs, committed) -- the event that,
|
|
9
|
+
// before D4, reset the seed chain to the bootstrap (ADR-0091 section 3.4);
|
|
10
|
+
// 3. register newer, INCOMPATIBLE decoy generations that must be skipped before any archive
|
|
11
|
+
// download: one whose receipt names a different embedding model, one whose recall report was
|
|
12
|
+
// measured against a different frozen fixture;
|
|
13
|
+
// 4. answer the real corpus-next-seed.mjs resolver through a fake `gh` served from that registry,
|
|
14
|
+
// which REFUSES any archive download and records every call;
|
|
15
|
+
// 5. check every candidate's runtime surface with the real verifyApprovedRuntime against a pin
|
|
16
|
+
// derived from the CHECKOUT's own files, never from the candidate -- so a seed's executable that
|
|
17
|
+
// leaked into the archive shows up as "no approved code release pinned".
|
|
18
|
+
//
|
|
19
|
+
// It publishes nothing and calls no network: the registry is a directory, the `gh` is a function.
|
|
20
|
+
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
|
|
26
|
+
const ARCHIVE_ASSET = 'ruvnet-brain.zip';
|
|
27
|
+
const SIGNATURE_ASSET = 'ruvnet-brain.zip.sig';
|
|
28
|
+
|
|
29
|
+
const sha256Text = (text) => crypto.createHash('sha256').update(text).digest('hex');
|
|
30
|
+
function sha256File(file) {
|
|
31
|
+
const hash = crypto.createHash('sha256');
|
|
32
|
+
const fd = fs.openSync(file, 'r');
|
|
33
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
34
|
+
try {
|
|
35
|
+
let bytes;
|
|
36
|
+
while ((bytes = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, bytes));
|
|
37
|
+
} finally { fs.closeSync(fd); }
|
|
38
|
+
return hash.digest('hex');
|
|
39
|
+
}
|
|
40
|
+
const clone = (from, to) => fs.copyFileSync(from, to, fs.constants.COPYFILE_FICLONE);
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------------------------
|
|
43
|
+
// The local registry
|
|
44
|
+
// ---------------------------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Register one generation. `files` are local paths whose basenames become asset names. `declared`
|
|
48
|
+
* overrides an asset's listed size without writing its bytes (a decoy's archive is listed, never
|
|
49
|
+
* served). A `.sig` placeholder is added when the files carry none, and the release records that it
|
|
50
|
+
* is a placeholder: the rehearsal's publish path is the non-promoting one, which signs nothing, and
|
|
51
|
+
* the resolver checks the signature's PRESENCE only (verification happens in the updater).
|
|
52
|
+
*/
|
|
53
|
+
export function registerGeneration({ registryDir, tag, createdAt, files = [], declared = {}, note = null }) {
|
|
54
|
+
const dir = path.join(registryDir, tag);
|
|
55
|
+
fs.mkdirSync(path.join(dir, 'assets'), { recursive: true });
|
|
56
|
+
const assets = [];
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const name = path.basename(file);
|
|
59
|
+
clone(file, path.join(dir, 'assets', name));
|
|
60
|
+
assets.push({ name, size: fs.statSync(file).size, state: 'uploaded' });
|
|
61
|
+
}
|
|
62
|
+
for (const [name, size] of Object.entries(declared)) {
|
|
63
|
+
fs.writeFileSync(path.join(dir, 'assets', name), '');
|
|
64
|
+
assets.push({ name, size, state: 'uploaded', declaredOnly: true });
|
|
65
|
+
}
|
|
66
|
+
let signaturePlaceholder = false;
|
|
67
|
+
if (!assets.some(({ name }) => name === SIGNATURE_ASSET)) {
|
|
68
|
+
fs.writeFileSync(path.join(dir, 'assets', SIGNATURE_ASSET), 'rehearsal placeholder: not a signature\n');
|
|
69
|
+
assets.push({ name: SIGNATURE_ASSET, size: fs.statSync(path.join(dir, 'assets', SIGNATURE_ASSET)).size, state: 'uploaded' });
|
|
70
|
+
signaturePlaceholder = true;
|
|
71
|
+
}
|
|
72
|
+
const release = { tagName: tag, isDraft: false, createdAt, assets, signaturePlaceholder, note };
|
|
73
|
+
fs.writeFileSync(path.join(dir, 'release.json'), `${JSON.stringify(release, null, 2)}\n`);
|
|
74
|
+
return release;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The files a recorded `gh release create` would have uploaded: its existing-file arguments. */
|
|
78
|
+
export function uploadedFilesOf(createArgs) {
|
|
79
|
+
return (createArgs || []).filter((value) => path.isAbsolute(String(value)) && fs.existsSync(value) && fs.statSync(value).isFile());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A `gh` answering the three calls corpus-next-seed.mjs makes, from the registry. An archive download
|
|
84
|
+
* is REFUSED and recorded, so the rehearsal can assert the resolver judged from small files only.
|
|
85
|
+
*/
|
|
86
|
+
export function registryGh(registryDir) {
|
|
87
|
+
const calls = [];
|
|
88
|
+
const releases = () => fs.readdirSync(registryDir)
|
|
89
|
+
.map((tag) => JSON.parse(fs.readFileSync(path.join(registryDir, tag, 'release.json'), 'utf8')));
|
|
90
|
+
const run = (command, args) => {
|
|
91
|
+
const line = `${command} ${args.join(' ')}`;
|
|
92
|
+
calls.push(line);
|
|
93
|
+
if (command !== 'gh') return { status: 127, stderr: `unexpected command ${command}` };
|
|
94
|
+
if (args[0] === 'release' && args[1] === 'list') {
|
|
95
|
+
return { status: 0, stdout: JSON.stringify(releases().map(({ tagName, isDraft, createdAt }) => ({ tagName, isDraft, createdAt }))) };
|
|
96
|
+
}
|
|
97
|
+
const release = releases().find((row) => row.tagName === args[2]);
|
|
98
|
+
if (args[0] === 'release' && args[1] === 'view') {
|
|
99
|
+
if (!release) return { status: 1, stderr: 'release not found' };
|
|
100
|
+
return { status: 0, stdout: JSON.stringify({ tagName: release.tagName, isDraft: release.isDraft,
|
|
101
|
+
assets: release.assets.map(({ name, size, state }) => ({ name, size, state })) }) };
|
|
102
|
+
}
|
|
103
|
+
if (args[0] === 'release' && args[1] === 'download') {
|
|
104
|
+
const pattern = args[args.indexOf('--pattern') + 1];
|
|
105
|
+
const dir = args[args.indexOf('--dir') + 1];
|
|
106
|
+
if (pattern === ARCHIVE_ASSET) return { status: 1, stderr: 'rehearsal registry refuses archive downloads to the resolver' };
|
|
107
|
+
const asset = release?.assets.find(({ name }) => name === pattern);
|
|
108
|
+
if (!asset || asset.declaredOnly) return { status: 1, stderr: 'asset not found' };
|
|
109
|
+
fs.copyFileSync(path.join(registryDir, release.tagName, 'assets', pattern), path.join(dir, pattern));
|
|
110
|
+
return { status: 0, stdout: '' };
|
|
111
|
+
}
|
|
112
|
+
return { status: 1, stderr: `unsupported gh call in the rehearsal registry: ${line}` };
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
run,
|
|
116
|
+
calls,
|
|
117
|
+
downloads: () => calls.filter((line) => / release download /.test(line)),
|
|
118
|
+
archiveDownloadAttempts: () => calls.filter((line) => / release download /.test(line) && line.includes(`--pattern ${ARCHIVE_ASSET} `)),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Two newer generations the resolver must SKIP, each rebound to its own digest so every identity
|
|
124
|
+
* check that precedes the compatibility check still passes -- only the property under test differs:
|
|
125
|
+
* model-mismatch -- one store's receipt row names another model/dimensions (rejected from the
|
|
126
|
+
* receipt alone; its recall report must never be fetched);
|
|
127
|
+
* fixture-mismatch -- the recall report was measured against another frozen fixture (rejected
|
|
128
|
+
* after the small recall download; its archive must never be fetched).
|
|
129
|
+
*/
|
|
130
|
+
export function registerIncompatibleDecoys({ registryDir, real, createdAtMs, scratchDir }) {
|
|
131
|
+
const realReceipt = JSON.parse(fs.readFileSync(real.receiptFile, 'utf8'));
|
|
132
|
+
const realRecall = JSON.parse(fs.readFileSync(real.recallFile, 'utf8'));
|
|
133
|
+
const decoys = [];
|
|
134
|
+
const make = (kind, offsetMs, mutate) => {
|
|
135
|
+
const digest = sha256Text(`rehearsal-decoy:${kind}:${real.sha256}`);
|
|
136
|
+
const tag = `corpus-sha256-${digest}`;
|
|
137
|
+
const dir = path.join(scratchDir, `decoy-${kind}`);
|
|
138
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
139
|
+
const recall = { ...realRecall, archive: { ...realRecall.archive, sha256: digest, bytes: real.bytes } };
|
|
140
|
+
const receipt = { ...realReceipt, archive: { ...realReceipt.archive, sha256: digest, bytes: real.bytes } };
|
|
141
|
+
mutate({ recall, receipt });
|
|
142
|
+
const recallFile = path.join(dir, 'ruvnet-brain.zip.recall.json');
|
|
143
|
+
fs.writeFileSync(recallFile, JSON.stringify(recall));
|
|
144
|
+
receipt.recallReport = { file: path.basename(recallFile), sha256: sha256File(recallFile), bytes: fs.statSync(recallFile).size };
|
|
145
|
+
const receiptFile = path.join(dir, 'corpus-receipt.json');
|
|
146
|
+
fs.writeFileSync(receiptFile, JSON.stringify(receipt));
|
|
147
|
+
registerGeneration({
|
|
148
|
+
registryDir, tag, createdAt: new Date(createdAtMs + offsetMs).toISOString(),
|
|
149
|
+
files: [receiptFile, recallFile, real.accuracyFile], declared: { [ARCHIVE_ASSET]: real.bytes },
|
|
150
|
+
note: `rehearsal decoy (${kind}); its archive is listed, never served`,
|
|
151
|
+
});
|
|
152
|
+
decoys.push({ kind, tag });
|
|
153
|
+
};
|
|
154
|
+
make('fixture-mismatch', 1000, ({ recall }) => {
|
|
155
|
+
recall.fixture = { ...recall.fixture, sha256: sha256Text('rehearsal-decoy: a different frozen fixture') };
|
|
156
|
+
});
|
|
157
|
+
make('model-mismatch', 2000, ({ receipt }) => {
|
|
158
|
+
receipt.stores = receipt.stores.map((row, index) => (index === 0 ? { ...row, model: 'Xenova/all-MiniLM-L6-v2', dimensions: 384 } : row));
|
|
159
|
+
});
|
|
160
|
+
return decoys;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------------------------
|
|
164
|
+
// A code release between generations
|
|
165
|
+
// ---------------------------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
export function nextPatchVersion(version) {
|
|
168
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version || ''));
|
|
169
|
+
if (!match) throw new Error(`cannot bump a non x.y.z version (${version})`);
|
|
170
|
+
return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Cut a code release in the disposable checkout the way CONTRIBUTING.md's "set version" commit does:
|
|
175
|
+
* edit the ONE hand-edited number (plugin/.claude-plugin/plugin.json) and let scripts/sync-version.mjs
|
|
176
|
+
* propagate it (kb/package.json -- a SHIPPED runtime file -- among others). Only the paths this bump
|
|
177
|
+
* changed are committed; the rehearsal's own scratch edits in the checkout stay uncommitted.
|
|
178
|
+
*/
|
|
179
|
+
export function simulateCodeRelease({ checkoutRoot }) {
|
|
180
|
+
const git = (args) => {
|
|
181
|
+
const result = spawnSync('git', args, { cwd: checkoutRoot, encoding: 'utf8' });
|
|
182
|
+
if (result.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${String(result.stderr).trim().slice(0, 400)}`);
|
|
183
|
+
return String(result.stdout);
|
|
184
|
+
};
|
|
185
|
+
const dirty = () => new Set(git(['status', '--porcelain']).split('\n').filter(Boolean).map((line) => line.slice(3)));
|
|
186
|
+
const pluginFile = path.join(checkoutRoot, 'plugin', '.claude-plugin', 'plugin.json');
|
|
187
|
+
const plugin = JSON.parse(fs.readFileSync(pluginFile, 'utf8'));
|
|
188
|
+
const from = plugin.version;
|
|
189
|
+
const to = nextPatchVersion(from);
|
|
190
|
+
const before = dirty();
|
|
191
|
+
plugin.version = to;
|
|
192
|
+
fs.writeFileSync(pluginFile, `${JSON.stringify(plugin, null, 2)}\n`);
|
|
193
|
+
const sync = spawnSync(process.execPath, [path.join(checkoutRoot, 'scripts', 'sync-version.mjs')], { cwd: checkoutRoot, encoding: 'utf8' });
|
|
194
|
+
if (sync.status !== 0) throw new Error(`sync-version.mjs failed in the disposable checkout: ${String(sync.stderr || sync.stdout).trim().slice(0, 400)}`);
|
|
195
|
+
const committed = [...dirty()].filter((file) => !before.has(file)).sort();
|
|
196
|
+
if (!committed.includes('kb/package.json')) throw new Error('the simulated code release did not change kb/package.json, the shipped runtime manifest');
|
|
197
|
+
git(['add', '--', ...committed]);
|
|
198
|
+
git(['-c', 'user.email=rehearsal@localhost', '-c', 'user.name=corpus rehearsal', 'commit', '-q', '-m', `chore(release): set version ${to} (rehearsal)`]);
|
|
199
|
+
return { from, to, head: git(['rev-parse', 'HEAD']).trim(), committed };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------------------------
|
|
203
|
+
// The runtime surface, judged against the CHECKOUT
|
|
204
|
+
// ---------------------------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A pin built from the checkout's own bytes, for the runtime-shaped rows of a candidate manifest.
|
|
208
|
+
* build-bundle.mjs copies the kb module graph and kb/package*.json from <checkout>/kb, and
|
|
209
|
+
* verify-bundle.mjs, the signing key and primer/ from the checkout root or scripts/. A row is pinned
|
|
210
|
+
* only when a checkout file at one of those places has EXACTLY its bytes; anything else (a file that
|
|
211
|
+
* could only have come from the seed) stays unpinned, so verifyApprovedRuntime's backward check names
|
|
212
|
+
* it. Never emitted from the candidate itself, which would pin whatever leaked.
|
|
213
|
+
*/
|
|
214
|
+
export function checkoutRuntimePin({ checkoutRoot, manifest, approvedCodeSha, version, api }) {
|
|
215
|
+
const traced = [];
|
|
216
|
+
const untraced = [];
|
|
217
|
+
for (const row of manifest.files.filter((entry) => api.isRuntimeFile(entry.path))) {
|
|
218
|
+
const candidates = [path.join('kb', row.path), row.path, path.join('scripts', row.path)];
|
|
219
|
+
const source = candidates.find((relative) => {
|
|
220
|
+
const file = path.join(checkoutRoot, relative);
|
|
221
|
+
return fs.existsSync(file) && fs.statSync(file).isFile() && fs.statSync(file).size === row.bytes && sha256File(file) === row.sha256;
|
|
222
|
+
});
|
|
223
|
+
if (source) {
|
|
224
|
+
const file = path.join(checkoutRoot, source);
|
|
225
|
+
traced.push({ path: row.path, sha256: sha256File(file), bytes: fs.statSync(file).size, source });
|
|
226
|
+
} else untraced.push(row.path);
|
|
227
|
+
}
|
|
228
|
+
const pin = api.emitApprovedRuntime({
|
|
229
|
+
manifest: { schemaVersion: 1, kind: 'ruvnet-brain-archive-manifest', version, releaseTag: `v${version}`,
|
|
230
|
+
files: traced.map(({ path: p, sha256, bytes }) => ({ path: p, sha256, bytes })) },
|
|
231
|
+
approvedCodeSha,
|
|
232
|
+
});
|
|
233
|
+
return { pin, traced: traced.length, untraced };
|
|
234
|
+
}
|
package/scripts/release.mjs
CHANGED
|
@@ -60,8 +60,8 @@ const recallNotes = (receipt) => {
|
|
|
60
60
|
+ ` about themselves from their own content; ${r.exactFileTop5}/${r.questions} return the exact`
|
|
61
61
|
+ ` labeled file in the top 5 (floor ${r.floor}).`,
|
|
62
62
|
'NOT measured: generated-answer correctness, citation support, or unscoped whole-corpus discovery.',
|
|
63
|
-
`ADR-086 C3 was NOT met and is NOT claimed — its measurement
|
|
64
|
-
+
|
|
63
|
+
`ADR-086 C3 was NOT met and is NOT claimed — its diagnostic measurement (a declared question sample`
|
|
64
|
+
+ ` when its coverage.bounded says so) ships as ${receipt.accuracyReport.file} for inspection.`,
|
|
65
65
|
];
|
|
66
66
|
};
|
|
67
67
|
import { verifyBundle } from './verify-bundle.mjs';
|
|
@@ -164,6 +164,12 @@ export async function runProtectedCorpusSeed({
|
|
|
164
164
|
} catch (error) {
|
|
165
165
|
corpusFailure(`corpus receipt is unreadable/corrupt (${error.message})`);
|
|
166
166
|
}
|
|
167
|
+
// EXACT equality, never "GITHUB_SHA or an ancestor of it" (independent review of ADR-0091 D3,
|
|
168
|
+
// 2026-09-28). Accepting an ancestor let the unattended corpus job promote an OLDER runtime over the
|
|
169
|
+
// current live code release as `releases/latest` — fresh installs then fail on a version mismatch
|
|
170
|
+
// and already-updated clients refuse it as incompatible. The corpus is built at the newest
|
|
171
|
+
// install-verified release's sourceSha, and that must BE the protected main commit this run executes.
|
|
172
|
+
// The format check runs first; the comparisons below never hand the value to a subprocess.
|
|
167
173
|
if (!isHex(target, 40) || target !== head || target !== env.GITHUB_SHA || target !== receipt.builderSourceSha) {
|
|
168
174
|
corpusFailure('target must exactly equal HEAD, GITHUB_SHA, and the corpus receipt builderSourceSha');
|
|
169
175
|
}
|
|
@@ -210,10 +216,11 @@ export async function runProtectedCorpusSeed({
|
|
|
210
216
|
|
|
211
217
|
// ADR-086 Step 15's second binding. The detached accuracy report travels beside the archive; this
|
|
212
218
|
// proves (a) the file the receipt names is the file present here, byte for byte, (b) the report
|
|
213
|
-
// was measured against THESE archive bytes, (c)
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
219
|
+
// was measured against THESE archive bytes, and (c) it was produced by the committed benchmark
|
|
220
|
+
// against the committed oracle — so a swapped oracle or a patched benchmark is caught here even
|
|
221
|
+
// though the receipt itself carries only {file, sha256, bytes}. It does NOT check the score or
|
|
222
|
+
// coverage completeness: C3 is a diagnostic (ADR-086 amendment 2026-09-15), and the corpus
|
|
223
|
+
// pipeline measures a declared question sample of it (ADR-0091 D2).
|
|
217
224
|
const accuracyReportFile = `${bundleFile}.accuracy.json`;
|
|
218
225
|
if (receipt.accuracyReport.file !== path.basename(accuracyReportFile)) {
|
|
219
226
|
corpusFailure('corpus receipt names an accuracy report that is not the one beside this archive');
|
|
@@ -133,8 +133,21 @@ const checks = [
|
|
|
133
133
|
} },
|
|
134
134
|
|
|
135
135
|
// C — one release path
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
// ADR-0091 D3: the approved runtime is RESOLVED at run time from the newest install-verified code
|
|
137
|
+
// release (its signed public-verification aggregate), never committed; the nightly is armed only by
|
|
138
|
+
// the CORPUS_NIGHTLY repository variable. A committed pin, or a workflow still reading one, is drift.
|
|
139
|
+
{ id: 'C1', area: 'release', scope: 'repo', title: 'Corpus promotion resolves its runtime pin from an install-verified release at run time; nothing commits one',
|
|
140
|
+
run: () => {
|
|
141
|
+
const problems = [];
|
|
142
|
+
if (tracked.includes('data/approved-runtime.json')) problems.push('data/approved-runtime.json is committed (the pin must be resolved, never committed)');
|
|
143
|
+
for (const w of workflows) if (read(w).includes('data/approved-runtime.json')) problems.push(`${w} still reads data/approved-runtime.json`);
|
|
144
|
+
const dispatcher = read('.github/workflows/corpus-nightly-dispatch.yml');
|
|
145
|
+
if (!/vars\.CORPUS_NIGHTLY/.test(dispatcher)) problems.push('corpus-nightly-dispatch.yml is not gated on the CORPUS_NIGHTLY repository variable');
|
|
146
|
+
for (const w of ['corpus-nightly-dispatch.yml', 'protected-release.yml', 'corpus-seed.yml']) {
|
|
147
|
+
if (!read(`.github/workflows/${w}`).includes('approved-runtime.mjs --resolve')) problems.push(`${w} does not resolve the approved runtime`);
|
|
148
|
+
}
|
|
149
|
+
return none(problems);
|
|
150
|
+
} },
|
|
138
151
|
{ id: 'C2', area: 'release', scope: 'repo', title: 'Every scheduled workflow pages the phone on failure',
|
|
139
152
|
run: () => {
|
|
140
153
|
const alerts = read('.github/workflows/ntfy-alerts.yml');
|