feat(source-control): add push-selection and operation-state foundation #484
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 | |
| 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' | |
| # Arrange/Assert/cleanup are Shell + Git (scripts/e2e-harness.sh); Act | |
| # stays production TypeScript (npx vitest). E2E_WORKDIR/E2E_PR_NUMBER/ | |
| # E2E_SOURCE_BRANCH are set once at job level (see the job `env:` | |
| # above) so all steps below share the same run state/identity. | |
| - name: Provision isolated branch/container | |
| if: steps.gate.outputs.run == 'true' | |
| env: | |
| E2E_PROVIDER: ${{ matrix.provider }} | |
| run: scripts/e2e-harness.sh provision | |
| - name: Seed baseline fixture | |
| if: steps.gate.outputs.run == 'true' | |
| env: | |
| E2E_PROVIDER: ${{ matrix.provider }} | |
| run: scripts/e2e-harness.sh seed | |
| # 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 the whole step from scratch: | |
| # each suite's `runId`/branch paths are randomized per vitest process | |
| # (see e.g. e2e/suites/sync-manager.e2e.test.ts), 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: | | |
| set -a | |
| # shellcheck disable=SC1091 | |
| source "$E2E_WORKDIR/e2e.env" | |
| [ -f "$E2E_WORKDIR/e2e.secrets.env" ] && source "$E2E_WORKDIR/e2e.secrets.env" | |
| set +a | |
| npx vitest run -c vitest.e2e.config.ts "e2e/suites/${{ matrix.provider }}.e2e.test.ts" e2e/suites/sync-manager.e2e.test.ts | |
| - name: Independent verification | |
| if: steps.gate.outputs.run == 'true' | |
| env: | |
| E2E_PROVIDER: ${{ matrix.provider }} | |
| run: scripts/e2e-harness.sh verify | |
| # `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 |