model-eol 0.4.1 → 0.5.1

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/bot/bot.mjs CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  sha256,
23
23
  stableJson,
24
24
  } from './lib/common.mjs'
25
- import { validatePlanItems } from '../lib/apply.mjs'
25
+ import { assertValidPlanDocument } from '../lib/validate-document.mjs'
26
26
  import { parseCliArgs } from '../lib/cli.mjs'
27
27
  import { loadConfig } from './lib/config.mjs'
28
28
  import { downloadFeeds } from './lib/feeds.mjs'
@@ -33,11 +33,15 @@ import {
33
33
  commitAll,
34
34
  configureIdentity,
35
35
  defaultBranch,
36
+ deleteRemoteBranch,
36
37
  gitAuthentication,
38
+ isCommitAvailable,
37
39
  originFor,
38
40
  prepareBranch,
39
41
  pushBranch,
42
+ restoreRemoteBranch,
40
43
  verifyBotBranch,
44
+ verifyRemoteBranchHead,
41
45
  } from './lib/git.mjs'
42
46
 
43
47
  const ROOT = path.resolve(import.meta.dirname, '..')
@@ -46,6 +50,7 @@ const VENDORED_FEEDS = path.join(ROOT, 'feeds')
46
50
  const PLAN_SCHEMA = 'model-eol.plan/0.1'
47
51
  const BOT_SCHEMA = 'model-eol.bot/0.1'
48
52
  const EVAL_SCHEMA = 'model-eol.eval/0.1'
53
+ const EVAL_FAILURE_CHANNEL = 'configured-eval'
49
54
  const EVAL_ARTIFACT_MAX_BYTES = 64 * 1024 * 1024
50
55
  const ISSUE_REASONS = new Set([
51
56
  'not-direct-api',
@@ -69,6 +74,8 @@ export const parseArgs = argv => {
69
74
  'target-dir': { type: 'string' },
70
75
  config: { type: 'string' },
71
76
  'dry-run': { type: 'boolean' },
77
+ // Retained only to emit an actionable migration error. Publication must
78
+ // never execute repository-owned eval code in its write-capable process.
72
79
  eval: { type: 'boolean' },
73
80
  'feeds-url': { type: 'string', multiple: true },
74
81
  help: { type: 'boolean', short: 'h' },
@@ -80,7 +87,7 @@ export const parseArgs = argv => {
80
87
  targetDir: values['target-dir'] ?? process.cwd(),
81
88
  configPath: values.config ?? null,
82
89
  dryRun: values['dry-run'] ?? false,
83
- evalEnabled: values.eval ?? false,
90
+ deprecatedInlineEval: values.eval ?? false,
84
91
  feedsUrls: [],
85
92
  help: values.help ?? false,
86
93
  }
@@ -120,14 +127,15 @@ export const parseEvaluateArgs = argv => {
120
127
  export const helpText = () => `model-eol bot
121
128
 
122
129
  Usage:
123
- node bot/bot.mjs [--repo owner/name] [--target-dir PATH] [--config PATH] [--dry-run] [--eval] [--feeds-url URL[,URL...]]
130
+ node bot/bot.mjs [--repo owner/name] [--target-dir PATH] [--config PATH] [--dry-run] [--feeds-url URL[,URL...]]
124
131
  node bot/bot.mjs evaluate --target-dir PATH --plan-file PATH --output-file PATH [--config PATH] [--feeds-url URL[,URL...]]
125
132
 
126
133
  Environment:
127
134
  GITHUB_TOKEN Required unless --dry-run; authenticates the API and HTTPS Git remotes.
128
135
  GITHUB_API_URL GitHub API root, default https://api.github.com.
129
136
  MODEL_EOL_TOKEN_KIND Set to github-token to add the checks warning to PRs.
130
- MODEL_EOL_EVAL_COMMAND Optional command override used by both evaluate and publish.
137
+ MODEL_EOL_EVAL_COMMAND Optional override executed by evaluate and digest-checked by publish.
138
+ MODEL_EOL_EVAL_RESULTS_FILE Commit-bound result manifest produced by evaluate.
131
139
  `
132
140
 
133
141
  const validateRepo = repo => {
@@ -167,11 +175,8 @@ const validatePlan = plan => {
167
175
  if (plan?.plan_schema !== PLAN_SCHEMA) {
168
176
  throw new Error(`refusing plan schema ${plan?.plan_schema ?? 'missing'}; expected ${PLAN_SCHEMA}`)
169
177
  }
170
- if (!Array.isArray(plan.items) || !Array.isArray(plan.issues)) {
171
- throw new Error('refusing malformed plan document: items and issues arrays are required')
172
- }
173
178
  try {
174
- validatePlanItems(plan.items)
179
+ assertValidPlanDocument(plan)
175
180
  } catch (error) {
176
181
  throw new Error(`refusing malformed plan document: ${error.message}`)
177
182
  }
@@ -199,57 +204,6 @@ const verifiedPlanArtifact = (file, generatedPlan) => {
199
204
  return artifact
200
205
  }
201
206
 
202
- const readEvalArtifact = (file, maxBytes, statusFile = null) => {
203
- if (!file && !statusFile) return null
204
- let exitCode = null
205
- let statusError = null
206
- if (!statusFile) {
207
- statusError = 'eval status artifact is missing'
208
- } else {
209
- try {
210
- const statusArtifact = readReportCapped(statusFile, 1024)
211
- if (statusArtifact.missing) statusError = 'eval status artifact is missing'
212
- else {
213
- const statusText = statusArtifact.report.trim()
214
- if (!/^-?\d+$/.test(statusText)) statusError = 'eval status artifact is malformed'
215
- else {
216
- const parsed = Number(statusText)
217
- if (!Number.isSafeInteger(parsed)) statusError = 'eval status artifact is outside the safe integer range'
218
- else exitCode = parsed
219
- }
220
- }
221
- } catch (error) {
222
- statusError = `eval status artifact is unreadable: ${error.message}`
223
- }
224
- }
225
- let artifact
226
- try {
227
- artifact = file ? readReportCapped(file, maxBytes) : { missing: true, report: null }
228
- } catch (error) {
229
- return {
230
- status: 'fail',
231
- exit_code: exitCode,
232
- report: `eval report artifact is unreadable: ${error.message}`,
233
- }
234
- }
235
- if (statusError) {
236
- return {
237
- status: 'fail',
238
- exit_code: exitCode,
239
- report: artifact.report ? `${statusError}; report: ${artifact.report}` : statusError,
240
- }
241
- }
242
- if (artifact.missing) {
243
- return {
244
- status: 'fail',
245
- exit_code: exitCode,
246
- report: exitCode === 0 ? 'eval report artifact is missing' : null,
247
- }
248
- }
249
- if (artifact.report === '' && exitCode === 0) return null
250
- return { status: exitCode === 0 ? 'pass' : 'fail', exit_code: exitCode, report: artifact.report || null }
251
- }
252
-
253
207
  const withoutGenerated = plan => {
254
208
  const { generated, ...stable } = plan
255
209
  return stable
@@ -318,6 +272,20 @@ const readEvalResults = ({ file, config, commandOverride, plan, groups, baseHead
318
272
  return results
319
273
  }
320
274
 
275
+ const boundEvalResults = ({ file, config, commandOverride, plan, groups, baseHead }) => {
276
+ const settings = effectiveEval(config, commandOverride)
277
+ if (groups.length && settings.command && !file) {
278
+ throw new Error('configured eval requires MODEL_EOL_EVAL_RESULTS_FILE from `model-eol-bot evaluate`; publication never executes eval.command')
279
+ }
280
+ const results = file
281
+ ? readEvalResults({ file, config, commandOverride, plan, groups, baseHead })
282
+ : null
283
+ return {
284
+ results,
285
+ configDigest: results && settings.command ? evalConfigDigest(settings) : null,
286
+ }
287
+ }
288
+
321
289
  const feedContext = feedsDir => {
322
290
  const records = new Map()
323
291
  let files = []
@@ -431,6 +399,34 @@ const buildIssueGroups = (plan, config, root, records) => {
431
399
  return [...groups.values()].sort((a, b) => `${a.publisher}/${a.subject}/${a.shutdown ?? ''}`.localeCompare(`${b.publisher}/${b.subject}/${b.shutdown ?? ''}`))
432
400
  }
433
401
 
402
+ const evalIssueGroupFor = (group, evalResult, root) => ({
403
+ kind: 'issue',
404
+ id: group.id,
405
+ subject: group.id,
406
+ channel: EVAL_FAILURE_CHANNEL,
407
+ publisher: group.publisher,
408
+ shutdown: group.items[0]?.shutdown ?? null,
409
+ via: group.via ?? null,
410
+ issues: group.items.map(item => ({
411
+ file: item.file,
412
+ line: item.line,
413
+ matched: item.matched,
414
+ id: item.id,
415
+ publisher: item.publisher,
416
+ status: item.status,
417
+ shutdown: item.shutdown,
418
+ requested_via: group.via ?? null,
419
+ replacement: item.replacement,
420
+ reason: 'eval-failed',
421
+ sources: item.sources ?? [],
422
+ notes: item.notes ?? null,
423
+ })),
424
+ root,
425
+ context: group.context,
426
+ evalResult,
427
+ feedDigest: sha256(stableJson({ feed_digest: group.feedDigest, eval_result: evalResult })),
428
+ })
429
+
434
430
  const sourcesFor = group => {
435
431
  const values = group.kind === 'model'
436
432
  ? group.items.flatMap(item => item.sources ?? [])
@@ -484,7 +480,7 @@ const replacementSection = (item, now) => [
484
480
  : []),
485
481
  ]
486
482
 
487
- export const buildPullBody = ({ group, headSha, baseSha = null, now = new Date(), tokenKind = null, evalResult = null }) => {
483
+ export const buildPullBody = ({ group, headSha, baseSha = null, now = new Date(), tokenKind = null, evalResult = null, evalConfigHash = null }) => {
488
484
  const item = group.items[0]
489
485
  const announced = markdownText(group.context?.announced ?? 'not specified')
490
486
  const days = daysRemaining(item.shutdown, now)
@@ -500,6 +496,7 @@ export const buildPullBody = ({ group, headSha, baseSha = null, now = new Date()
500
496
  base_sha: baseSha,
501
497
  head_sha: headSha,
502
498
  feed_digest: group.feedDigest,
499
+ ...(evalConfigHash ? { eval_config_digest: evalConfigHash } : {}),
503
500
  }
504
501
  const sections = [
505
502
  metadataLine(metadata),
@@ -551,7 +548,7 @@ export const buildIssueBody = ({ group, now = new Date() }) => {
551
548
  const evidence = group.issues
552
549
  .map(issue => `- ${markdownText(`${repoPath(issue.file, group.root || '.')}:${issue.line} - ${issue.reason}${issue.matched ? ` (${issue.matched})` : ''}`)}`)
553
550
  .join('\n')
554
- return [
551
+ const sections = [
555
552
  metadataLine(metadata),
556
553
  '',
557
554
  '## Finding',
@@ -578,7 +575,10 @@ export const buildIssueBody = ({ group, now = new Date() }) => {
578
575
  '',
579
576
  '## Feed notes',
580
577
  notesSection(group),
581
- ].join('\n')
578
+ ]
579
+ const evaluation = evalSection(group.evalResult)
580
+ if (evaluation) sections.push('', evaluation)
581
+ return sections.join('\n')
582
582
  }
583
583
 
584
584
  const pullTitle = group => `model-eol: migrate ${group.id} before ${group.items[0].shutdown}`
@@ -618,7 +618,10 @@ const matchingIssues = (issues, group) => issues
618
618
  if (record.metadata.id !== (group.id || group.subject)) return false
619
619
  if (record.metadata.publisher !== group.publisher) return false
620
620
  if ((record.metadata.via ?? null) !== (group.via ?? null)) return false
621
- return !group.channel || record.metadata.channel === group.channel || (record.metadata.id === group.channel && !record.metadata.channel)
621
+ const expectedChannel = group.channel ?? null
622
+ const actualChannel = record.metadata.channel ?? null
623
+ if (actualChannel === expectedChannel) return true
624
+ return expectedChannel !== null && actualChannel === null && record.metadata.id === expectedChannel
622
625
  })
623
626
 
624
627
  const modelIdentity = (publisher, id, via) => `${publisher}\0${id}\0${via ?? ''}`
@@ -656,10 +659,12 @@ const groupFromMetadata = (kind, metadata) => ({
656
659
 
657
660
  const reconcileStaleWork = async ({ api, pulls, issues, models, issueGroups }) => {
658
661
  const activeModels = new Set(models.map(group => modelIdentity(group.publisher, group.id, group.via)))
659
- const activeIssues = new Set(issueGroups.flatMap(group => [
660
- issueIdentity(group.publisher, group.id || group.subject, group.via, group.channel),
661
- issueIdentity(group.publisher, group.id || group.subject, group.via, null),
662
- ]))
662
+ const activeIssues = new Set(issueGroups.flatMap(group => {
663
+ const exact = issueIdentity(group.publisher, group.id || group.subject, group.via, group.channel)
664
+ return group.channel === EVAL_FAILURE_CHANNEL
665
+ ? [exact]
666
+ : [exact, issueIdentity(group.publisher, group.id || group.subject, group.via, null)]
667
+ }))
663
668
  const decisions = []
664
669
  for (const record of ownedPullRecords(pulls, api.repo)) {
665
670
  if (!isOpen(record.item)) continue
@@ -676,7 +681,8 @@ const reconcileStaleWork = async ({ api, pulls, issues, models, issueGroups }) =
676
681
  if (!isOpen(record.item)) continue
677
682
  const key = issueIdentity(record.metadata.publisher, record.metadata.id, record.metadata.via, record.metadata.channel)
678
683
  const legacyKey = issueIdentity(record.metadata.publisher, record.metadata.id, record.metadata.via, null)
679
- if (activeIssues.has(key) || activeIssues.has(legacyKey)) continue
684
+ const evalFailure = record.metadata.channel === EVAL_FAILURE_CHANNEL
685
+ if (activeIssues.has(key) || (!evalFailure && activeIssues.has(legacyKey))) continue
680
686
  await api.comment(record.item.number, staleWorkComment('issue'))
681
687
  await api.updateIssue(record.item.number, {
682
688
  state: 'closed',
@@ -780,7 +786,11 @@ export const evaluatePlan = async ({
780
786
  warn,
781
787
  })
782
788
  try {
789
+ const baseHead = gitHead(targetPath)
783
790
  const generatedPlan = runPlan({ workDir: targetPath, config, configPath: planConfigPath, feedsDir: feedSet.dir, checkerPath, warn })
791
+ if (gitHead(targetPath) !== baseHead) {
792
+ throw new Error('refusing eval: target commit changed while generating the migration plan')
793
+ }
784
794
  const plan = verifiedPlanArtifact(planFile, generatedPlan)
785
795
  const groups = buildModelGroups(plan, config, targetPath, feedContext(feedSet.dir))
786
796
  const results = []
@@ -795,6 +805,9 @@ export const evaluatePlan = async ({
795
805
  let result
796
806
  try {
797
807
  cloneRepository(targetPath, clone)
808
+ if (gitHead(clone) !== baseHead) {
809
+ throw new Error(`refusing eval: isolated checkout for ${group.publisher}/${group.id} does not match the captured base commit`)
810
+ }
798
811
  const applied = spawnSync(process.execPath, [checkerPath, 'apply', '--plan', selectedPlanPath], {
799
812
  cwd: clone,
800
813
  encoding: 'utf8',
@@ -849,10 +862,13 @@ export const evaluatePlan = async ({
849
862
  })
850
863
  }
851
864
  }
865
+ if (gitHead(targetPath) !== baseHead) {
866
+ throw new Error('refusing eval: target commit changed during migration evaluation')
867
+ }
852
868
  const artifact = {
853
869
  schema: EVAL_SCHEMA,
854
870
  generated: now.toISOString(),
855
- base_sha: gitHead(targetPath),
871
+ base_sha: baseHead,
856
872
  plan_digest: planDigest(plan),
857
873
  eval_config_digest: evalConfigDigest(settings),
858
874
  configured: Boolean(settings.command),
@@ -865,18 +881,19 @@ export const evaluatePlan = async ({
865
881
  }
866
882
  }
867
883
 
868
- const makePatch = ({ source, base, branch, expectedHead, allowMissingBranch = false, group, plan, config, checkerPath, evalEnabled, root, warn, gitAuth }) => {
884
+ const makePatch = ({ source, base, expectedBaseHead, branch, expectedHead, allowMissingBranch = false, group, plan, checkerPath, gitAuth }) => {
869
885
  const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-bot-work-'))
870
886
  const planRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-plan-'))
871
887
  const clone = path.join(workRoot, 'repo')
872
- const fullPlanPath = path.join(planRoot, 'plan.json')
873
888
  const selectedPlanPath = path.join(planRoot, 'selected-plan.json')
874
- const reportPath = path.join(planRoot, 'eval-report.md')
875
- writeJson(fullPlanPath, plan)
876
889
  writeJson(selectedPlanPath, { ...plan, items: group.items, issues: [] })
877
890
  try {
878
891
  cloneRepository(source, clone, gitAuth)
879
892
  prepareBranch(clone, branch, base, gitAuth)
893
+ const preparedBaseHead = gitHead(clone)
894
+ if (preparedBaseHead !== expectedBaseHead) {
895
+ throw new Error(`refusing patch: prepared base commit ${preparedBaseHead} does not match evaluated base commit ${expectedBaseHead}`)
896
+ }
880
897
  const applied = spawnSync(process.execPath, [checkerPath, 'apply', '--plan', selectedPlanPath], {
881
898
  cwd: clone,
882
899
  encoding: 'utf8',
@@ -886,51 +903,12 @@ const makePatch = ({ source, base, branch, expectedHead, allowMissingBranch = fa
886
903
  throw new Error(`apply subprocess failed: ${applied.error?.message || applied.stderr?.trim() || `exit ${applied.status}`}`)
887
904
  }
888
905
  const planFiles = [...new Set(group.items.map(item => repoPath(item.file, clone)))]
889
- const postApplyHashes = hashFiles(clone, planFiles)
890
- const postApplyHead = gitHead(clone)
891
-
892
- let evalResult = null
893
- if (evalEnabled && config.eval.command) {
894
- try {
895
- evalResult = runEvalHook({
896
- command: config.eval.command,
897
- timeoutMs: config.eval.timeout_ms,
898
- maxReportBytes: config.eval.max_report_bytes,
899
- passEnv: config.eval.pass_env,
900
- cwd: clone,
901
- oldId: group.id,
902
- newId: group.items[0].replacement,
903
- planPath: fullPlanPath,
904
- reportPath,
905
- })
906
- } catch (error) {
907
- warn(`model-eol: warning: eval runner failed for ${group.id}: ${error.message}`)
908
- evalResult = { status: 'fail', exit_code: null, report: `eval runner error: ${error.message}` }
909
- }
910
- }
911
- if (evalResult) {
912
- const drift = evalWorkspaceDrift(clone, planFiles, postApplyHashes, postApplyHead)
913
- if (drift) {
914
- evalResult = {
915
- ...evalResult,
916
- status: 'fail',
917
- report: capReport([evalResult.report, drift].filter(Boolean).join('\n'), config.eval.max_report_bytes),
918
- }
919
- }
920
- }
921
- if (evalResult && evalResult.status !== 'pass') {
922
- const error = new Error(`eval hook did not pass: ${evalResult.status}`)
923
- error.code = 'MODEL_EOL_EVAL_FAILED'
924
- error.evalResult = evalResult
925
- throw error
926
- }
927
-
928
906
  configureIdentity(clone)
929
907
  const files = planFiles
930
908
  const message = `model-eol: migrate ${group.id} to ${group.items[0].replacement} (${group.feedDigest.slice(0, 8)})`
931
909
  const headSha = commitAll(clone, files, message)
932
910
  pushBranch(clone, branch, expectedHead, gitAuth, { allowMissing: allowMissingBranch })
933
- return { headSha, evalResult }
911
+ return { headSha }
934
912
  } finally {
935
913
  fs.rmSync(workRoot, { recursive: true, force: true })
936
914
  fs.rmSync(planRoot, { recursive: true, force: true })
@@ -939,7 +917,148 @@ const makePatch = ({ source, base, branch, expectedHead, allowMissingBranch = fa
939
917
 
940
918
  const decision = (group, action, extra = {}) => ({ group, action, ...extra })
941
919
 
942
- const processModel = async ({ api, pulls, group, source, base, baseHead, plan, config, checkerPath, evalEnabled, externalEval, root, now, tokenKind, warn, gitAuth }) => {
920
+ const publicationBaseDecision = ({ group, root, base, baseHead, gitAuth, number = undefined }) => {
921
+ const state = verifyRemoteBranchHead(root, base, baseHead, gitAuth)
922
+ if (state.safe) return null
923
+ return decision(group, 'stand-down', {
924
+ ...(number === undefined ? {} : { number }),
925
+ reason: state.error ? 'default-branch-unverifiable' : 'default-branch-moved',
926
+ expectedBaseHead: baseHead,
927
+ currentBaseHead: state.head,
928
+ })
929
+ }
930
+
931
+ const staleBodyFor = body => {
932
+ const metadata = parseMetadata(body)
933
+ if (!metadata) throw new Error('refusing to close bot pull request with malformed generated metadata')
934
+ return staleClosedBody(body, metadata)
935
+ }
936
+
937
+ const cleanupPushedBranchAndRethrow = ({ error, root, branch, headSha, gitAuth, context, relatedErrors = [] }) => {
938
+ try {
939
+ deleteRemoteBranch(root, branch, headSha, gitAuth)
940
+ } catch (cleanupError) {
941
+ throw new AggregateError(
942
+ [error, ...relatedErrors, cleanupError],
943
+ `${context} and exact-head cleanup of ${branch} also failed`,
944
+ )
945
+ }
946
+ if (relatedErrors.length) {
947
+ throw new AggregateError(
948
+ [error, ...relatedErrors],
949
+ `${context}; exact-head cleanup succeeded after pull-request state verification also failed`,
950
+ )
951
+ }
952
+ throw error
953
+ }
954
+
955
+ const rollbackPushedBranchAndRethrow = ({ error, root, branch, headSha, restoreHead, gitAuth, context }) => {
956
+ try {
957
+ restoreRemoteBranch(root, branch, headSha, restoreHead, gitAuth)
958
+ } catch (rollbackError) {
959
+ throw new AggregateError(
960
+ [error, rollbackError],
961
+ `${context} and exact-head rollback of ${branch} also failed`,
962
+ )
963
+ }
964
+ throw error
965
+ }
966
+
967
+ const recoverFailedPullUpdate = async ({ api, open, priorBody, error, root, group, headSha, restoreHead, rollbackTrusted, gitAuth, context }) => {
968
+ let current = null
969
+ let readError = null
970
+ try {
971
+ current = await api.getPull(open.item.number)
972
+ } catch (failure) {
973
+ readError = failure
974
+ }
975
+ const metadata = parseMetadata(current?.body)
976
+ const priorBodyProven = rollbackTrusted
977
+ && current?.number === open.item.number
978
+ && isOpen(current)
979
+ && hasModelEolLabel(current)
980
+ && current.head?.ref === group.branch
981
+ && current.head?.repo?.full_name === api.repo
982
+ && current.body === priorBody
983
+ && metadata?.head_sha === restoreHead
984
+ if (priorBodyProven) {
985
+ rollbackPushedBranchAndRethrow({
986
+ error,
987
+ root,
988
+ branch: group.branch,
989
+ headSha,
990
+ restoreHead,
991
+ gitAuth,
992
+ context,
993
+ })
994
+ }
995
+ cleanupPushedBranchAndRethrow({
996
+ error,
997
+ root,
998
+ branch: group.branch,
999
+ headSha,
1000
+ gitAuth,
1001
+ context: `${context} with ambiguous or changed pull-request state`,
1002
+ relatedErrors: readError ? [readError] : [],
1003
+ })
1004
+ }
1005
+
1006
+ const closePullAtNewHead = async ({ api, open, body, root, branch, headSha, gitAuth }) => {
1007
+ const closedBody = staleBodyFor(body)
1008
+ try {
1009
+ await api.updatePull(open.item.number, { state: 'closed', body: closedBody })
1010
+ } catch (error) {
1011
+ cleanupPushedBranchAndRethrow({
1012
+ error,
1013
+ root,
1014
+ branch,
1015
+ headSha,
1016
+ gitAuth,
1017
+ context: `pull request #${open.item.number} closure failed`,
1018
+ })
1019
+ }
1020
+ return closedBody
1021
+ }
1022
+
1023
+ const publicationBaseStandDownComment = (baseDecision, headSha) => baseDecision.reason === 'default-branch-unverifiable'
1024
+ ? `model-eol is leaving this pull request closed because it could not re-verify the default branch after pushing evaluated migration head ${markdownCode(headSha)}. The new head is recorded as automated stale work and must be regenerated from a freshly evaluated base before reopening.`
1025
+ : `model-eol is leaving this pull request closed because the default branch moved from evaluated commit ${markdownCode(baseDecision.expectedBaseHead)} to ${markdownCode(baseDecision.currentBaseHead || 'missing')} after pushing migration head ${markdownCode(headSha)}. The new head is recorded as automated stale work and must be reevaluated before reopening.`
1026
+
1027
+ const closedBaseStandDownDecision = async ({ api, open, baseDecision, closedBody, headSha }) => {
1028
+ await api.comment(open.item.number, publicationBaseStandDownComment(baseDecision, headSha))
1029
+ return {
1030
+ ...baseDecision,
1031
+ number: open.item.number,
1032
+ closedPullNumber: open.item.number,
1033
+ body: closedBody,
1034
+ headSha,
1035
+ }
1036
+ }
1037
+
1038
+ const closePullForEvalFailure = async ({ api, open, evalResult }) => {
1039
+ await api.comment(open.item.number, `model-eol is closing this bot-owned pull request because its configured migration eval no longer passes (${markdownCode(evalResult.status)}). The migration remains blocked and can be regenerated after the eval clears.`)
1040
+ await api.updatePull(open.item.number, {
1041
+ state: 'closed',
1042
+ body: staleClosedBody(open.item.body, open.metadata),
1043
+ })
1044
+ }
1045
+
1046
+ const processEvalFailure = async ({ api, issueRecords, group, evalResult, root, now, issuesEnabled, open = null }) => {
1047
+ const evalIssueGroup = evalIssueGroupFor(group, evalResult, root)
1048
+ const issueDecision = issuesEnabled
1049
+ ? await processIssue({ api, issues: issueRecords, group: evalIssueGroup, now })
1050
+ : null
1051
+ if (open) await closePullForEvalFailure({ api, open, evalResult })
1052
+ return decision(group, 'eval-failed', {
1053
+ evalResult,
1054
+ evalIssueGroup: issuesEnabled ? evalIssueGroup : null,
1055
+ issueAction: issueDecision?.action ?? null,
1056
+ issueNumber: issueDecision?.number ?? null,
1057
+ closedPullNumber: open?.item.number ?? null,
1058
+ })
1059
+ }
1060
+
1061
+ const processModel = async ({ api, pulls, issueRecords, group, source, base, baseHead, plan, config, checkerPath, externalEval, evalConfigHash, root, now, tokenKind, gitAuth }) => {
943
1062
  const matches = matchingPulls(pulls, group, api.repo)
944
1063
  const conflict = matches.find(record => record.conflict)
945
1064
  if (conflict) return decision(group, 'conflict', { number: conflict.item.number })
@@ -956,9 +1075,9 @@ const processModel = async ({ api, pulls, group, source, base, baseHead, plan, c
956
1075
  return decision(group, 'stand-down', { number: open.item.number })
957
1076
  }
958
1077
  if (externalEval && externalEval.status !== 'pass') {
959
- return decision(group, 'eval-failed', { number: open.item.number, evalResult: externalEval })
1078
+ return processEvalFailure({ api, issueRecords, group, evalResult: externalEval, root, now, issuesEnabled: config.issues.enabled, open })
960
1079
  }
961
- if (open.metadata.feed_digest === currentDigest && open.metadata.base_sha === baseHead) {
1080
+ if (open.metadata.feed_digest === currentDigest && open.metadata.base_sha === baseHead && (!evalConfigHash || open.metadata.eval_config_digest === evalConfigHash)) {
962
1081
  return decision(group, 'skip-unchanged', { number: open.item.number })
963
1082
  }
964
1083
  let patch
@@ -966,36 +1085,48 @@ const processModel = async ({ api, pulls, group, source, base, baseHead, plan, c
966
1085
  patch = makePatch({
967
1086
  source,
968
1087
  base,
1088
+ expectedBaseHead: baseHead,
969
1089
  branch: open.item.head?.ref || group.branch,
970
1090
  expectedHead: open.metadata.head_sha,
971
1091
  group,
972
1092
  plan,
973
- config,
974
1093
  checkerPath,
975
- evalEnabled,
976
- root,
977
- warn,
978
1094
  gitAuth,
979
1095
  })
980
1096
  } catch (error) {
981
- if (error.code === 'MODEL_EOL_EVAL_FAILED') {
982
- return decision(group, 'eval-failed', { number: open.item.number, evalResult: error.evalResult })
983
- }
984
1097
  if (error.code !== 'MODEL_EOL_BRANCH_STAND_DOWN') throw error
985
1098
  await api.comment(open.item.number, standingDownComment(group, open.metadata, error.currentHead))
986
1099
  return decision(group, 'stand-down', { number: open.item.number })
987
1100
  }
988
- if (patch.evalResult && patch.evalResult.status !== 'pass') {
989
- return decision(group, 'eval-failed', { number: open.item.number, evalResult: patch.evalResult })
1101
+ const body = buildPullBody({ group, headSha: patch.headSha, baseSha: baseHead, now, tokenKind, evalResult: externalEval, evalConfigHash })
1102
+ const baseDecision = publicationBaseDecision({ group, root, base, baseHead, gitAuth, number: open.item.number })
1103
+ if (baseDecision) {
1104
+ const closedBody = await closePullAtNewHead({ api, open, body, root, branch: group.branch, headSha: patch.headSha, gitAuth })
1105
+ return closedBaseStandDownDecision({ api, open, baseDecision, closedBody, headSha: patch.headSha })
1106
+ }
1107
+ const priorBody = open.item.body
1108
+ try {
1109
+ await api.updatePull(open.item.number, { title: pullTitle(group), body })
1110
+ } catch (error) {
1111
+ await recoverFailedPullUpdate({
1112
+ api,
1113
+ open,
1114
+ priorBody,
1115
+ error,
1116
+ root,
1117
+ group,
1118
+ headSha: patch.headSha,
1119
+ restoreHead: open.metadata.head_sha,
1120
+ rollbackTrusted: true,
1121
+ gitAuth,
1122
+ context: `pull request #${open.item.number} update failed`,
1123
+ })
990
1124
  }
991
- if (!patch.evalResult && externalEval) patch.evalResult = externalEval
992
- const body = buildPullBody({ group, headSha: patch.headSha, baseSha: baseHead, now, tokenKind, evalResult: patch.evalResult })
993
- await api.updatePull(open.item.number, { title: pullTitle(group), body })
994
1125
  return decision(group, 'update', { number: open.item.number, body, headSha: patch.headSha })
995
1126
  }
996
1127
 
997
1128
  if (externalEval && externalEval.status !== 'pass') {
998
- return decision(group, 'eval-failed', { evalResult: externalEval })
1129
+ return processEvalFailure({ api, issueRecords, group, evalResult: externalEval, root, now, issuesEnabled: config.issues.enabled })
999
1130
  }
1000
1131
 
1001
1132
  const replacement = group.items[0].replacement
@@ -1004,6 +1135,11 @@ const processModel = async ({ api, pulls, group, source, base, baseHead, plan, c
1004
1135
 
1005
1136
  const previous = matches.find(record => record.metadata?.head_sha) ?? null
1006
1137
  const previousBotHead = previous?.metadata.head_sha ?? null
1138
+ const previousRollbackHead = previousBotHead
1139
+ && verifyBotBranch(root, group.branch, previousBotHead, gitAuth).safe
1140
+ && isCommitAvailable(root, previousBotHead)
1141
+ ? previousBotHead
1142
+ : null
1007
1143
  if (!previousBotHead) {
1008
1144
  const occupied = verifyBotBranch(root, group.branch, null, gitAuth)
1009
1145
  if (occupied.error) throw new Error(occupied.error)
@@ -1017,51 +1153,80 @@ const processModel = async ({ api, pulls, group, source, base, baseHead, plan, c
1017
1153
  patch = makePatch({
1018
1154
  source,
1019
1155
  base,
1156
+ expectedBaseHead: baseHead,
1020
1157
  branch: group.branch,
1021
1158
  expectedHead: previousBotHead,
1022
1159
  allowMissingBranch: Boolean(previousBotHead && !isOpen(previous.item)),
1023
1160
  group,
1024
1161
  plan,
1025
- config,
1026
1162
  checkerPath,
1027
- evalEnabled,
1028
- root,
1029
- warn,
1030
1163
  gitAuth,
1031
1164
  })
1032
1165
  } catch (error) {
1033
- if (error.code === 'MODEL_EOL_EVAL_FAILED') {
1034
- return decision(group, 'eval-failed', { evalResult: error.evalResult })
1035
- }
1036
1166
  if (error.code !== 'MODEL_EOL_BRANCH_STAND_DOWN') throw error
1037
1167
  const prior = matches.find(record => record.metadata?.head_sha === previousBotHead)
1038
1168
  if (prior) await api.comment(prior.item.number, standingDownComment(group, prior.metadata, error.currentHead))
1039
1169
  return decision(group, 'stand-down', { number: prior?.item.number })
1040
1170
  }
1041
- if (patch.evalResult && patch.evalResult.status !== 'pass') {
1042
- return decision(group, 'eval-failed', { evalResult: patch.evalResult })
1043
- }
1044
- if (!patch.evalResult && externalEval) patch.evalResult = externalEval
1045
- const body = buildPullBody({ group, headSha: patch.headSha, baseSha: baseHead, now, tokenKind, evalResult: patch.evalResult })
1171
+ const body = buildPullBody({ group, headSha: patch.headSha, baseSha: baseHead, now, tokenKind, evalResult: externalEval, evalConfigHash })
1046
1172
  const latestPulls = await api.listPullsByHead(group.branch)
1047
1173
  const latestMatches = matchingPulls(latestPulls, group, api.repo)
1048
1174
  const latestConflict = latestMatches.find(record => record.conflict)
1049
1175
  if (latestConflict) return decision(group, 'conflict', { number: latestConflict.item.number })
1050
1176
  const latestOpen = latestMatches.find(record => isOpen(record.item))
1051
1177
  if (latestOpen) {
1052
- await api.updatePull(latestOpen.item.number, { title: pullTitle(group), body })
1178
+ const baseDecision = publicationBaseDecision({ group, root, base, baseHead, gitAuth, number: latestOpen.item.number })
1179
+ if (baseDecision) {
1180
+ const closedBody = await closePullAtNewHead({ api, open: latestOpen, body, root, branch: group.branch, headSha: patch.headSha, gitAuth })
1181
+ return closedBaseStandDownDecision({ api, open: latestOpen, baseDecision, closedBody, headSha: patch.headSha })
1182
+ }
1183
+ const priorBody = latestOpen.item.body
1184
+ try {
1185
+ await api.updatePull(latestOpen.item.number, { title: pullTitle(group), body })
1186
+ } catch (error) {
1187
+ await recoverFailedPullUpdate({
1188
+ api,
1189
+ open: latestOpen,
1190
+ priorBody,
1191
+ error,
1192
+ root,
1193
+ group,
1194
+ headSha: patch.headSha,
1195
+ restoreHead: previousRollbackHead,
1196
+ rollbackTrusted: Boolean(previousRollbackHead && latestOpen.metadata.head_sha === previousRollbackHead),
1197
+ gitAuth,
1198
+ context: `concurrent pull request #${latestOpen.item.number} update failed`,
1199
+ })
1200
+ }
1053
1201
  return decision(group, 'update', { number: latestOpen.item.number, body, headSha: patch.headSha })
1054
1202
  }
1055
1203
  const latestUntrusted = latestPulls.find(item => pullOnExpectedBranch(item, group, api.repo) && !latestMatches.some(record => record.item.number === item.number))
1056
1204
  if (latestUntrusted) return decision(group, 'conflict', { number: latestUntrusted.number })
1057
1205
  const latestSuppressed = latestMatches.find(record => !isOpen(record.item) && !isMerged(record.item) && record.metadata.stale_closed !== true && record.metadata.shutdown === group.items[0].shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
1058
1206
  if (latestSuppressed) return decision(group, 'skip-dismissed', { number: latestSuppressed.item.number })
1059
- const created = await api.createPull({
1060
- title: pullTitle(group),
1061
- head: group.branch,
1062
- base,
1063
- body,
1064
- })
1207
+ const baseDecision = publicationBaseDecision({ group, root, base, baseHead, gitAuth })
1208
+ if (baseDecision) {
1209
+ deleteRemoteBranch(root, group.branch, patch.headSha, gitAuth)
1210
+ return { ...baseDecision, headSha: patch.headSha, deletedBranch: group.branch }
1211
+ }
1212
+ let created
1213
+ try {
1214
+ created = await api.createPull({
1215
+ title: pullTitle(group),
1216
+ head: group.branch,
1217
+ base,
1218
+ body,
1219
+ })
1220
+ } catch (error) {
1221
+ cleanupPushedBranchAndRethrow({
1222
+ error,
1223
+ root,
1224
+ branch: group.branch,
1225
+ headSha: patch.headSha,
1226
+ gitAuth,
1227
+ context: 'pull request creation failed',
1228
+ })
1229
+ }
1065
1230
  return decision(group, 'create', { number: created.number, body, headSha: patch.headSha })
1066
1231
  }
1067
1232
 
@@ -1093,9 +1258,9 @@ const processIssue = async ({ api, issues, group, now }) => {
1093
1258
  return decision(group, 'create', { number: created.number, body })
1094
1259
  }
1095
1260
 
1096
- const dryDecision = (group, now, tokenKind) => group.kind === 'model'
1261
+ const dryDecision = (group, now, tokenKind, evalResult = null, evalConfigHash = null) => group.kind === 'model'
1097
1262
  ? decision(group, 'create', {
1098
- body: buildPullBody({ group, headSha: 'dry-run', now, tokenKind }),
1263
+ body: buildPullBody({ group, headSha: 'dry-run', now, tokenKind, evalResult, evalConfigHash }),
1099
1264
  branch: group.branch,
1100
1265
  })
1101
1266
  : decision(group, 'create', { body: buildIssueBody({ group, now }) })
@@ -1178,6 +1343,9 @@ export const runBot = async ({
1178
1343
  targetDir = process.cwd(),
1179
1344
  configPath = null,
1180
1345
  dryRun = false,
1346
+ deprecatedInlineEval = false,
1347
+ // Programmatic compatibility aliases are accepted only to fail with the
1348
+ // same migration guidance as the removed CLI/environment contracts.
1181
1349
  evalEnabled = false,
1182
1350
  feedsUrls = [],
1183
1351
  token = process.env.GITHUB_TOKEN,
@@ -1195,6 +1363,12 @@ export const runBot = async ({
1195
1363
  now = new Date(),
1196
1364
  warn = message => console.error(message),
1197
1365
  } = {}) => {
1366
+ if (deprecatedInlineEval || evalEnabled) {
1367
+ throw new Error('inline --eval was removed because publication is write-capable; run `model-eol-bot evaluate` in a read-only job and pass its manifest with MODEL_EOL_EVAL_RESULTS_FILE')
1368
+ }
1369
+ if (evalReportFile || evalStatusFile) {
1370
+ throw new Error('MODEL_EOL_EVAL_REPORT_FILE and MODEL_EOL_EVAL_STATUS_FILE were removed because they are not commit-bound; use MODEL_EOL_EVAL_RESULTS_FILE from `model-eol-bot evaluate`')
1371
+ }
1198
1372
  if (!dryRun) {
1199
1373
  validateRepo(repo)
1200
1374
  if (!token) throw new Error('GITHUB_TOKEN is required unless --dry-run is used')
@@ -1226,8 +1400,25 @@ export const runBot = async ({
1226
1400
  const records = feedContext(feedSet.dir)
1227
1401
  const models = buildModelGroups(plan, config, scan.path, records)
1228
1402
  const issues = config.issues.enabled ? buildIssueGroups(plan, config, scan.path, records) : []
1229
- const report = group => feedSet.degraded ? reportOnlyDecision(group, now, tokenKind) : dryDecision(group, now, tokenKind)
1230
- const decisions = [...models.map(report), ...issues.map(report)]
1403
+ if (feedSet.degraded) {
1404
+ const report = group => reportOnlyDecision(group, now, tokenKind)
1405
+ const decisions = [...models.map(report), ...issues.map(report)]
1406
+ return { plan, config, decisions, feedsDir: feedSet.dir, degraded: true }
1407
+ }
1408
+ const boundEval = boundEvalResults({
1409
+ file: evalResultsFile,
1410
+ config,
1411
+ commandOverride: evalCommandOverride,
1412
+ plan,
1413
+ groups: models,
1414
+ baseHead: evalResultsFile ? gitHead(scan.path) : null,
1415
+ })
1416
+ const decisions = models.map(group => {
1417
+ const evalResult = boundEval.results?.get(evalIdentity(group.publisher, group.id, group.via)) ?? null
1418
+ if (evalResult && evalResult.status !== 'pass') return decision(group, 'eval-failed', { evalResult })
1419
+ return dryDecision(group, now, tokenKind, evalResult, boundEval.configDigest)
1420
+ })
1421
+ decisions.push(...issues.map(group => dryDecision(group, now, tokenKind)))
1231
1422
  return { plan, config, decisions, feedsDir: feedSet.dir, degraded: Boolean(feedSet.degraded) }
1232
1423
  }
1233
1424
 
@@ -1257,27 +1448,29 @@ export const runBot = async ({
1257
1448
  const decisions = [...models.map(report), ...issues.map(report)]
1258
1449
  return { plan, config, decisions, feedsDir: feedSet.dir, degraded: true }
1259
1450
  }
1260
- const externalEvalResults = evalResultsFile
1261
- ? readEvalResults({ file: evalResultsFile, config, commandOverride: evalCommandOverride, plan, groups: models, baseHead })
1262
- : null
1263
- const legacyExternalEval = externalEvalResults ? null : readEvalArtifact(
1264
- evalReportFile ? asAbsolute(evalReportFile) : null,
1265
- config.eval.max_report_bytes,
1266
- evalStatusFile ? asAbsolute(evalStatusFile) : null,
1267
- )
1451
+ const boundEval = boundEvalResults({
1452
+ file: evalResultsFile,
1453
+ config,
1454
+ commandOverride: evalCommandOverride,
1455
+ plan,
1456
+ groups: models,
1457
+ baseHead,
1458
+ })
1268
1459
  const api = new GitHubClient({ repo, apiUrl, token, transport, warn })
1269
1460
  const labelReady = models.length || issues.length ? await api.ensureModelEolLabel() : true
1270
1461
  const pulls = await api.listPulls()
1271
1462
  const issueRecords = await api.listIssues()
1272
1463
  const decisions = []
1464
+ const evalIssueGroups = []
1273
1465
  if (!labelReady) {
1274
1466
  for (const group of [...models, ...issues]) decisions.push(decision(group, 'label-unavailable'))
1275
1467
  } else {
1276
1468
  for (const group of models) {
1277
- const externalEval = externalEvalResults?.get(evalIdentity(group.publisher, group.id, group.via)) ?? legacyExternalEval
1469
+ const externalEval = boundEval.results?.get(evalIdentity(group.publisher, group.id, group.via)) ?? null
1278
1470
  decisions.push(await processModel({
1279
1471
  api,
1280
1472
  pulls,
1473
+ issueRecords,
1281
1474
  group,
1282
1475
  source,
1283
1476
  base,
@@ -1285,18 +1478,21 @@ export const runBot = async ({
1285
1478
  plan,
1286
1479
  config,
1287
1480
  checkerPath,
1288
- evalEnabled,
1289
1481
  externalEval,
1482
+ evalConfigHash: boundEval.configDigest,
1290
1483
  root: baseClone,
1291
1484
  now,
1292
1485
  tokenKind,
1293
- warn,
1294
1486
  gitAuth,
1295
1487
  }))
1488
+ const latestDecision = decisions.at(-1)
1489
+ if (latestDecision?.evalIssueGroup) evalIssueGroups.push(latestDecision.evalIssueGroup)
1296
1490
  }
1297
1491
  for (const group of issues) decisions.push(await processIssue({ api, issues: issueRecords, group, now }))
1298
1492
  }
1299
- decisions.push(...await reconcileStaleWork({ api, pulls, issues: issueRecords, models, issueGroups: issues }))
1493
+ if (labelReady) {
1494
+ decisions.push(...await reconcileStaleWork({ api, pulls, issues: issueRecords, models, issueGroups: [...issues, ...evalIssueGroups] }))
1495
+ }
1300
1496
  return { plan, config, decisions, feedsDir: feedSet.dir, degraded: false }
1301
1497
  } finally {
1302
1498
  scan?.cleanup()
@@ -1334,7 +1530,7 @@ export const main = async (argv = process.argv.slice(2), env = process.env) => {
1334
1530
  targetDir: options.targetDir,
1335
1531
  configPath: options.configPath,
1336
1532
  dryRun: options.dryRun,
1337
- evalEnabled: options.evalEnabled,
1533
+ deprecatedInlineEval: options.deprecatedInlineEval,
1338
1534
  feedsUrls: options.feedsUrls,
1339
1535
  planFile: env.MODEL_EOL_PLAN_FILE || null,
1340
1536
  evalReportFile: env.MODEL_EOL_EVAL_REPORT_FILE || null,