Repository navigation
318 lines (309 loc) · 16.9 KB
/
Copy pathci.yml
File metadata and controls
318 lines (309 loc) · 16.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
name: ci
on:
push:
branches: [main]
tags-ignore: ["v*.*.*"]
pull_request:
workflow_dispatch:
# Nightly, so a vendor-side change is caught here rather than by a user's
# meter. The e2e suite is the only thing that talks to OpenCode, and the
# bundled catalog is the only thing that tracks models.dev — both are exactly
# the kind of contract that rots quietly between commits.
schedule:
- cron: "37 4 * * *"
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
name: check · test · build · gate
runs-on: ubuntu-latest
# A hung test otherwise burns the six-hour default. Everything here is
# offline and fast; twenty minutes is generous, not tight.
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# No `version:` input: pnpm/action-setup refuses to run when it is given
# both that and a `packageManager` field, and package.json pins the pnpm
# version exactly, in `packageManager`. One source of truth, and the
# action reads it.
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 26
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run check
- run: pnpm run test
# The zen/go compatibility matrix, offline: `test/mock/` boots a Vite
# server that answers the gateway's endpoints in their own wire formats
# and refuses a request whose shape its endpoint does not accept.
# Deterministic and secretless, so unlike the e2e job it gates every
# push and every PR.
- run: pnpm run test:mock
- run: pnpm run build
- run: node --experimental-strip-types scripts/check.ts
# Coverage as its own step, not folded into `test`: the ratchet only means
# something if it can fail a build. Uploaded so a PR can be diffed against
# `main` rather than only against the threshold.
- run: pnpm run test:coverage
- name: upload coverage report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: coverage
path: coverage
retention-days: 7
# `engines.node` promises >=24 and every other job runs 26, so the floor was a
# claim nothing exercised. The suites that depend on the engine (node:sqlite,
# Intl.DurationFormat and its ICU strings) are exactly the ones that differ
# between majors, so the floor gets its own offline run of the unit and mock
# suites. Raise this together with `engines.node`.
engine-floor:
name: test · node 24 (engines floor)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
# Shuffled here, and in order in the main job: the two runs together show a
# case that passes only because of what an earlier case left behind. The
# seed is printed, so a failure reproduces with `--sequence.seed=<n>`.
- run: pnpm run test:shuffle
- run: pnpm run test:mock
# The bundled catalog is generated from models.dev, and the one field that went
# stale before (`provider_npm`) put nine models on an endpoint that cannot
# serve them — a WRONG answer rather than a missing one, and invisible to the
# unit suite. Regenerating on a schedule turns the vendor's next change into a
# diff to review instead of a bug to diagnose.
#
# WHAT THIS GATES, and what it deliberately does not. The failure it exists
# for is an OMISSION: someone changes a generator - or the parser it calls -
# and forgets to regenerate, so the wrong `provider_npm` ships. That is a fact
# about a pull request's OWN diff, so the diff decides. On a pull request each
# of the three checks (`catalog:shim`, `limits:shim`, `cli:version`) GATES
# only when the diff touches one of that check's INPUTS: its script and every
# local module the script loads, transitively (a type-only import loads
# nothing), plus `src/identity.ts`, which `cli:version` reads as data.
# Otherwise it REPORTS - a warning, exit 0. One half of `cli:version` gates
# every pull request regardless: the offline scan that every copy of the CLI
# version in the tree agrees with the pin, which reads only the files the
# repository maintains (never the generated tables, which hold vendor data),
# so no vendor move can break it.
# `scripts/freshness-scope.ts` derives the inputs from the sources themselves
# and makes the call, so adding an import to a generator moves its inputs
# without anyone editing a list. The generated files are never inputs: a pull
# request that only carries a refresh reports drift rather than failing on it,
# and one that edits a generated file without touching its generator gets a
# warning naming the file, since a hand-edit is forbidden.
#
# Why a pull request is not gated on drift alone: the vendor's catalog, its Go
# limits page and its CLI version move most days, and each move used to turn
# every open pull request red although none of them caused it. Vendor drift
# is not a pull request's regression. The nightly run notices all three. For
# the two generated tables it regenerates them and proposes the refresh as a
# pull request of its own, which is where it is reviewed. For the CLI version
# it only warns: the bump is a human pull request to `src/identity.ts` (and
# its copies), which gates, because the pin is one of the check's inputs.
#
# The gates FAIL CLOSED. Each runs unless the scope step answered "report",
# so a lost answer (the step moved below its readers, an output renamed)
# falls back to gating every pull request, which is loud, rather than to
# running nothing, which is not.
#
# A push to main is the same question with the same answer. `main` was
# reviewed with a current shim; the vendor may have rotated rows in the
# minutes since. The runtime already refreshes the catalog on its own (SWR, 1h
# TTL), so a shim a few hours behind only affects a cold start - it is not a
# regression the push introduced. Failing there turned upstream churn into a
# red main several times in one afternoon, which is how a signal gets
# ignored. So on main it REPORTS (a warning, and the nightly proposes the
# refresh) rather than failing.
#
# Fork PRs run the same scoped checks: they read only public sources, and the
# job's write access is used by the nightly path alone, which a fork never
# triggers.
catalog:
name: catalog freshness
runs-on: ubuntu-latest
timeout-minutes: 10
# Write access is needed by the nightly path alone, which proposes the
# refresh as a pull request instead of failing. The gate steps never write.
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Two commits: on a pull request HEAD is the merge ref, whose first
# parent is the base, so `HEAD^1..HEAD` is exactly the PR's own diff.
# The default depth of one would leave `HEAD^1` missing.
fetch-depth: 2
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 26
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: which checks does this pull request gate? (pull requests)
id: scope
if: github.event_name == 'pull_request'
run: |
changed="$RUNNER_TEMP/changed-files"
git diff --name-only HEAD^1 HEAD > "$changed"
# Printed, so the decision below can be checked against what it read.
echo "this pull request changes $(wc -l < "$changed") file(s):"
cat "$changed"
# decide <output key> <check>: "gate" when the diff touches one of
# the check's inputs, "report" otherwise. Any warning the script
# raises (a generated file changed without its generator) goes to
# stderr, kept apart from the answer and printed after it.
decide() {
notes="$RUNNER_TEMP/scope-notes"
scope=$(node --experimental-strip-types --disable-warning=ExperimentalWarning \
scripts/freshness-scope.ts "$2" < "$changed" 2> "$notes") \
|| { cat "$notes"; exit 1; }
cat "$notes"
case "$scope" in
gate | report) ;;
*) echo "::error::freshness-scope answered '$scope' for $2"; exit 1 ;;
esac
echo "$2: $scope"
echo "$1=$scope" >> "$GITHUB_OUTPUT"
}
decide catalog catalog:shim
decide limits limits:shim
decide cli cli:version
- name: is src/catalog-data.ts current? (pull requests touching its generator)
if: github.event_name == 'pull_request' && steps.scope.outputs.catalog != 'report'
run: |
# Exits 1 on a stale shim and prints what would change. This pull
# request changed what generates the shim, so a forgotten
# regeneration is its own omission, and still fixable: a gate.
pnpm run catalog:shim || {
echo "::error::src/catalog-data.ts is out of date — run 'pnpm run catalog:shim -- --write' and commit the result."
exit 1
}
- name: report a stale catalog (other pull requests, and pushes to main)
if: github.event_name == 'push' || (github.event_name == 'pull_request' && steps.scope.outputs.catalog == 'report')
run: |
# A warning, not a failure. A pull request that touched no input of
# the generator, or a push to main: vendor drift is not a regression
# of the change under test. The nightly run proposes the refresh.
pnpm run catalog:shim \
|| echo "::warning::src/catalog-data.ts is stale - the registry moved; this run does not gate on vendor drift, and the nightly refresh will propose it."
# The Go plan's per-model dollar allowance, from OpenCode's own docs. It
# moves when THEY move — the page says limits "may change as we learn from
# early usage and feedback" — and a stale table shows a wrong dollar figure
# that looks authoritative, which is worse than showing none.
- name: is src/go-limits-data.ts current? (pull requests touching its generator)
if: github.event_name == 'pull_request' && steps.scope.outputs.limits != 'report'
run: |
pnpm run limits:shim || {
echo "::error::src/go-limits-data.ts is out of date — run 'pnpm run limits:shim -- --write' and commit the result."
exit 1
}
- name: report a stale limits table (other pull requests, and pushes to main)
if: github.event_name == 'push' || (github.event_name == 'pull_request' && steps.scope.outputs.limits == 'report')
run: |
pnpm run limits:shim \
|| echo "::warning::src/go-limits-data.ts is stale - the vendor's docs page moved; this run does not gate on vendor drift, and the nightly refresh will propose it."
# The nightly run exists to NOTICE vendor drift, and drift is the normal
# case - the sources move most days. Failing here meant a red run every
# night, which trains everyone to ignore the signal. So the nightly path
# regenerates and proposes a pull request; the refresh gates above run on
# a pull request that touches a generator (where a forgotten regeneration
# is still fixable); any other pull request, and a push to main, only
# reports. A manual dispatch runs neither shim check - only the CLI
# version's report below.
#
# The User-Agent names the CLI version, and the vendor moves it every few
# days - which is the same shape again, with one difference: the script
# checks two things. Every copy of the version in the tree agreeing with
# `src/identity.ts` is offline, and only the repository can break it, so
# it gates every pull request. The pin being the published version follows
# the vendor, so it gates only a pull request that touches the pin or the
# script, and warns everywhere else - quoting the script's own `error:`
# line, so the warning names the cause that failed.
- name: every copy of the CLI version agrees with the pin (pull requests)
if: github.event_name == 'pull_request'
run: pnpm run cli:version -- --copies
- name: the claimed CLI version is the published one (pull requests touching it)
if: github.event_name == 'pull_request' && steps.scope.outputs.cli != 'report'
run: pnpm run cli:version
- name: report a stale CLI version (other pull requests, push, nightly and dispatch)
if: github.event_name != 'pull_request' || steps.scope.outputs.cli == 'report'
run: |
# The default shell has no pipefail, without which `tee` would answer
# for the check and the warning below could never fire.
set -o pipefail
log="$RUNNER_TEMP/cli-version.log"
pnpm run cli:version 2>&1 | tee "$log" \
|| echo "::warning::$(grep -m 1 '^error: ' "$log" || echo 'cli:version failed - see the log above')"
- name: propose the refresh (nightly only)
if: github.event_name == 'schedule'
env:
# PAT, not GITHUB_TOKEN. The push decides the run: a branch pushed
# with the checkout credential is attributed to github-actions[bot],
# and GitHub parks the pull_request run in action_required - the
# first refresh arrived that way, with no CI until a human approved
# the run by hand. Pushed with the PAT the actor is the PAT's owner,
# which is how the release PRs get their checks. Same secret
# release-please uses.
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
run: |
pnpm run catalog:shim -- --write
pnpm run limits:shim -- --write
# The generators emit compact JSON; the repository formats its
# sources. Without this the comparison below always sees a diff and
# every nightly run would open a pull request whose entire content is
# reformatting - the opposite of "open one when upstream moved".
pnpm exec vp fmt --write src/catalog-data.ts src/go-limits-data.ts
if git diff --quiet -- src/catalog-data.ts src/go-limits-data.ts; then
echo "both generated files are current - no upstream change, no pull request"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B chore/generated-refresh
git add src/catalog-data.ts src/go-limits-data.ts
git commit -m "chore: refresh the generated catalog and limits tables"
# Explicit token URL: `origin` carries the checkout credential, and
# a push through it is what lands the run in action_required.
git push --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" chore/generated-refresh
gh pr create --head chore/generated-refresh \
--title "chore: refresh the generated catalog and limits tables" \
--body "The nightly freshness check found the vendor sources moved. Regenerated by CI - review the diff and merge." \
|| echo "a refresh pull request is already open"
# The live gateway contract: the only job that talks to OpenCode itself, so a
# vendor payload change fails here rather than in a user's meter.
#
# Secrets are not exposed to fork or Dependabot PRs, and every credentialed
# case skips when its key is absent — so this stays green there while still
# checking that the endpoints answer. Add OPENCODE_API_KEY (Zen) and
# OPENCODE_GO_API_KEY (Go plan) under Settings → Secrets → Actions to arm the
# keyed cases.
e2e:
name: e2e · live gateway
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 26
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run test:e2e
env:
OPENCODE_E2E: "1"
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_GO_API_KEY: ${{ secrets.OPENCODE_GO_API_KEY }}