Skip to content

Feat/sync status workflow UI #545

Feat/sync status workflow UI

Feat/sync status workflow UI #545

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 ──► provider-e2e ──┐
# │
# lint ──────────────────────┤
# unit-test ─────────────────┤──► required-checks ──► package
# build ─────────────────────┤ └────► publish (main only)
#
# All four validation jobs (lint, unit-test, build, provider-e2e) start in
# parallel right after the push; only `provider-e2e` waits on `changes` for its
# 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.
# ─────────────────────────────────────────────────────────────────────────────
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/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'
# ── Fast checks (parallel, no E2E dependency) ──────────────────────────────
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 ────────────────────────────────────
# Real-provider E2E: one matrix job covering GitHub, GitLab, and Gitea (see
# 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'
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 "CI / Provider 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
# ── 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, 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.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