Skip to content

docs(architecture): define module boundaries and bug-fix rules #693

docs(architecture): define module boundaries and bug-fix rules

docs(architecture): define module boundaries and bug-fix rules #693

Workflow file for this run

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