model-eol 0.8.4 → 0.9.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 +93 -1
- package/action.yml +1 -1
- package/bot/bot.mjs +22 -122
- package/bot/lib/common.mjs +2 -32
- package/check.mjs +83 -9
- package/lib/digests.mjs +146 -0
- package/lib/receipts.mjs +216 -0
- package/lib/validate-document.mjs +37 -1
- package/lib/verify.mjs +359 -0
- package/package.json +2 -2
- package/schema/model-eol.eval.schema.json +49 -0
- package/schema/model-eol.feed-refresh-receipt.schema.json +63 -0
- package/schema/model-eol.npm-release-result.schema.json +38 -0
- package/schema/model-eol.verification.schema.json +78 -0
package/README.md
CHANGED
|
@@ -571,6 +571,98 @@ Flash-Lite GA models. The pipeline opened
|
|
|
571
571
|
and downstream checks stopped warning about dates Google no longer listed. A
|
|
572
572
|
lifecycle tracker has to retract stale alarms as well as add new ones.
|
|
573
573
|
|
|
574
|
+
## Receipts
|
|
575
|
+
|
|
576
|
+
model-eol writes three receipt kinds. Each one is a public Draft-07 schema,
|
|
577
|
+
and `model-eol validate receipt.json` selects the schema from the receipt's
|
|
578
|
+
`schema` value.
|
|
579
|
+
|
|
580
|
+
| Kind | `schema` value | Binds |
|
|
581
|
+
|---|---|---|
|
|
582
|
+
| [Eval results manifest](https://thossullivan.github.io/model-eol/schema/0.1/model-eol.eval.schema.json) | `model-eol.eval/0.1` | One pass, fail, or timeout result per migration group to the evaluated base commit, the plan digest, the eval settings digest, and each group's feed digest. Written by `model-eol-bot evaluate`. |
|
|
583
|
+
| [Feed-refresh receipt](https://thossullivan.github.io/model-eol/schema/0.1/model-eol.feed-refresh-receipt.schema.json) | `model-eol/feed-refresh-receipt@0.1` | The refresh run URL, the checked commit, and the check time to the SHA-256 of every `feeds/*.json` file. A pending receipt also names the open feed PR. |
|
|
584
|
+
| [npm release result](https://thossullivan.github.io/model-eol/schema/0.1/model-eol.npm-release-result.schema.json) | `model-eol/npm-release-result@0.2` | The release workflow's source commit to either no release, or one exact version, its tag, the release commit, and the registry tarball's sha512 integrity. |
|
|
585
|
+
|
|
586
|
+
Additive changes keep a receipt's version. Any change that makes an old
|
|
587
|
+
receipt invalid, or changes what a digest covers, is a new version. Validators
|
|
588
|
+
reject unknown versions. A few rules, such as feed sort order and `tag` equal
|
|
589
|
+
to `v` plus `version`, cannot be written in Draft-07. Each schema's
|
|
590
|
+
`description` lists them, and `model-eol validate` enforces them in code.
|
|
591
|
+
|
|
592
|
+
`model-eol validate` checks a receipt's structure only. `model-eol verify`
|
|
593
|
+
checks what a receipt claims against inputs you supply:
|
|
594
|
+
|
|
595
|
+
```bash
|
|
596
|
+
# release result against the exact tarball you hold (no registry lookup)
|
|
597
|
+
npm pack model-eol@0.8.4
|
|
598
|
+
model-eol verify npm-release-result.json --package model-eol-0.8.4.tgz \
|
|
599
|
+
--commit RELEASE_SHA --source-commit SOURCE_SHA --version 0.8.4
|
|
600
|
+
|
|
601
|
+
# refresh receipt against a checkout's feeds directory
|
|
602
|
+
model-eol verify feed-refresh-receipt.json --feeds-dir ./feeds \
|
|
603
|
+
--commit REFRESH_SHA --run-url https://github.com/OWNER/REPO/actions/runs/ID
|
|
604
|
+
|
|
605
|
+
# eval manifest against the plan, config, and feeds the evaluator used
|
|
606
|
+
model-eol verify eval-results.json --commit BASE_SHA --plan plan.json \
|
|
607
|
+
--config .model-eol.json --feeds-dir ./feeds
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
The receipt kind comes from its `schema` value. verify validates the receipt's
|
|
611
|
+
structure first, then reports each claim. It never reads the current
|
|
612
|
+
directory, git state, or the network; every input is an explicit flag.
|
|
613
|
+
|
|
614
|
+
| Flag | Checks |
|
|
615
|
+
|---|---|
|
|
616
|
+
| `--feeds-dir DIR` | Refresh: the SHA-256 of every `DIR/*.json`, compared with the receipt's `feeds` list as a set. Changed, missing, and extra files are each a mismatch. `DIR` is the feeds directory itself; a repository root (a directory holding `feeds/`) is refused. Eval: with `--plan` and `--config`, recomputes each result's `feed_digest` (`--plan` and `--config` alone already fix the expected result set). |
|
|
617
|
+
| `--commit SHA` | Refresh `refresh_commit`, release `release_commit`, or eval `base_sha`. |
|
|
618
|
+
| `--run-url URL` | Refresh `refresh_run`, after the same HTTPS URL normalization the receipt writer applies. |
|
|
619
|
+
| `--package FILE.tgz` | Release `registry_integrity`: the sha512 SRI of the tarball bytes. |
|
|
620
|
+
| `--source-commit SHA` | Release `source_commit`. |
|
|
621
|
+
| `--version X.Y.Z` | Release `version`; `tag` is bound to it as `vX.Y.Z`. |
|
|
622
|
+
| `--plan FILE` | Eval `plan_digest`, from a validated `model-eol.plan/0.1` document. |
|
|
623
|
+
| `--config FILE` | Eval `eval_config_digest` and `configured`, with the bot's settings resolution. `MODEL_EOL_EVAL_COMMAND` in the environment overrides `eval.command` as it does for the bot, and the output says so. For a repository without `.model-eol.json`, pass a file containing `{}`. |
|
|
624
|
+
| `--require-all` | Exit 1 when the result is `partial`. |
|
|
625
|
+
| `--json` | Emit a [`model-eol/verification@0.1`](https://thossullivan.github.io/model-eol/schema/0.1/model-eol.verification.schema.json) document. |
|
|
626
|
+
|
|
627
|
+
SHA inputs are 40 to 64 hex characters; uppercase is lowercased. A flag that
|
|
628
|
+
does not apply to the receipt's kind is a usage error.
|
|
629
|
+
|
|
630
|
+
Each claim is `verified`, `mismatch` (with the receipt's value and the
|
|
631
|
+
computed one), `not-checked` (with the flag that would check it), or
|
|
632
|
+
`not-applicable` (a `released: false` result makes no release claims, so only
|
|
633
|
+
`source_commit` applies). Supplying `--commit`, `--version`, or `--package` for a
|
|
634
|
+
`released: false` result adds a `released` claim that mismatches, because those
|
|
635
|
+
inputs assert a release the receipt says did not happen. For a refresh receipt,
|
|
636
|
+
the report also shows its `state`, `checked_at`, and `pending_pr` as information;
|
|
637
|
+
a pending receipt verifies by the same rules as a clean one. For a release
|
|
638
|
+
result the report shows `released`; a release gate must pass `--package`,
|
|
639
|
+
`--version`, `--commit`, and `--require-all`, so that a no-release receipt
|
|
640
|
+
mismatches instead of verifying through `--source-commit` alone.
|
|
641
|
+
|
|
642
|
+
For a configured eval manifest, `--plan` and `--config` fix the set of result
|
|
643
|
+
identities the evaluator had to produce: a planned identity with no result, a
|
|
644
|
+
result for an unplanned identity, and a duplicated identity are each a
|
|
645
|
+
mismatch, and `--feeds-dir` additionally checks each result's `feed_digest`.
|
|
646
|
+
Without `--plan` and `--config`, a `results` claim is reported as not checked,
|
|
647
|
+
so stripping results from a manifest never verifies. Text output escapes
|
|
648
|
+
control characters in every printed value. The overall result is `verified` only when every
|
|
649
|
+
applicable claim is verified. It is `partial` when nothing mismatched but at
|
|
650
|
+
least one claim was not checked: the receipt is consistent with what you
|
|
651
|
+
supplied, not proven. It is `mismatch` when any claim mismatched.
|
|
652
|
+
|
|
653
|
+
Eval outcomes are never claims. `verify` does not check `status`, `exit_code`,
|
|
654
|
+
`report`, or `generated`, because an evaluation cannot be reproduced offline. A
|
|
655
|
+
`verified` eval manifest is bound to the supplied commit, plan, config, and
|
|
656
|
+
feeds. It does not mean the evals passed. A gate that needs passing evals must
|
|
657
|
+
read the results itself.
|
|
658
|
+
|
|
659
|
+
| Exit | Meaning |
|
|
660
|
+
|---|---|
|
|
661
|
+
| 0 | No mismatch: `verified`, or `partial` without `--require-all`. |
|
|
662
|
+
| 1 | At least one claim mismatched, or `partial` with `--require-all`. |
|
|
663
|
+
| 2 | Usage error, unreadable file, structurally invalid receipt, or unsupported receipt schema or version. No claim is reported. |
|
|
664
|
+
| 3 | Output write failure when the result would otherwise exit 0. |
|
|
665
|
+
|
|
574
666
|
## The actual ask
|
|
575
667
|
|
|
576
668
|
The goal is the format, not another checker. Providers already publish
|
|
@@ -592,7 +684,7 @@ is the most useful thing any of us can do here.
|
|
|
592
684
|
canonical schemas, feeds, refresh health, a publication manifest, and Atom.
|
|
593
685
|
Each deployment is bound to an exact feed-refresh receipt and verified against
|
|
594
686
|
every live asset. `model-eol validate` checks feeds, repository policy, check
|
|
595
|
-
reports, inventories, schedules, alerts, and
|
|
687
|
+
reports, inventories, schedules, alerts, plans, and receipts against the same
|
|
596
688
|
zero-dependency runtime contracts.
|
|
597
689
|
- Current-model entries, and therefore policy-floor horizons, populate only when
|
|
598
690
|
the refresh runs with provider API keys for the models endpoints. Anthropic's
|
package/action.yml
CHANGED
|
@@ -41,7 +41,7 @@ inputs:
|
|
|
41
41
|
required: false
|
|
42
42
|
default: ''
|
|
43
43
|
document-type:
|
|
44
|
-
description: 'Optional validate document type: feed, config, check, inventory, schedule, alert, or
|
|
44
|
+
description: 'Optional validate document type: feed, config, check, inventory, schedule, alert, plan, eval, feed-refresh-receipt, or npm-release-result'
|
|
45
45
|
required: false
|
|
46
46
|
default: ''
|
|
47
47
|
include-docs:
|
package/bot/bot.mjs
CHANGED
|
@@ -12,11 +12,9 @@ import {
|
|
|
12
12
|
branchFor,
|
|
13
13
|
daysRemaining,
|
|
14
14
|
hasMetadataMarker,
|
|
15
|
-
itemDigest,
|
|
16
15
|
markdownCode,
|
|
17
16
|
markdownLink,
|
|
18
17
|
markdownText,
|
|
19
|
-
matchesAnyGlob,
|
|
20
18
|
metadataLine,
|
|
21
19
|
parseMetadata,
|
|
22
20
|
repoPath,
|
|
@@ -24,9 +22,22 @@ import {
|
|
|
24
22
|
staleWorkComment,
|
|
25
23
|
stableJson,
|
|
26
24
|
} from './lib/common.mjs'
|
|
25
|
+
import {
|
|
26
|
+
contextFor,
|
|
27
|
+
effectiveEval,
|
|
28
|
+
evalConfigDigest,
|
|
29
|
+
evalIdentity,
|
|
30
|
+
feedContext,
|
|
31
|
+
groupDigest,
|
|
32
|
+
ignoredModelIds,
|
|
33
|
+
isIgnored,
|
|
34
|
+
modelGroups,
|
|
35
|
+
planDigest,
|
|
36
|
+
} from '../lib/digests.mjs'
|
|
27
37
|
import { assertValidPlanDocument } from '../lib/validate-document.mjs'
|
|
28
38
|
import { captureWindowFor, lifecycleFor } from '../lib/feeds.mjs'
|
|
29
39
|
import { parseCliArgs } from '../lib/cli.mjs'
|
|
40
|
+
import { EVAL_RESULTS_SCHEMA as EVAL_SCHEMA, assertValidEvalResults } from '../lib/receipts.mjs'
|
|
30
41
|
import { loadConfig } from './lib/config.mjs'
|
|
31
42
|
import { downloadFeeds } from './lib/feeds.mjs'
|
|
32
43
|
import { capReport, readReportCapped, runEvalHook, reportForBody } from './lib/eval.mjs'
|
|
@@ -47,12 +58,13 @@ import {
|
|
|
47
58
|
verifyRemoteBranchHead,
|
|
48
59
|
} from './lib/git.mjs'
|
|
49
60
|
|
|
61
|
+
export { contextFor }
|
|
62
|
+
|
|
50
63
|
const ROOT = path.resolve(import.meta.dirname, '..')
|
|
51
64
|
const CHECKER = path.join(ROOT, 'check.mjs')
|
|
52
65
|
const VENDORED_FEEDS = path.join(ROOT, 'feeds')
|
|
53
66
|
const PLAN_SCHEMA = 'model-eol.plan/0.1'
|
|
54
67
|
const BOT_SCHEMA = 'model-eol.bot/0.1'
|
|
55
|
-
const EVAL_SCHEMA = 'model-eol.eval/0.1'
|
|
56
68
|
const EVAL_FAILURE_CHANNEL = 'configured-eval'
|
|
57
69
|
const EVAL_ARTIFACT_MAX_BYTES = 64 * 1024 * 1024
|
|
58
70
|
const ISSUE_REASONS = new Set([
|
|
@@ -207,27 +219,6 @@ const verifiedPlanArtifact = (file, generatedPlan) => {
|
|
|
207
219
|
return artifact
|
|
208
220
|
}
|
|
209
221
|
|
|
210
|
-
const withoutGenerated = plan => {
|
|
211
|
-
const { generated, ...stable } = plan
|
|
212
|
-
return stable
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const planDigest = plan => sha256(stableJson(withoutGenerated(plan)))
|
|
216
|
-
|
|
217
|
-
const effectiveEval = (config, commandOverride = null) => ({
|
|
218
|
-
...config.eval,
|
|
219
|
-
command: typeof commandOverride === 'string' && commandOverride.trim() ? commandOverride : config.eval.command,
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
const evalConfigDigest = settings => sha256(stableJson({
|
|
223
|
-
command: settings.command ?? null,
|
|
224
|
-
timeout_ms: settings.timeout_ms,
|
|
225
|
-
max_report_bytes: settings.max_report_bytes,
|
|
226
|
-
pass_env: settings.pass_env,
|
|
227
|
-
}))
|
|
228
|
-
|
|
229
|
-
const evalIdentity = (publisher, id, via) => `${publisher}\0${id}\0${via ?? ''}`
|
|
230
|
-
|
|
231
222
|
const readJsonArtifact = (file, maxBytes, label) => {
|
|
232
223
|
const absolute = asAbsolute(file)
|
|
233
224
|
const artifact = readReportCapped(absolute, maxBytes + 1)
|
|
@@ -245,30 +236,26 @@ const readEvalResults = ({ file, config, commandOverride, plan, groups, baseHead
|
|
|
245
236
|
const maxBytes = Math.min(EVAL_ARTIFACT_MAX_BYTES, Math.max(1024 * 1024, groups.length * (settings.max_report_bytes + 2048)))
|
|
246
237
|
const artifact = readJsonArtifact(file, maxBytes, 'eval results artifact')
|
|
247
238
|
if (artifact?.schema !== EVAL_SCHEMA) throw new Error(`refusing eval results schema ${artifact?.schema ?? 'missing'}; expected ${EVAL_SCHEMA}`)
|
|
239
|
+
try {
|
|
240
|
+
assertValidEvalResults(artifact)
|
|
241
|
+
} catch (error) {
|
|
242
|
+
throw new Error(`refusing eval results artifact: ${error.message}`)
|
|
243
|
+
}
|
|
248
244
|
if (artifact.plan_digest !== planDigest(plan)) throw new Error('refusing eval results artifact: plan digest does not match the independently verified plan')
|
|
249
245
|
if (artifact.eval_config_digest !== evalConfigDigest(settings)) throw new Error('refusing eval results artifact: eval configuration digest does not match')
|
|
250
|
-
if (typeof artifact.base_sha !== 'string' || !/^[0-9a-f]{40,64}$/.test(artifact.base_sha)) throw new Error('refusing eval results artifact: evaluated base commit is malformed or missing')
|
|
251
246
|
if (artifact.base_sha !== baseHead) throw new Error(`refusing eval results artifact: evaluated base commit ${artifact.base_sha} does not match current default-branch head ${baseHead}`)
|
|
252
247
|
const configured = Boolean(settings.command)
|
|
253
248
|
if (artifact.configured !== configured) throw new Error('refusing eval results artifact: configured state does not match eval.command')
|
|
254
|
-
if (!Array.isArray(artifact.results)) throw new Error('refusing eval results artifact: results must be an array')
|
|
255
249
|
if (artifact.results.length !== (configured ? groups.length : 0)) {
|
|
256
250
|
throw new Error('refusing eval results artifact: expected exactly one result for every planned migration')
|
|
257
251
|
}
|
|
258
252
|
const expected = new Map(groups.map(group => [evalIdentity(group.publisher, group.id, group.via), group]))
|
|
259
253
|
const results = new Map()
|
|
260
254
|
for (const result of artifact.results) {
|
|
261
|
-
if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('refusing eval results artifact: result must be an object')
|
|
262
|
-
if (typeof result.publisher !== 'string' || !result.publisher || typeof result.id !== 'string' || !result.id) throw new Error('refusing eval results artifact: result identity is malformed')
|
|
263
|
-
if (result.via !== null && typeof result.via !== 'string') throw new Error('refusing eval results artifact: result clock is malformed')
|
|
264
255
|
const key = evalIdentity(result.publisher, result.id, result.via)
|
|
265
256
|
const group = expected.get(key)
|
|
266
257
|
if (!group || results.has(key)) throw new Error('refusing eval results artifact: result identity is unexpected or duplicated')
|
|
267
258
|
if (result.feed_digest !== group.feedDigest) throw new Error(`refusing eval results artifact: feed digest mismatch for ${group.publisher}/${group.id}`)
|
|
268
|
-
if (!['pass', 'fail', 'timeout'].includes(result.status)) throw new Error(`refusing eval results artifact: invalid status for ${group.publisher}/${group.id}`)
|
|
269
|
-
if (result.exit_code !== null && !Number.isSafeInteger(result.exit_code)) throw new Error(`refusing eval results artifact: invalid exit code for ${group.publisher}/${group.id}`)
|
|
270
|
-
if (result.status === 'pass' && result.exit_code !== 0) throw new Error(`refusing eval results artifact: passing result has a non-zero exit code for ${group.publisher}/${group.id}`)
|
|
271
|
-
if (result.report !== null && typeof result.report !== 'string') throw new Error(`refusing eval results artifact: invalid report for ${group.publisher}/${group.id}`)
|
|
272
259
|
if (result.report !== null && Buffer.byteLength(result.report) > settings.max_report_bytes) throw new Error(`refusing eval results artifact: report exceeds configured cap for ${group.publisher}/${group.id}`)
|
|
273
260
|
results.set(key, { status: result.status, exit_code: result.exit_code, report: result.report })
|
|
274
261
|
}
|
|
@@ -289,95 +276,8 @@ const boundEvalResults = ({ file, config, commandOverride, plan, groups, baseHea
|
|
|
289
276
|
}
|
|
290
277
|
}
|
|
291
278
|
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
let files = []
|
|
295
|
-
try {
|
|
296
|
-
files = fs.readdirSync(feedsDir).filter(file => file.endsWith('.json')).sort()
|
|
297
|
-
} catch {
|
|
298
|
-
return records
|
|
299
|
-
}
|
|
300
|
-
for (const file of files) {
|
|
301
|
-
let feed
|
|
302
|
-
try {
|
|
303
|
-
feed = JSON.parse(fs.readFileSync(path.join(feedsDir, file), 'utf8'))
|
|
304
|
-
} catch {
|
|
305
|
-
continue
|
|
306
|
-
}
|
|
307
|
-
if (feed?.spec !== 'model-eol/0.1' || !Array.isArray(feed.models)) continue
|
|
308
|
-
for (const entry of feed.models) {
|
|
309
|
-
for (const key of [entry.id, ...(entry.aliases ?? [])]) {
|
|
310
|
-
if (typeof key === 'string') records.set(`${feed.publisher}\0${key}`, { feed, entry })
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
return records
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
export const contextFor = (records, publisher, id, via) => {
|
|
318
|
-
const record = records.get(`${publisher}\0${id}`)
|
|
319
|
-
if (!record) return { announced: null, notes: [], entry: null }
|
|
320
|
-
const distribution = via ? (record.entry.distributions ?? []).find(item => item.via === via) : null
|
|
321
|
-
return {
|
|
322
|
-
announced: distribution ? distribution.announced ?? null : record.entry.announced ?? null,
|
|
323
|
-
notes: [record.feed.note, record.entry.notes].filter(Boolean),
|
|
324
|
-
entry: record.entry,
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const ignoredModelIds = (plan, config) => {
|
|
329
|
-
const ignored = new Set(config.ignore.models)
|
|
330
|
-
const ids = new Set()
|
|
331
|
-
for (const item of [...plan.items, ...plan.issues]) {
|
|
332
|
-
if (item.id && (ignored.has(item.id) || ignored.has(item.matched))) ids.add(item.id)
|
|
333
|
-
}
|
|
334
|
-
return ids
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const isIgnored = (entry, root, config, ignoredIds) => {
|
|
338
|
-
if (entry.id && ignoredIds.has(entry.id)) return true
|
|
339
|
-
if (entry.id && config.ignore.models.includes(entry.id)) return true
|
|
340
|
-
if (entry.matched && config.ignore.models.includes(entry.matched)) return true
|
|
341
|
-
return matchesAnyGlob(repoPath(entry.file, root), config.ignore.paths)
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
const buildModelGroups = (plan, config, root, records) => {
|
|
345
|
-
const ignoredIds = ignoredModelIds(plan, config)
|
|
346
|
-
const groups = new Map()
|
|
347
|
-
for (const item of plan.items) {
|
|
348
|
-
if (activeWaiver(item)) continue
|
|
349
|
-
if (isIgnored(item, root, config, ignoredIds)) continue
|
|
350
|
-
const via = Object.hasOwn(item, 'requested_via') ? item.requested_via : plan.via
|
|
351
|
-
const key = `${item.publisher}\0${item.id}\0${via ?? ''}`
|
|
352
|
-
if (!groups.has(key)) {
|
|
353
|
-
groups.set(key, {
|
|
354
|
-
kind: 'model',
|
|
355
|
-
id: item.id,
|
|
356
|
-
publisher: item.publisher,
|
|
357
|
-
items: [],
|
|
358
|
-
via,
|
|
359
|
-
branch: branchFor(item.publisher, item.id, via),
|
|
360
|
-
context: contextFor(records, item.publisher, item.id, via),
|
|
361
|
-
})
|
|
362
|
-
}
|
|
363
|
-
groups.get(key).items.push(item)
|
|
364
|
-
}
|
|
365
|
-
for (const group of groups.values()) {
|
|
366
|
-
group.items.sort((a, b) => `${a.file}:${a.line}:${a.occurrence}`.localeCompare(`${b.file}:${b.line}:${b.occurrence}`))
|
|
367
|
-
group.feedDigest = groupDigest(group.items, group.context?.entry)
|
|
368
|
-
}
|
|
369
|
-
return [...groups.values()].sort((a, b) => `${a.publisher}/${a.id}`.localeCompare(`${b.publisher}/${b.id}`))
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// the capture section reads clocks outside the plan items, so they join the digest that gates body updates
|
|
373
|
-
const clockRows = entry => entry
|
|
374
|
-
? {
|
|
375
|
-
shutdown: entry.shutdown ?? null,
|
|
376
|
-
date_precision: entry.date_precision ?? null,
|
|
377
|
-
distributions: itemDigest((entry.distributions ?? []).map(row => ({ via: row.via, shutdown: row.shutdown ?? null, status: row.status ?? null, date_precision: row.date_precision ?? null }))),
|
|
378
|
-
}
|
|
379
|
-
: null
|
|
380
|
-
const groupDigest = (items, entry) => sha256(stableJson({ items: itemDigest(items), clocks: clockRows(entry) }))
|
|
279
|
+
const buildModelGroups = (plan, config, root, records) =>
|
|
280
|
+
modelGroups(plan, config, root, records, (publisher, id, via) => ({ branch: branchFor(publisher, id, via) }))
|
|
381
281
|
|
|
382
282
|
const buildIssueGroups = (plan, config, root, records) => {
|
|
383
283
|
const ignoredIds = ignoredModelIds(plan, config)
|
package/bot/lib/common.mjs
CHANGED
|
@@ -1,27 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import path from 'node:path'
|
|
1
|
+
import { sha256 } from '../../lib/digests.mjs'
|
|
3
2
|
|
|
4
3
|
export { matchesAnyGlob, matchesGlob } from '../../lib/glob.mjs'
|
|
5
|
-
|
|
6
|
-
export const sha256 = value => crypto.createHash('sha256').update(value).digest('hex')
|
|
7
|
-
|
|
8
|
-
const stableValue = value => {
|
|
9
|
-
if (Array.isArray(value)) return value.map(stableValue)
|
|
10
|
-
if (value && typeof value === 'object') {
|
|
11
|
-
return Object.fromEntries(Object.keys(value).sort().map(key => [key, stableValue(value[key])]))
|
|
12
|
-
}
|
|
13
|
-
return value
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export const stableJson = value => JSON.stringify(stableValue(value))
|
|
17
|
-
|
|
18
|
-
export const sortedJsonDigest = values => {
|
|
19
|
-
const sorted = values.slice().sort((a, b) => {
|
|
20
|
-
const [left, right] = [stableJson(a), stableJson(b)]
|
|
21
|
-
return left < right ? -1 : left > right ? 1 : 0
|
|
22
|
-
})
|
|
23
|
-
return sha256(stableJson(sorted))
|
|
24
|
-
}
|
|
4
|
+
export { activeWaiver, itemDigest, repoPath, sha256, sortedJsonDigest, stableJson } from '../../lib/digests.mjs'
|
|
25
5
|
|
|
26
6
|
const safeSegment = value => {
|
|
27
7
|
const result = String(value ?? '')
|
|
@@ -45,12 +25,6 @@ export const daysRemaining = (shutdown, now = new Date()) => {
|
|
|
45
25
|
return Math.ceil((new Date(`${shutdown}T00:00:00Z`) - now) / 86400000)
|
|
46
26
|
}
|
|
47
27
|
|
|
48
|
-
export const repoPath = (file, root) => {
|
|
49
|
-
const absolute = path.isAbsolute(file) ? file : path.resolve(root, file)
|
|
50
|
-
const relative = path.relative(root, absolute)
|
|
51
|
-
return relative.split(path.sep).join('/').replace(/^\.\//, '')
|
|
52
|
-
}
|
|
53
|
-
|
|
54
28
|
export const metadataLine = metadata => `<!-- model-eol ${JSON.stringify(metadata).replaceAll('-->', '\\u002d\\u002d\\u003e')} -->`
|
|
55
29
|
|
|
56
30
|
export const hasMetadataMarker = body => String(body ?? '').includes('<!-- model-eol')
|
|
@@ -82,10 +56,6 @@ export const parseMetadata = body => {
|
|
|
82
56
|
}
|
|
83
57
|
}
|
|
84
58
|
|
|
85
|
-
export const itemDigest = items => sortedJsonDigest(items)
|
|
86
|
-
|
|
87
|
-
export const activeWaiver = item => item?.waiver?.active === true ? item.waiver : null
|
|
88
|
-
|
|
89
59
|
export const staleWorkComment = (kind, waiver = null) => waiver
|
|
90
60
|
? `model-eol is closing this bot-owned ${kind} because an active waiver makes its finding non-actionable. The waiver expires ${waiver.expires}, is owned by ${markdownText(waiver.owner)}, and records: ${markdownText(waiver.reason)}`
|
|
91
61
|
: `model-eol is closing this bot-owned ${kind} because its finding is no longer actionable on the repository's current default branch. The reference may have been removed, ignored, retracted by the feed, moved to another clock, or disabled by repository configuration.`
|
package/check.mjs
CHANGED
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
// node check.mjs alert [paths...] [--format github|markdown|badge] [--json]
|
|
11
11
|
// node check.mjs plan [paths...] [--days N] [--scope all|direct] [--via DISTRIBUTOR] [--feeds DIR]
|
|
12
12
|
// node check.mjs apply --plan plan.json [--dry-run]
|
|
13
|
-
// node check.mjs validate document.json [...] [--type feed|config|check|inventory|schedule|alert|plan]
|
|
13
|
+
// node check.mjs validate document.json [...] [--type feed|config|check|inventory|schedule|alert|plan|eval|feed-refresh-receipt|npm-release-result|verification]
|
|
14
|
+
// node check.mjs verify RECEIPT [inputs] [--require-all] [--json]
|
|
14
15
|
//
|
|
15
16
|
// exit codes: check/alert 0 = clear, 1 = finding, 2 = usage error;
|
|
16
17
|
// apply 0 = all items applied, 1 = item refused, 2 = usage or plan error;
|
|
17
|
-
// validate 0 = every document valid, 2 = usage, parse, schema, or semantic error
|
|
18
|
+
// validate 0 = every document valid, 2 = usage, parse, schema, or semantic error;
|
|
19
|
+
// verify 0 = verified or partial, 1 = mismatch (or partial with --require-all), 2 = usage, read, structural, or schema error.
|
|
18
20
|
|
|
19
21
|
import fs from 'node:fs'
|
|
20
22
|
import path from 'node:path'
|
|
@@ -48,6 +50,7 @@ import {
|
|
|
48
50
|
} from './lib/reports.mjs'
|
|
49
51
|
import { addedLinesForTargets, filterFindingsToChanged, gitRootFor, incompleteScanNotes, scanTargets } from './lib/scanner.mjs'
|
|
50
52
|
import { DOCUMENT_TYPES, formatDocumentErrors, validateDocumentFile } from './lib/validate-document.mjs'
|
|
53
|
+
import { SUPPORTED_RECEIPT_SCHEMAS, VERIFY_INPUTS, VerifyError, formatVerification, printable, verifyReceipt } from './lib/verify.mjs'
|
|
51
54
|
|
|
52
55
|
const rootForTarget = target => {
|
|
53
56
|
const absolute = path.resolve(target)
|
|
@@ -96,7 +99,7 @@ const repoPathForFile = (file, roots) => {
|
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
const main = () => {
|
|
99
|
-
const COMMANDS = new Set(['check', 'inventory', 'schedule', 'alert', 'plan', 'apply', 'validate', 'help'])
|
|
102
|
+
const COMMANDS = new Set(['check', 'inventory', 'schedule', 'alert', 'plan', 'apply', 'validate', 'verify', 'help'])
|
|
100
103
|
const args = process.argv.slice(2)
|
|
101
104
|
|
|
102
105
|
if (args[0] === '--help' || args[0] === '-h') args[0] = 'help'
|
|
@@ -119,13 +122,20 @@ try {
|
|
|
119
122
|
'dry-run': { type: 'boolean' },
|
|
120
123
|
changed: { type: 'string' },
|
|
121
124
|
type: { type: 'string' },
|
|
125
|
+
'feeds-dir': { type: 'string' },
|
|
126
|
+
commit: { type: 'string' },
|
|
127
|
+
'run-url': { type: 'string' },
|
|
128
|
+
package: { type: 'string' },
|
|
129
|
+
'source-commit': { type: 'string' },
|
|
130
|
+
version: { type: 'string' },
|
|
131
|
+
'require-all': { type: 'boolean' },
|
|
122
132
|
help: { type: 'boolean', short: 'h' },
|
|
123
133
|
},
|
|
124
134
|
allowPositionals: true,
|
|
125
135
|
help: 'node check.mjs --help',
|
|
126
136
|
})
|
|
127
137
|
} catch (error) {
|
|
128
|
-
console.error(error.message)
|
|
138
|
+
console.error(printable(error.message))
|
|
129
139
|
return 2
|
|
130
140
|
}
|
|
131
141
|
|
|
@@ -152,7 +162,9 @@ Usage:
|
|
|
152
162
|
node check.mjs alert [paths...] [--config FILE] [--days N] [--scope all|direct] [--format github|markdown|badge] [--json]
|
|
153
163
|
node check.mjs plan [paths...] [--config FILE] [--days N] [--scope all|direct] [--via DISTRIBUTOR] [--feeds DIR] [--allow-incomplete]
|
|
154
164
|
node check.mjs apply --plan plan.json [--dry-run]
|
|
155
|
-
node check.mjs validate document.json [...] [--type feed|config|check|inventory|schedule|alert|plan]
|
|
165
|
+
node check.mjs validate document.json [...] [--type feed|config|check|inventory|schedule|alert|plan|eval|feed-refresh-receipt|npm-release-result|verification]
|
|
166
|
+
node check.mjs verify RECEIPT [--feeds-dir DIR] [--commit SHA] [--run-url URL] [--package FILE.tgz]
|
|
167
|
+
[--source-commit SHA] [--version X.Y.Z] [--plan FILE] [--config FILE] [--require-all] [--json]
|
|
156
168
|
|
|
157
169
|
Commands:
|
|
158
170
|
check Fail when tracked model IDs are retired or retiring within --days.
|
|
@@ -164,8 +176,38 @@ Commands:
|
|
|
164
176
|
alert Emit GitHub Actions annotations, Markdown, or a Shields badge JSON and fail on errors.
|
|
165
177
|
plan Emit a JSON migration plan with only safely patchable direct API references.
|
|
166
178
|
apply Apply a plan with line-hash and occurrence checks; use --dry-run to preview.
|
|
167
|
-
validate Validate a feed, config, check, inventory, schedule, alert, or plan document
|
|
179
|
+
validate Validate a feed, config, check, inventory, schedule, alert, or plan document,
|
|
180
|
+
an eval, feed-refresh-receipt, or npm-release-result receipt, or a verify --json report.
|
|
168
181
|
The type is inferred from its discriminator or .model-eol.json filename unless --type is set.
|
|
182
|
+
verify Check what a receipt claims against inputs you supply, offline. The receipt kind
|
|
183
|
+
comes from its schema value:
|
|
184
|
+
${SUPPORTED_RECEIPT_SCHEMAS.join('\n ')}
|
|
185
|
+
Each claim is verified, mismatch, not-checked (its input was not supplied), or
|
|
186
|
+
not-applicable (a no-release receipt; a release input given for one adds a mismatched
|
|
187
|
+
released claim). Nothing is read from the current directory or git.
|
|
188
|
+
The result is verified only when every applicable claim is; partial means nothing
|
|
189
|
+
mismatched but some claim was not checked; mismatch means at least one claim failed.
|
|
190
|
+
--json emits a model-eol/verification@0.1 document.
|
|
191
|
+
Eval outcomes (status, exit_code, report) are never claims: verified means the
|
|
192
|
+
manifest is bound to the supplied commit, plan, config, and feeds, not that evals passed.
|
|
193
|
+
|
|
194
|
+
Verify inputs:
|
|
195
|
+
--feeds-dir DIR Feeds directory itself (DIR/*.json, e.g. ./feeds), not a repository root.
|
|
196
|
+
Feed-refresh: every feeds/*.json digest; missing, extra, and changed files mismatch.
|
|
197
|
+
Eval: with --plan and --config, recomputes each result's feed_digest
|
|
198
|
+
(--plan and --config alone already fix the expected result set).
|
|
199
|
+
--commit SHA Feed-refresh refresh_commit, npm-release release_commit, or eval base_sha.
|
|
200
|
+
--run-url URL Feed-refresh refresh_run, compared after HTTPS URL normalization.
|
|
201
|
+
--package FILE.tgz npm-release registry_integrity: sha512 SRI of the tarball bytes. No registry lookup.
|
|
202
|
+
--source-commit SHA npm-release source_commit.
|
|
203
|
+
--version X.Y.Z npm-release version (the tag is bound to it as vX.Y.Z).
|
|
204
|
+
--plan FILE Eval plan_digest from a model-eol.plan/0.1 document; also needed for feed digests.
|
|
205
|
+
--config FILE Eval eval_config_digest and configured, from .model-eol.json (use a file holding {}
|
|
206
|
+
for a repository without one). MODEL_EOL_EVAL_COMMAND overrides eval.command as it
|
|
207
|
+
does for the bot, and the output says when it applied.
|
|
208
|
+
--require-all Exit 1 when the result is partial. A release gate passes --package, --version,
|
|
209
|
+
--commit, and --require-all: a no-release receipt then mismatches.
|
|
210
|
+
SHA inputs are 40-64 hexadecimal characters; uppercase is lowercased.
|
|
169
211
|
|
|
170
212
|
Scopes:
|
|
171
213
|
all Check every tracked model ID found. This preserves the original behavior.
|
|
@@ -176,9 +218,10 @@ Configuration:
|
|
|
176
218
|
--config FILE selects an explicit config. Explicit CLI flags override config values.
|
|
177
219
|
|
|
178
220
|
Exit codes:
|
|
179
|
-
0 Clean, or report generated successfully.
|
|
180
|
-
1 Findings, alert errors, or refused apply items.
|
|
181
|
-
2 Usage, feed, scan, plan, or validation errors.
|
|
221
|
+
0 Clean, or report generated successfully. verify: verified or partial (no claim mismatched).
|
|
222
|
+
1 Findings, alert errors, or refused apply items. verify: a claim mismatched, or partial with --require-all.
|
|
223
|
+
2 Usage, feed, scan, plan, or validation errors. verify: usage error, unreadable file, structurally
|
|
224
|
+
invalid receipt, or unsupported receipt schema.
|
|
182
225
|
3 Output stream failure other than EPIPE.
|
|
183
226
|
`)
|
|
184
227
|
return 0
|
|
@@ -193,6 +236,37 @@ if (values.via === '') {
|
|
|
193
236
|
return 2
|
|
194
237
|
}
|
|
195
238
|
|
|
239
|
+
if (command === 'verify') {
|
|
240
|
+
const allowedOptions = new Set([...VERIFY_INPUTS, 'require-all', 'json'])
|
|
241
|
+
const unsupported = Object.keys(values).find(option => !allowedOptions.has(option))
|
|
242
|
+
if (positionals.length !== 1 || unsupported) {
|
|
243
|
+
const detail = unsupported ? `; --${unsupported} is not supported by verify` : ''
|
|
244
|
+
console.error(`verify requires exactly one RECEIPT${detail}; run node check.mjs --help for usage`)
|
|
245
|
+
return 2
|
|
246
|
+
}
|
|
247
|
+
let report
|
|
248
|
+
try {
|
|
249
|
+
report = verifyReceipt({
|
|
250
|
+
file: positionals[0],
|
|
251
|
+
inputs: Object.fromEntries(VERIFY_INPUTS.map(flag => [flag, values[flag]])),
|
|
252
|
+
env: process.env,
|
|
253
|
+
})
|
|
254
|
+
} catch (error) {
|
|
255
|
+
console.error(printable(error instanceof VerifyError
|
|
256
|
+
? `model-eol verify: ${error.message}`
|
|
257
|
+
: `model-eol verify: unexpected error, nothing was checked: ${error?.message ?? error}`))
|
|
258
|
+
return 2
|
|
259
|
+
}
|
|
260
|
+
console.log(AS_JSON ? JSON.stringify(report, null, 2) : formatVerification(report))
|
|
261
|
+
if (report.result === 'mismatch') return 1
|
|
262
|
+
return report.result === 'partial' && values['require-all'] ? 1 : 0
|
|
263
|
+
}
|
|
264
|
+
const verifyOnly = ['feeds-dir', 'commit', 'run-url', 'package', 'source-commit', 'version', 'require-all'].find(option => values[option] !== undefined)
|
|
265
|
+
if (verifyOnly) {
|
|
266
|
+
console.error(`--${verifyOnly} is only supported by the verify command`)
|
|
267
|
+
return 2
|
|
268
|
+
}
|
|
269
|
+
|
|
196
270
|
if (command === 'apply') {
|
|
197
271
|
if (!PLAN_FILE || positionals.length) {
|
|
198
272
|
console.error('apply requires --plan plan.json and accepts no target paths')
|