Skip to content

docs: record provider ci verification #481

docs: record provider ci verification

docs: record provider ci verification #481

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'
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