-
Notifications
You must be signed in to change notification settings - Fork 30
375 lines (327 loc) · 15 KB
/
Copy pathtest.yml
File metadata and controls
375 lines (327 loc) · 15 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
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
name: Tests
on:
push:
branches:
- preview
- main
pull_request:
concurrency:
group: tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# This workflow carries no `paths-ignore` on purpose. A required status check
# that is never produced for docs-only pull requests leaves them stuck on
# "Expected — Waiting for status" forever, so the workflow always starts and
# the cost is controlled by skipping the expensive job below instead.
changes:
name: changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
code: ${{ steps.scope.outputs.code }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
# Full history: the push range and the pull-request merge base below
# both need commits that a shallow clone does not have.
fetch-depth: 0
- name: Determine code changes
id: scope
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PUSH_BEFORE: ${{ github.event.before }}
HEAD_SHA: ${{ github.sha }}
# `-e` is off so every fallback below can be reached; each git call that
# is allowed to fail is checked explicitly.
run: |
set -uo pipefail
# Fail CLOSED. Every path that cannot *prove* the change is docs-only
# reports code=true and runs the full suite. A wrong "docs-only"
# verdict silently skips every release gate; a wrong "code" verdict
# only costs runner minutes.
emit() { echo "code=$1" >> "$GITHUB_OUTPUT"; echo "resolved code=$1"; }
range_base=""
range_head=""
if [ "$EVENT_NAME" = "pull_request" ]; then
range_head="$PR_HEAD_SHA"
# Diff against the merge base rather than the base branch tip, so
# commits that landed on the base after this PR opened are not
# attributed to this PR.
if [ -n "$PR_BASE_SHA" ] && git rev-parse --verify --quiet "$PR_BASE_SHA^{commit}" >/dev/null; then
range_base="$(git merge-base "$PR_BASE_SHA" "$range_head" 2>/dev/null || true)"
fi
if [ -z "$range_base" ] && [ -n "$PR_BASE_REF" ]; then
git fetch --no-tags origin "+refs/heads/$PR_BASE_REF:refs/remotes/origin/$PR_BASE_REF" || true
range_base="$(git merge-base "refs/remotes/origin/$PR_BASE_REF" "$range_head" 2>/dev/null || true)"
fi
else
range_head="$HEAD_SHA"
# github.event.before is the all-zeros SHA for the first push to a
# ref (branch creation), where there is no previous commit to diff.
case "$PUSH_BEFORE" in
''|0000000000000000000000000000000000000000)
range_base=""
;;
*)
if git rev-parse --verify --quiet "$PUSH_BEFORE^{commit}" >/dev/null; then
range_base="$PUSH_BEFORE"
fi
;;
esac
fi
if [ -z "$range_base" ] || [ -z "$range_head" ]; then
echo "::notice::no usable diff range (base='$range_base' head='$range_head') — running the full suite"
emit true
exit 0
fi
if ! changed="$(git diff --name-only "$range_base" "$range_head")"; then
echo "::notice::git diff $range_base..$range_head failed — running the full suite"
emit true
exit 0
fi
echo "changed files ($range_base..$range_head):"
echo "$changed"
if [ -z "$changed" ]; then
# An empty diff is not evidence of a docs-only change; it usually
# means the range was wrong. Run the suite.
echo "::notice::empty diff for a real range — running the full suite"
emit true
exit 0
fi
# Same set the old `paths-ignore` carried: top-level README*.md and
# everything under docs/. Anything else — including this workflow —
# counts as code.
non_doc="$(echo "$changed" | grep -vE '^README[^/]*\.md$|^docs/' || true)"
if [ -n "$non_doc" ]; then
emit true
else
emit false
fi
node-tests:
name: node-tests
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
env:
SUBMODULE_PAT: ${{ secrets.SUBMODULE_PAT }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Checkout public submodules
run: |
git submodule update --init skills_ref
git submodule update --init officecli
- name: Checkout private submodules (needs SUBMODULE_PAT secret)
if: ${{ env.SUBMODULE_PAT != '' }}
run: |
git submodule update --init devlog
env:
GIT_CONFIG_COUNT: 1
GIT_CONFIG_KEY_0: url.https://x-access-token:${{ env.SUBMODULE_PAT }}@github.com/.insteadOf
GIT_CONFIG_VALUE_0: https://github.com/
- name: Skip private submodules when SUBMODULE_PAT is unavailable
if: ${{ env.SUBMODULE_PAT == '' }}
run: echo "Skipping private devlog checkout because SUBMODULE_PAT is not configured."
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install system test dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ripgrep
rg --version
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Build native modules
run: npm rebuild better-sqlite3
- name: Build TypeScript
run: npx tsc
- name: Run tests
# `-e` is off for this step so the elapsed time is reported even when
# the suite fails; the original exit status is re-raised at the end.
shell: bash {0}
run: |
started=$(date +%s)
npx tsx --import ./tests/setup/test-home.ts --experimental-test-module-mocks --test-force-exit --test tests/*.test.ts tests/unit/*.test.ts
status=$?
elapsed=$(( $(date +%s) - started ))
echo "suite wall time: ${elapsed}s (step limit: 480s)"
# Warn while there is still room to act. The old limit was reached
# with no warning at all, because nothing reported how close the
# suite was running to it: the last green run had 9 seconds of
# headroom and looked exactly like a healthy one.
if [ "$elapsed" -gt 360 ]; then
echo "::warning::test suite took ${elapsed}s, within 2 minutes of the 480s step limit — split it or trim the slow suites before it starts timing out"
fi
exit $status
# The 3-minute limit was already being met by 9 seconds: the last green
# run (30162036088, 2026-07-25) spent 2m51s here. 5581 lines of tests
# landed after it, and the step started dying with ~1270 tests still
# unreported — no single slow test, just a suite that outgrew its box.
#
# 8 minutes is roughly 2.5x the current runtime rather than a number
# picked to make today pass, so ordinary growth does not put this back
# in the same position. The limit still exists to catch a genuine hang;
# subprocess-spawning suites carry their own per-process timeouts well
# under it, so a stuck case fails with a name instead of killing the
# step silently.
timeout-minutes: 8
- name: Release gates (gate:all)
run: npm run gate:all
- name: Build frontend
run: npm run build:frontend
- name: Check frontend build output
run: npm run check:frontend-build-output
- name: Fresh install smoke (safe mode)
run: npm run test:fresh-install
- name: Deps security check
run: npm run check:deps
- name: Dependency advisory gate
run: npm run check:deps:audit
- name: Strict-baseline regression gate
run: npm run check:strict-baseline
- name: Verify devlog submodule
id: devlog-check
run: |
if [[ ! -d devlog/_fin ]]; then
echo "⚠️ devlog submodule not checked out — SUBMODULE_PAT secret may be missing"
echo "available=false" >> "$GITHUB_OUTPUT"
else
echo "available=true" >> "$GITHUB_OUTPUT"
fi
- name: Devlog _fin status gate
if: steps.devlog-check.outputs.available == 'true'
run: bash structure/audit-fin-status.sh
- name: Upload audit report
if: always()
uses: actions/upload-artifact@v5
with:
name: fin-status-audit
path: devlog/_fin/_status_audit.md
if-no-files-found: ignore
- name: Copilot gap check
run: npm run check:copilot-gap
- name: File size check
run: |
echo "=== Files over 500 lines ==="
find . \( -name '*.js' -o -name '*.ts' \) -not -path './node_modules/*' -not -path './skills_ref/*' -not -name '*.d.ts' \
| xargs wc -l | sort -rn | awk '$1 > 500 && !/total/ { print "⚠️ " $0; found=1 } END { if (!found) print "✅ All files under 500 lines" }'
- name: CLI basic tests
run: npx tsx --test tests/integration/cli-basic.test.ts
- name: API smoke tests
run: |
npx tsx server.ts &
SERVER_PID=$!
# Wait for server to be ready (max 10s)
for i in $(seq 1 20); do
if curl -sf http://localhost:3457/api/session > /dev/null 2>&1; then
echo "✅ Server ready after $((i/2))s"
break
fi
sleep 0.5
done
TEST_PORT=3457 npx tsx --test tests/integration/api-smoke.test.ts || SMOKE_EXIT=$?
kill $SERVER_PID 2>/dev/null || true
exit ${SMOKE_EXIT:-0}
env:
NODE_ENV: test
# The single required status check for the `main` ruleset.
#
# Fast Windows unit lane on every code PR (#384). Before this, Windows tests
# ran only in postinstall-platform.yml behind a literal paths: allowlist, so
# a PR touching Windows behavior in unlisted files merged with zero Windows
# CI. Explicit file list - pwsh does not expand globs. Windows flakes in
# these suites block merge by design; that is the lane's purpose.
windows-unit:
name: windows-unit
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: windows-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --ignore-scripts
# better-sqlite3 v13 ships prebuilds (win32-x64 included), so a source
# rebuild is unnecessary here -- and node-gyp cannot detect the VS 2026
# toolchain on windows-latest ("unknown version undefined"), so
# `npm rebuild` hard-fails. Verify loadability instead and rebuild only
# if the prebuild does not load, which is what the repo's own native
# doctor already does.
- name: Ensure native modules are loadable
run: node scripts/ensure-native-modules.cjs
- name: Run Windows-relevant unit tests
run: npx tsx --import ./tests/setup/test-home.ts --experimental-test-module-mocks --test tests/unit/windows-service-esm.test.ts tests/unit/service-windows-branch.test.ts tests/unit/windows-service-lifecycle-honesty.test.ts tests/unit/windows-installer-shims.test.ts tests/unit/windows-spawn-primitives.test.ts tests/unit/manager-browser-open.test.ts tests/unit/eaddrinuse-diagnostics-contract.test.ts tests/unit/tui-env-flags.test.ts tests/unit/windows-launch-spec.test.ts tests/unit/service-lifecycle-cli.test.ts
# The check name is the job `name:` below and is load-bearing: the branch
# ruleset and the publish gate both match on the literal string
# `ci-aggregate`. Renaming this job silently detaches both of them, so the
# name is pinned explicitly instead of being inherited from the job id.
#
# It runs on every event this workflow accepts — including docs-only pull
# requests, where `node-tests` is legitimately skipped — so a required check
# always has a run to wait for.
ci-aggregate:
name: ci-aggregate
needs: [changes, node-tests, windows-unit]
# `always()` rather than the default success() semantics: the point of this
# job is to report on upstream failures, so it must still run after one.
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Verify producers
env:
CHANGES_RESULT: ${{ needs.changes.result }}
NODE_TESTS_RESULT: ${{ needs.node-tests.result }}
WINDOWS_UNIT_RESULT: ${{ needs.windows-unit.result }}
CODE_CHANGED: ${{ needs.changes.outputs.code }}
run: |
set -uo pipefail
failed=0
# `success` and `skipped` pass; `failure`, `cancelled`, an empty
# result, and anything unrecognised fail. Skipped and failed are
# deliberately NOT collapsed together: a docs-only change skips
# node-tests on purpose, a broken one does not.
check() {
job="$1"
result="$2"
case "$result" in
success) echo "PASS $job: success" ;;
skipped) echo "PASS $job: skipped (not required for this change)" ;;
failure) echo "FAIL $job: failure"; failed=1 ;;
cancelled) echo "FAIL $job: cancelled"; failed=1 ;;
'') echo "FAIL $job: no result reported"; failed=1 ;;
*) echo "FAIL $job: unexpected result '$result'"; failed=1 ;;
esac
}
check changes "$CHANGES_RESULT"
check node-tests "$NODE_TESTS_RESULT"
check windows-unit "$WINDOWS_UNIT_RESULT"
# A skip is only legitimate when change detection actually said the
# change was docs-only. If it said "code" and the suite still did not
# run, something dropped the job and the check must not go green.
if [ "$CHANGES_RESULT" = "success" ] && [ "$CODE_CHANGED" = "true" ] && [ "$NODE_TESTS_RESULT" = "skipped" ]; then
echo "FAIL node-tests was skipped even though change detection reported code changes"
failed=1
fi
if [ "$CHANGES_RESULT" = "success" ] && [ "$CODE_CHANGED" = "true" ] && [ "$WINDOWS_UNIT_RESULT" = "skipped" ]; then
echo "FAIL windows-unit was skipped even though change detection reported code changes"
failed=1
fi
if [ "$failed" -ne 0 ]; then
echo "::error::ci-aggregate failed — see the per-job results above"
exit 1
fi
echo "ci-aggregate: all required jobs succeeded or were legitimately skipped"