model-eol 0.2.3 → 0.2.4

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.
Files changed (3) hide show
  1. package/README.md +44 -10
  2. package/check.mjs +74 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,17 +8,18 @@
8
8
  ![license](https://img.shields.io/badge/license-MIT-blue)
9
9
 
10
10
  **A machine-readable deprecation feed format for AI models, plus the reference
11
- tooling that turns it into a Dependabot for models.** v0.2.2.
11
+ tooling that turns it into a Dependabot for models.** v0.2.3.
12
12
 
13
13
  ![model-eol demo: retired models, distributor clocks, worst-case date](docs/img/demo.gif)
14
14
 
15
15
  ## The problem
16
16
 
17
- On July 23, 2026, OpenAI shut down 18 models on schedule. Teams that pinned dated
18
- snapshots - the provider's own recommended practice - got production 404s, because a
19
- model ID is the one dependency your toolchain can't see. When an npm package is
20
- deprecated, the warning prints at install, Dependabot opens the PR, the advisory
21
- fails CI. A model's retirement date lives in an HTML docs page and an email.
17
+ On July 23, 2026, OpenAI shut down 15 model snapshots on schedule. One of my
18
+ tuned research workflows returned a model-not-found error that morning even though
19
+ the retirement had been documented for months. A model ID is the one dependency
20
+ my toolchain could not see. When an npm package is deprecated, the warning prints
21
+ at install, Dependabot opens the PR, and an advisory can fail CI. A model's
22
+ retirement date usually lives in an HTML docs page and an email.
22
23
 
23
24
  Ordinary software solved this layer: endoflife.date for EOL data, OSV for
24
25
  vulnerability feeds. This is the model version - a shared schema first, tooling
@@ -100,6 +101,31 @@ PRs to trigger checks), and provider API keys enter the picture in exactly two
100
101
  optional places: your own eval hook command, and the feed-refresh models-endpoint
101
102
  coverage.
102
103
 
104
+ ## Use it as a GitHub Action
105
+
106
+ The same gate as a composite action - no npm install, feeds bundled, pinned to
107
+ the `v0` line:
108
+
109
+ ```yaml
110
+ - uses: actions/checkout@v4
111
+ with:
112
+ fetch-depth: 0 # --changed needs history to diff against the base ref
113
+ - uses: thossullivan/model-eol@v0
114
+ with:
115
+ command: check
116
+ changed: origin/${{ github.base_ref }}
117
+ days: 90
118
+ scope: direct
119
+ ```
120
+
121
+ That is the PR gate: fail only when *this* change adds a model that is retired
122
+ or retiring within the threshold. Add `via: aws-bedrock` (or `azure-ai-foundry`)
123
+ to judge by a distributor's clock. The copy-paste version with both gates - PR
124
+ diff plus a weekly full-repository check - is
125
+ [`examples/workflows/model-eol.yml`](examples/workflows/model-eol.yml); the
126
+ same inputs drive `inventory` and `schedule` if CI should export an ML-BOM or a
127
+ retirement calendar instead.
128
+
103
129
  ## Same weights, different clocks
104
130
 
105
131
  The same model retires on different dates per channel, and a checker that ignores
@@ -111,13 +137,13 @@ channel you actually call, and the bedrock distributor fetcher keeps the clocks
111
137
  current from AWS's own lifecycle page. Treat a distributor's later date as runway
112
138
  for the same migration, not as a destination.
113
139
 
114
- ## Policy floors - a forward guarantee from absence
140
+ ## Policy floors - a planning floor from absence
115
141
 
116
142
  A feed entry with no `announced` and no `shutdown` is an affirmative "nothing
117
143
  scheduled as of `generated`" - something scraping HTML can never say. Publishers
118
- with a stated minimum notice period (Anthropic commits to >=60 days) carry a
119
- `policy` floor, so the schedule can say `guaranteed until <date> per anthropic
120
- stated policy`. Stated policy, not a contract - the wording says so. OpenAI
144
+ with a stated minimum notice period (Anthropic states >=60 days) carry a
145
+ `policy` floor, so the schedule can calculate a no-earlier-than planning date
146
+ from the feed refresh. Stated policy, not a contract. OpenAI
121
147
  publishes no formal floor, so its feed makes no forward claim.
122
148
 
123
149
  ## Direct-first inventory
@@ -178,6 +204,14 @@ their keep: they caught the hand-compiled feeds drifting from Anthropic's
178
204
  recommendations within one week, and corrected a hand-compiled Bedrock date that
179
205
  was three months wrong.
180
206
 
207
+ The refresh also travels in the other direction. On August 3, Google removed the
208
+ previously listed October 16 earliest shutdown dates for the Gemini 2.5 Pro,
209
+ Flash, and Flash-Lite GA models. The pipeline opened
210
+ [a PR with the semantic retraction](https://github.com/thossullivan/model-eol/pull/60),
211
+ and merging it published v0.2.3 so downstream checks stopped warning from dates
212
+ Google no longer listed. A lifecycle tracker has to retract stale alarms as well
213
+ as add new ones.
214
+
181
215
  ## The actual ask
182
216
 
183
217
  The endgame is not "another checker"; it's the format. Providers already publish
package/check.mjs CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  } from './lib/reports.mjs'
35
35
  import { addedLinesForTargets, filterFindingsToChanged, incompleteScanNotes, scanTargets } from './lib/scanner.mjs'
36
36
 
37
+ const main = () => {
37
38
  const COMMANDS = new Set(['check', 'inventory', 'schedule', 'alert', 'plan', 'apply', 'help'])
38
39
  const args = process.argv.slice(2)
39
40
 
@@ -62,7 +63,7 @@ try {
62
63
  })
63
64
  } catch (error) {
64
65
  console.error(error.message)
65
- process.exit(2)
66
+ return 2
66
67
  }
67
68
 
68
69
  const { values, positionals } = parsed
@@ -107,43 +108,49 @@ Commands:
107
108
  Scopes:
108
109
  all Check every tracked model ID found. This preserves the original behavior.
109
110
  direct Check direct API and generic model references; leave cloud/gateway refs in inventory.
111
+
112
+ Exit codes:
113
+ 0 Clean, or report generated successfully.
114
+ 1 Findings, alert errors, or refused apply items.
115
+ 2 Usage, feed, scan, or plan errors.
116
+ 3 Output stream failure other than EPIPE.
110
117
  `)
111
- process.exit(0)
118
+ return 0
112
119
  }
113
120
 
114
121
  if (command === 'apply') {
115
122
  if (!PLAN_FILE || positionals.length) {
116
123
  console.error('apply requires --plan plan.json and accepts no target paths')
117
- process.exit(2)
124
+ return 2
118
125
  }
119
126
  try {
120
127
  const result = applyPlan({ planPath: PLAN_FILE, dryRun: DRY_RUN, rootDir: process.cwd() })
121
- process.exit(result.failed ? 1 : 0)
128
+ return result.failed ? 1 : 0
122
129
  } catch (e) {
123
130
  console.error(`failed to apply plan ${PLAN_FILE}: ${e.message}`)
124
- process.exit(2)
131
+ return 2
125
132
  }
126
133
  }
127
134
 
128
135
  if (!Number.isFinite(DAYS) || !Number.isInteger(DAYS) || DAYS < 0) {
129
136
  console.error('--days must be a finite non-negative integer')
130
- process.exit(2)
137
+ return 2
131
138
  }
132
139
  if (!['all', 'direct'].includes(SCOPE)) {
133
140
  console.error('--scope must be all or direct')
134
- process.exit(2)
141
+ return 2
135
142
  }
136
143
  if (CHANGED_BASE !== null && command !== 'check') {
137
144
  console.error('--changed is only supported by the check command')
138
- process.exit(2)
145
+ return 2
139
146
  }
140
147
  if (command === 'alert' && FORMAT !== null && !['github', 'markdown', 'badge', 'json'].includes(FORMAT)) {
141
148
  console.error('--format must be github, markdown, badge, or json for alert')
142
- process.exit(2)
149
+ return 2
143
150
  }
144
151
  if (command === 'inventory' && FORMAT !== null && !['json', 'cyclonedx', 'text'].includes(FORMAT)) {
145
152
  console.error('--format must be json, cyclonedx, or text for inventory')
146
- process.exit(2)
153
+ return 2
147
154
  }
148
155
 
149
156
  let feedData
@@ -151,11 +158,11 @@ try {
151
158
  feedData = loadFeeds(FEEDS_DIR)
152
159
  } catch (e) {
153
160
  console.error(`failed to load feeds from ${FEEDS_DIR}: ${e.message}`)
154
- process.exit(2)
161
+ return 2
155
162
  }
156
163
  if (feedData.entries.size === 0) {
157
164
  console.error(`no feed entries loaded from ${FEEDS_DIR}`)
158
- process.exit(2)
165
+ return 2
159
166
  }
160
167
 
161
168
  let scan
@@ -168,7 +175,7 @@ try {
168
175
  })
169
176
  } catch (e) {
170
177
  console.error(`scan failed: ${e.message}`)
171
- process.exit(2)
178
+ return 2
172
179
  }
173
180
 
174
181
  const incompleteNotes = incompleteScanNotes(scan.notes)
@@ -179,23 +186,22 @@ if (incompleteNotes.length && (command === 'check' || command === 'plan')) {
179
186
  const location = note.file ? ` (${note.file})` : ''
180
187
  console.error(` ${note.reason}${location}${note.message ? `: ${note.message}` : ''}`)
181
188
  }
182
- if (!ALLOW_INCOMPLETE) process.exit(2)
189
+ if (!ALLOW_INCOMPLETE) return 2
183
190
  }
184
191
 
185
192
  const findings = scan.modelRefs.map(ref => findingFromRef(ref, { days: DAYS, via: VIA }))
186
193
  const checkFindings = SCOPE === 'direct'
187
194
  ? findings.filter(f => f.usage === 'direct-api' || f.usage === 'model-reference')
188
195
  : findings
189
- const changedFindings = CHANGED_BASE === null
190
- ? checkFindings
191
- : (() => {
192
- try {
193
- return filterFindingsToChanged(checkFindings, addedLinesForTargets(targets, CHANGED_BASE))
194
- } catch (e) {
195
- console.error(`--changed failed: ${e.message}`)
196
- process.exit(2)
197
- }
198
- })()
196
+ let changedFindings = checkFindings
197
+ if (CHANGED_BASE !== null) {
198
+ try {
199
+ changedFindings = filterFindingsToChanged(checkFindings, addedLinesForTargets(targets, CHANGED_BASE))
200
+ } catch (e) {
201
+ console.error(`--changed failed: ${e.message}`)
202
+ return 2
203
+ }
204
+ }
199
205
  const bad = changedFindings.filter(isBad)
200
206
  const inventory = () => buildInventory({ scan, findings, days: DAYS, via: VIA, scope: SCOPE, targets })
201
207
  const schedule = () => buildSchedule(inventory())
@@ -210,7 +216,7 @@ if (command === 'plan') {
210
216
  via: VIA,
211
217
  scope: SCOPE,
212
218
  }), null, 2))
213
- process.exit(0)
219
+ return 0
214
220
  }
215
221
 
216
222
  if (command === 'check') {
@@ -219,7 +225,7 @@ if (command === 'check') {
219
225
  } else {
220
226
  console.log(formatCheck({ findings: changedFindings, bad, scannedFiles: scan.files.length, days: DAYS, scope: SCOPE }))
221
227
  }
222
- process.exit(bad.length ? 1 : 0)
228
+ return bad.length ? 1 : 0
223
229
  }
224
230
 
225
231
  if (command === 'inventory') {
@@ -232,7 +238,7 @@ if (command === 'inventory') {
232
238
  } else {
233
239
  console.log(formatInventory(inv, DAYS))
234
240
  }
235
- process.exit(0)
241
+ return 0
236
242
  }
237
243
 
238
244
  if (command === 'schedule') {
@@ -242,7 +248,7 @@ if (command === 'schedule') {
242
248
  } else {
243
249
  console.log(formatSchedule(sched, DAYS))
244
250
  }
245
- process.exit(0)
251
+ return 0
246
252
  }
247
253
 
248
254
  if (command === 'alert') {
@@ -256,8 +262,46 @@ if (command === 'alert') {
256
262
  } else {
257
263
  console.log(formatAlertGithub(payload, DAYS))
258
264
  }
259
- process.exit(payload.errors.length ? 1 : 0)
265
+ return payload.errors.length ? 1 : 0
260
266
  }
261
267
 
262
268
  console.error(`unknown command: ${command}`)
263
- process.exit(2)
269
+ return 2
270
+ }
271
+
272
+ const writeAndCapture = (stream, chunk) => new Promise(resolve => {
273
+ let error = null
274
+ let settling = false
275
+ const settle = nextError => {
276
+ if (nextError && !error) error = nextError
277
+ if (settling) return
278
+ settling = true
279
+ setImmediate(() => {
280
+ stream.off('error', onError)
281
+ resolve(error)
282
+ })
283
+ }
284
+ const onError = nextError => settle(nextError)
285
+ stream.on('error', onError)
286
+ try {
287
+ stream.write(chunk, settle)
288
+ } catch (writeError) {
289
+ settle(writeError)
290
+ }
291
+ })
292
+ const drainStream = stream => writeAndCapture(stream, '')
293
+
294
+ const exitCode = main()
295
+ const [stdoutError, stderrError] = await Promise.all([
296
+ drainStream(process.stdout),
297
+ drainStream(process.stderr),
298
+ ])
299
+ const outputError = [stdoutError, stderrError].find(error => error && error.code !== 'EPIPE')
300
+ let finalExitCode = exitCode
301
+ if (exitCode === 0 && outputError) {
302
+ finalExitCode = 3
303
+ const diagnosticStream = stderrError ? (stdoutError ? null : process.stdout) : process.stderr
304
+ const detail = String(outputError.code ?? outputError.message ?? 'unknown error').replace(/[\r\n]+/g, ' ')
305
+ if (diagnosticStream) await writeAndCapture(diagnosticStream, `model-eol: output failed: ${detail}\n`)
306
+ }
307
+ process.exit(finalExitCode)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "model-eol",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "A machine-readable deprecation feed format for AI models, plus a zero-dependency checker and inventory CLI.",
5
5
  "type": "module",
6
6
  "bin": {