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.
- package/README.md +44 -10
- package/check.mjs +74 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,17 +8,18 @@
|
|
|
8
8
|

|
|
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.
|
|
11
|
+
tooling that turns it into a Dependabot for models.** v0.2.3.
|
|
12
12
|
|
|
13
13
|

|
|
14
14
|
|
|
15
15
|
## The problem
|
|
16
16
|
|
|
17
|
-
On July 23, 2026, OpenAI shut down
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
|
119
|
-
`policy` floor, so the schedule can
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
+
return 2
|
|
118
125
|
}
|
|
119
126
|
try {
|
|
120
127
|
const result = applyPlan({ planPath: PLAN_FILE, dryRun: DRY_RUN, rootDir: process.cwd() })
|
|
121
|
-
|
|
128
|
+
return result.failed ? 1 : 0
|
|
122
129
|
} catch (e) {
|
|
123
130
|
console.error(`failed to apply plan ${PLAN_FILE}: ${e.message}`)
|
|
124
|
-
|
|
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
|
-
|
|
137
|
+
return 2
|
|
131
138
|
}
|
|
132
139
|
if (!['all', 'direct'].includes(SCOPE)) {
|
|
133
140
|
console.error('--scope must be all or direct')
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
+
return 2
|
|
155
162
|
}
|
|
156
163
|
if (feedData.entries.size === 0) {
|
|
157
164
|
console.error(`no feed entries loaded from ${FEEDS_DIR}`)
|
|
158
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
+
return payload.errors.length ? 1 : 0
|
|
260
266
|
}
|
|
261
267
|
|
|
262
268
|
console.error(`unknown command: ${command}`)
|
|
263
|
-
|
|
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)
|