-
Notifications
You must be signed in to change notification settings - Fork 5
471 lines (448 loc) · 21.7 KB
/
Copy pathci.yml
File metadata and controls
471 lines (448 loc) · 21.7 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
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
name: CI/CD
permissions:
contents: write
issues: write
pull-requests: write
attestations: write
id-token: write
on:
push:
branches: [main, master, '**']
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
provider:
description: 'Provider(s) to run (github, gitlab, gitea, or all)'
default: 'all'
keep_branch:
description: 'Keep the E2E branch/container after the run for debugging'
type: boolean
default: false
schedule:
# Weekly API-drift check: same real-provider suites, no code change required to trigger them.
- cron: '0 6 * * 1'
# ─────────────────────────────────────────────────────────────────────────────
# CI DAG overview
#
# changes ──► gitea-e2e ─────┐
# └─► provider-e2e ───┤
# lint ───────────────────────┤
# unit-test ──────────────────┤──► required-checks ──► package
# build ──────────────────────┤ └────► publish (main only)
#
# All five validation jobs start in parallel right after the push; only the E2E
# jobs wait on `changes` for their
# path gate. No validation waits on E2E any more -- a lint/unit/build error now
# surfaces in <1-2 min instead of after the real-provider matrix. The single
# `required-checks` job is the only status branch protection needs to watch.
# Release (package/publish) runs only after that gate passes, so a real
# provider regression still blocks the release instead of shipping and being
# caught after the fact.
#
# Whole-run concurrency (workflow level, NOT per job): a `push` to a branch
# with an open PR fires both a `push` and a `pull_request` run for the same
# commit. Keying concurrency per provider job (an earlier design) let the two
# runs pick *different* winners per provider -- push cancels the PR run's
# GitHub/GitLab legs, the PR run cancels the push run's Gitea leg -- so no
# single run ever had all providers green and the required-checks DAG could
# wedge. Keying at workflow level by source branch alone
# (github.head_ref || github.ref_name) means only ONE whole CI DAG per branch
# survives: the newest run supersedes the older one entirely. Manual
# (workflow_dispatch) and scheduled runs are keyed by event_name+run_id, so
# they get unique groups and never cancel -- or get cancelled by -- branch CI.
# ─────────────────────────────────────────────────────────────────────────────
concurrency:
group: >-
ci-${{
(github.event_name == 'push' || github.event_name == 'pull_request')
&& (github.head_ref || github.ref_name)
|| format('{0}-{1}', github.event_name, github.run_id)
}}
cancel-in-progress: true
jobs:
# `on.push.paths`/`on.pull_request.paths` would gate this *whole* workflow
# file by path -- including the always-must-run validation/release jobs
# below. This job instead computes a per-job boolean so only `provider-e2e`
# skips on irrelevant changes, while `lint`/`unit-test`/`build`/release are
# unaffected (they run on every push/PR regardless of path).
changes:
name: CI / Detect Changes
runs-on: ubuntu-latest
outputs:
e2e-relevant: ${{ steps.filter.outputs.e2e-relevant }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: dorny/paths-filter@15192bc058cc28a13dbf6cde61f19e18988b7af6 # v3
id: filter
with:
filters: |
e2e-relevant:
- 'src/services/**'
- 'src/logic/sync-manager.ts'
- 'src/logic/sync/**'
- 'src/logic/source-control/**'
- 'src/utils/git-blob-sha.ts'
- 'src/utils/path.ts'
- 'src/utils/symlink.ts'
- 'e2e-tests/**'
- 'vitest.e2e.config.ts'
- 'scripts/e2e-harness.sh'
- 'scripts/e2e-namespace.sh'
- 'scripts/e2e-namespace-cleanup.sh'
- 'scripts/e2e-suites.txt'
- 'scripts/run-e2e.sh'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/ci.yml'
# ── Fast checks (parallel, no E2E dependency) ──────────────────────────────
# Gitea is secretless and disposable, so it runs on a fresh GitHub-hosted
# VM. This is the only E2E job allowed to execute fork PR code; untrusted
# code must never reach the privileged self-hosted fleet below.
gitea-e2e:
name: CI / Provider E2E / gitea
needs: changes
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 20
if: >-
(needs.changes.outputs.e2e-relevant == 'true' ||
github.event_name == 'workflow_dispatch' ||
github.event_name == 'schedule' ||
github.ref == 'refs/heads/main') &&
(github.event_name != 'workflow_dispatch' ||
github.event.inputs.provider == 'all' ||
github.event.inputs.provider == 'gitea')
env:
E2E_KEEP_BRANCH: ${{ github.event.inputs.keep_branch }}
E2E_PR_NUMBER: ${{ github.event.pull_request.number }}
E2E_SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
steps:
- name: Compute run-scoped workdir
run: echo "E2E_WORKDIR=$RUNNER_TEMP/git-files-sync-e2e/${{ github.run_id }}/${{ github.run_attempt }}/gitea" >> "$GITHUB_ENV"
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22'
cache: npm
- run: npm ci --ignore-scripts
- name: Run disposable Gitea E2E
run: scripts/run-e2e.sh --provider gitea
lint:
name: CI / Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22'
cache: npm
- run: npm ci --ignore-scripts
- run: npm run lint
unit-test:
name: CI / Unit Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci --ignore-scripts
- name: Build (compatibility check)
run: npm run build
- name: Run tests with coverage
run: npm run test -- --coverage
- name: Upload coverage
if: matrix.node-version == 22
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: coverage-report
path: coverage/
overwrite: true
build:
name: CI / Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22'
cache: npm
- run: npm ci --ignore-scripts
- run: npm run build
# Upload the raw built artifacts (main.js/manifest/styles.css) for ad-hoc
# PR install testing on non-main branches. `npm run build` already ran
# above as the validation (tsc -noEmit + Obsidian 1.11.0 compat typecheck +
# esbuild), so this upload is in the *same* job -- never a separate
# upload-artifact job that could drift out of sync with the build state.
- name: Set artifact name
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master'
id: artifact
run: |
BRANCH=$(echo "${{ github.ref_name }}" | tr '/' '-')
SHA=$(echo "${{ github.sha }}" | cut -c1-7)
echo "name=plugin-${BRANCH}-${SHA}" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master'
with:
name: ${{ steps.artifact.outputs.name }}
path: |
main.js
manifest.json
styles.css
retention-days: 7
# ── Integration check: real-provider E2E ────────────────────────────────────
# Credentialed provider E2E: GitHub and GitLab remain on the self-hosted
# fleet. Fork PRs are rejected at job level before one can claim a runner.
# docs/testing/real-provider-e2e.md). It starts as soon as `changes` resolves
# (no preflight/E2E-gate dependency any more), then provider legs run in
# parallel. `fail-fast: false` so one provider failure doesn't cancel the
# others.
provider-e2e:
name: CI / Provider E2E / ${{ matrix.provider }}
needs: [changes]
runs-on: [self-hosted, linux, x64, 32gb-ram]
# Runs when sync/provider-relevant paths changed, or unconditionally on
# workflow_dispatch/schedule/a push to main (main always gets the full
# tier regardless of path, per the issue's CI wiring). The per-provider
# part of the gating (internal PRs/main/dispatch/schedule get every
# provider; a fork PR only gets Gitea) can't live here: job-level `if:`
# has no access to the `matrix` context (GitHub Actions error
# "Unrecognized named-value: 'matrix'" if you try) -- only step-level
# `if:` can see it. That part is done by the "Determine whether this
# provider leg should run" step below instead, gating every later step.
if: >-
(needs.changes.outputs.e2e-relevant == 'true' ||
github.event_name == 'workflow_dispatch' ||
github.event_name == 'schedule' ||
github.ref == 'refs/heads/main') &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) &&
(github.event_name != 'workflow_dispatch' ||
github.event.inputs.provider == 'all' ||
github.event.inputs.provider == 'github' ||
github.event.inputs.provider == 'gitlab')
strategy:
fail-fast: false
max-parallel: 2
matrix:
provider: [github, gitlab]
# Concurrency is handled at WORKFLOW level (see the header comment on the
# `concurrency:` block above): one whole CI DAG per source branch, so a
# push + pull_request race for the same commit can never split provider
# winners across two runs. Per-provider job groups were removed for that
# reason -- they let each provider pick a different surviving run.
# This is NOT a cleanup mechanism (see scripts/e2e-harness.sh's per-run
# branch naming): a cancelled run's branch can still be mid-delete when
# the next one starts, which is exactly why every run gets its own unique
# branch regardless of cancellation.
env:
E2E_GITHUB_OWNER: ${{ vars.E2E_GITHUB_OWNER }}
E2E_GITHUB_REPO: ${{ vars.E2E_GITHUB_REPO }}
E2E_GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }}
# E2E_GITLAB_PROJECT_ID is configured as a repo *secret*, not a
# variable, on firstsun-dev/git-files-sync (confirmed via `gh secret
# list` while wiring this workflow) -- unlike E2E_GITHUB_OWNER/REPO,
# which are plain (non-sensitive) vars.
E2E_GITLAB_PROJECT_ID: ${{ secrets.E2E_GITLAB_PROJECT_ID }}
E2E_GITLAB_TOKEN: ${{ secrets.E2E_GITLAB_TOKEN }}
E2E_KEEP_BRANCH: ${{ github.event.inputs.keep_branch }}
# Identity inputs for scripts/e2e-namespace.sh (via e2e-harness.sh's
# `provision`): PR runs get e2e/pr/<number>/**, everything else
# (push/workflow_dispatch/schedule) is a branch-only run under
# e2e/branch/<id>/**. github.head_ref is only set for pull_request
# events; ref_name covers push/dispatch/schedule.
E2E_PR_NUMBER: ${{ github.event.pull_request.number }}
E2E_SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
steps:
# `runner` context (needed for `runner.temp`) isn't available in a
# job-level `env:` block -- only `github`, `inputs`, `matrix`, `needs`,
# `secrets`, `strategy`, `vars` are (actionlint: "context 'runner' is
# not allowed here"); using it there makes GitHub reject the entire
# workflow file at parse time (0 jobs created, no check run at all).
# So E2E_WORKDIR is computed here instead, in a step, and exported via
# $GITHUB_ENV for every later step incl. cleanup. Unconditional (no
# `if:` gate) so it's always set before the gate/cleanup steps run.
# Unique per run_id/run_attempt/provider so a previous killed job's
# local files can never leak into this one -- never a shared/reused
# directory across runs (see docs/testing/real-provider-e2e.md).
- name: Compute run-scoped workdir
run: echo "E2E_WORKDIR=$RUNNER_TEMP/git-files-sync-e2e/${{ github.run_id }}/${{ github.run_attempt }}/${{ matrix.provider }}" >> "$GITHUB_ENV"
# Per-provider gate (needs `matrix`, so it runs as a step, not the job-level
# `if:` above -- see the comment on that `if:` for why). A fork PR (head repo
# != base repo) only gets Gitea, which needs no repository secrets and can
# safely run against an untrusted fork's code; GitHub/GitLab need real sandbox
# credentials that must never be exposed to a fork PR's workflow run. All
# other events/providers run.
- name: Determine whether this provider leg should run
id: gate
run: |
run=true
if [ "${{ github.event_name }}" = "workflow_dispatch" ] \
&& [ "${{ github.event.inputs.provider }}" != "all" ] \
&& [ "${{ github.event.inputs.provider }}" != "${{ matrix.provider }}" ]; then
run=false
fi
echo "run=$run" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
if: steps.gate.outputs.run == 'true'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
if: steps.gate.outputs.run == 'true'
with:
node-version: '22'
cache: npm
# --ignore-scripts: this job only needs installed deps to run vitest/the
# harness, not husky's `prepare` git-hook setup -- skipping lifecycle
# scripts avoids letting an install-time script run arbitrary code.
- run: npm ci --ignore-scripts
if: steps.gate.outputs.run == 'true'
# One entry point for the whole real-provider E2E flow: scripts/run-e2e.sh
# provisions the isolated branch/container, seeds the baseline fixture,
# runs the suites listed in scripts/e2e-suites.txt (the single source of
# truth — CI and local run the same command, so the suite list is never
# duplicated here), and cleans up via its EXIT trap. New suites are added
# in scripts/e2e-suites.txt only; run-e2e.sh's own forward/reverse checks
# fail the run if a suite file isn't registered (or vice versa).
# E2E_WORKDIR is set by the "Compute run-scoped workdir" step above; the
# job `env:` supplies the provider secrets and run identity
# (E2E_PR_NUMBER/E2E_SOURCE_BRANCH) that run-e2e.sh/e2e-harness.sh consume.
#
# Retried (not just run once): observed failures against the real
# providers include transient runner-network blips unrelated to the
# suite/product code (e.g. a bare `getaddrinfo ENOTFOUND gitlab.com`
# mid-test on 2026-08-14, run 31770197590) that a same-attempt rerun
# simply doesn't reproduce. Safe to retry from scratch: run-e2e.sh
# re-provisions a fresh isolated branch each attempt and every suite's
# runId/branch paths are randomized per vitest process, so a failed
# attempt's partial remote state never collides with the retry -- a
# genuine product/test bug still fails identically every attempt and
# exhausts the retries.
- name: Run provider E2E (production TypeScript, real provider)
if: steps.gate.outputs.run == 'true'
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
env:
E2E_PROVIDER: ${{ matrix.provider }}
with:
timeout_minutes: 15
max_attempts: 3
retry_wait_seconds: 15
command: scripts/run-e2e.sh --provider "${{ matrix.provider }}"
# `if: always()` -- cleanup is best-effort, never a prerequisite for
# the next run (see scripts/e2e-harness.sh's cmd_cleanup and
# docs/testing/real-provider-e2e.md's cleanup hierarchy); a
# cancelled/killed job still gets a shot at this step, but the next
# run never depends on it succeeding.
- name: Cleanup
if: always() && steps.gate.outputs.run == 'true'
env:
E2E_PROVIDER: ${{ matrix.provider }}
run: scripts/e2e-harness.sh cleanup
# ── Final gate ──────────────────────────────────────────────────────────────
# Single required status check. Branch protection only has to reference
# this one job name (see docs/testing/real-provider-e2e.md's note on the
# `CI / gitea` required-status split). `if: always()` so a real validation
# failure is caught here and blocks merge/release; a cancelled matrix leg
# (a newer run in the same branch/provider concurrency group replaced this
# duplicate) is treated as a failure here too -- the surviving run is the
# one whose gate result GitHub uses for the latest commit, so blocking the
# cancelled duplicate's gate is correct, not a wedged red.
required-checks:
name: CI / Required Checks
needs: [lint, unit-test, build, gitea-e2e, provider-e2e]
if: always()
runs-on: ubuntu-latest
steps:
- name: Aggregate validation results
run: |
fail=0
for r in "${{ needs.lint.result }}" "${{ needs.unit-test.result }}" "${{ needs.build.result }}" "${{ needs.gitea-e2e.result }}" "${{ needs.provider-e2e.result }}"; do
echo "result: $r"
case "$r" in
success|skipped) ;;
*) echo "::error::validation job reported '$r' -- blocking merge/release."; fail=1 ;;
esac
done
exit "$fail"
# ── Release (gated on required-checks) ──────────────────────────────────────
package:
name: Release / Package
needs: [required-checks]
if: needs.required-checks.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22'
cache: npm
- run: npm ci --ignore-scripts
- run: npm run build
- name: Create plugin package
run: |
VERSION=$(node -p "require('./manifest.json').version")
BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
BRANCH_NAME_SAFE=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9._-]/-/g')
ZIP_NAME="git-file-sync-${VERSION}-${BRANCH_NAME_SAFE}.zip"
zip -j "$ZIP_NAME" main.js manifest.json styles.css || zip -j "$ZIP_NAME" main.js manifest.json
echo "ZIP_NAME=$ZIP_NAME" >> "$GITHUB_ENV"
echo "PLUGIN_VERSION=$VERSION" >> "$GITHUB_ENV"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: plugin-build-artifact-${{ github.sha }}
path: ${{ env.ZIP_NAME }}
retention-days: 7
- name: Annotate build summary
run: |
echo "::notice title=Obsidian Plugin Build::git-file-sync v${{ env.PLUGIN_VERSION }} built (${{ env.ZIP_NAME }})"
{
echo "### Obsidian Plugin Build"
echo ""
echo "- Plugin: \`git-file-sync\`"
echo "- Version: \`${{ env.PLUGIN_VERSION }}\`"
echo "- Artifact: \`${{ env.ZIP_NAME }}\`"
} >> "$GITHUB_STEP_SUMMARY"
publish:
name: Release / Publish
needs: [required-checks]
# semantic-release only releases on main/master (see .releaserc.json's
# `branches`); gating the whole job on those refs skips the build/attest
# work on every PR run.
if: >-
needs.required-checks.result == 'success' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22'
cache: npm
- run: npm ci
- run: npm run build
- name: Attest main.js
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: main.js
- name: Attest styles.css
if: hashFiles('styles.css') != ''
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: styles.css
- env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN || github.token }}
run: npx semantic-release