test: expand source control real-provider E2E coverage #530
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' | |
| jobs: | |
| # `on.push.paths`/`on.pull_request.paths` would gate this *whole* workflow | |
| # file by path -- including the release-critical `CI` job below, which must | |
| # keep running for every push/PR regardless of path. This job instead | |
| # computes a per-job boolean so only `provider-e2e` skips on irrelevant | |
| # changes, while `CI`/`build-artifact` are unaffected. | |
| changes: | |
| name: Detect sync/provider-relevant 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/utils/git-blob-sha.ts' | |
| - 'src/utils/path.ts' | |
| - 'src/utils/symlink.ts' | |
| - 'e2e/**' | |
| - 'scripts/e2e-harness.sh' | |
| - 'scripts/e2e-namespace.sh' | |
| - 'scripts/e2e-namespace-cleanup.sh' | |
| - 'scripts/run-e2e.sh' | |
| - 'package.json' | |
| - 'package-lock.json' | |
| - '.github/workflows/ci.yml' | |
| # Real-provider E2E: one matrix job covering GitHub, GitLab, and Gitea (see | |
| # docs/testing/real-provider-e2e.md). | |
| provider-e2e: | |
| name: 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' | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 3 | |
| matrix: | |
| provider: [github, gitlab, gitea] | |
| # One group per source branch/provider -- keyed by branch name alone | |
| # (github.head_ref || github.ref_name, same expression E2E_SOURCE_BRANCH | |
| # below uses), deliberately NOT split by event type. A `push` to a branch | |
| # with an open PR fires both a `push` and a `pull_request` run for the | |
| # same commit; keying by event type (PR number vs branch name, as an | |
| # earlier version of this did) put those two runs in different groups, | |
| # so they ran fully concurrently against the same shared provider | |
| # sandbox and starved each other (observed as real GitLab API timeouts | |
| # under that double load -- see docs/testing/real-provider-e2e.md). | |
| # Keying by branch name alone means the later of the two cancels the | |
| # earlier instead, same as a repeated push or a workflow rerun. 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. `concurrency:` at job level *does* | |
| # support the `matrix` context (unlike job-level `if:`, see the comment | |
| # below), so each matrix leg still gets its own group. | |
| concurrency: | |
| group: e2e-${{ github.head_ref || github.ref_name }}-${{ matrix.provider }} | |
| cancel-in-progress: true | |
| 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 | |
| # TODO(e2e): gitea temporarily disabled in CI -- container | |
| # provisioning against this runner fleet's Docker topology needs | |
| # more investigation (bridge-IP reachability, health-check timing) | |
| # than is safe to iterate on inside the shared matrix. Suite/harness | |
| # code is untouched and passes locally (`npm run test:e2e -- | |
| # --provider gitea`); re-enable by deleting this block once the CI | |
| # runner behavior is confirmed. NOTE: gitea is also what normally | |
| # covers fork PRs (no secrets needed) -- while this is disabled, | |
| # fork PRs get no E2E coverage at all. | |
| if [ "${{ matrix.provider }}" = "gitea" ]; then | |
| run=false | |
| # Make the disabled state explicit in the run log + summary so a | |
| # green "E2E / gitea" job is never mistaken for "Gitea E2E passed". | |
| echo "::notice::Gitea E2E is disabled in CI (runner Docker networking — see TODO below). Suite/harness code passes locally; re-enable by removing this block." | |
| { | |
| echo "### Gitea E2E: disabled" | |
| echo "Reason: runner Docker networking — container provisioning against this runner fleet needs investigation (bridge-IP reachability, health-check timing)." | |
| echo "Suite/harness code is untouched and passes locally (\`npm run test:e2e -- --provider gitea\`). The Gitea infrastructure fix is tracked separately; do not infer three-provider coverage from a green gitea leg." | |
| echo "Re-enable by removing the gitea block in the \"Determine whether this provider leg should run\" step." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| if [ "${{ github.event_name }}" = "pull_request" ] \ | |
| && [ "${{ matrix.provider }}" != "gitea" ] \ | |
| && [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then | |
| run=false | |
| fi | |
| 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 | |
| # Aggregates the matrix into a single required status so branch protection | |
| # only has to reference one check name (see docs/testing/real-provider-e2e.md | |
| # for the "Gitea required, GitHub/GitLab not required at branch-protection | |
| # level" split -- required-vs-optional per *provider* still comes from the | |
| # "Determine whether this provider leg should run" step above; this gate | |
| # only asks "did whatever ran, pass?"). A gated-off leg's steps are all | |
| # skipped without failing the job, so it still reports "success" here. | |
| # `if: always()` so a real provider-e2e failure is caught here and blocks | |
| # CI/release. A cancelled matrix means a newer run in the same branch/provider | |
| # concurrency group replaced this duplicate run; report that as neutral and | |
| # do not start another copy of downstream CI. | |
| e2e-gate: | |
| name: E2E gate | |
| needs: provider-e2e | |
| if: always() | |
| runs-on: ubuntu-latest | |
| outputs: | |
| run-ci: ${{ steps.check.outputs.run-ci }} | |
| steps: | |
| - name: Check provider-e2e result | |
| id: check | |
| run: | | |
| result="${{ needs.provider-e2e.result }}" | |
| echo "provider-e2e result: $result" | |
| echo "run-ci=true" >> "$GITHUB_OUTPUT" | |
| if [ "$result" = "cancelled" ]; then | |
| echo "run-ci=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::provider-e2e was replaced by a newer run in the same concurrency group." | |
| exit 0 | |
| fi | |
| if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then | |
| echo "::error::provider-e2e failed ($result) -- blocking CI/release." | |
| exit 1 | |
| fi | |
| CI: | |
| needs: e2e-gate | |
| if: needs.e2e-gate.outputs.run-ci == 'true' | |
| uses: firstsun-dev/.github/.github/workflows/obsidian-plugin-ci.yml@v1 | |
| with: | |
| plugin-id: "git-file-sync" | |
| secrets: | |
| RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} | |
| build-artifact: | |
| name: Upload build artifact | |
| runs-on: ubuntu-latest | |
| if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: '22' | |
| cache: 'npm' | |
| - run: npm ci | |
| - run: npm run build | |
| - name: Set artifact name | |
| 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@v5 | |
| with: | |
| name: ${{ steps.artifact.outputs.name }} | |
| path: | | |
| main.js | |
| manifest.json | |
| styles.css | |
| retention-days: 7 |