gitmote

gitmote  ·  history  ·  branches

feat: read CI workflows from .gitmote/workflows, not .github/workflows

aa1c7cbe7b72b787270f456d61fe0046a46cba12
Atanas Minev <atmin@protonmail.com> · 2026-07-06 11:37

diff --git a/README.md b/README.md
index 3f4c5d7..4519533 100644
--- a/README.md
+++ b/README.md
@@ -72,12 +72,14 @@ make dev-reset # wipe MinIO + data/ for a clean slate
Sign in to the UI at <http://localhost:8080/> by pasting the token, or
clone/push straight away with the printed URL. Requires Docker + Docker Compose.
-**CI works locally too.** Push a repo with `.github/workflows/*.yml` and gitmote
+**CI works locally too.** Push a repo with `.gitmote/workflows/*.yml` and gitmote
records a run and executes it on the spot — it spawns `gitmote-runner` as a local
process (the *same* runner code the cloud path runs on Scaleway, just a local
substrate) which runs the workflow with [`act`](https://github.com/nektos/act).
Install act (`brew install act`); it uses the same Docker/podman daemon MinIO
-does. Watch runs in the UI.
+does. Watch runs in the UI. gitmote reads `.gitmote/workflows` (GitHub-workflow
+syntax, run by act) — **not** `.github/workflows`, so a repo mirrored to GitHub
+chooses its forge by where the workflow lives and never double-runs.
## Run it locally
diff --git a/cmd/gitmote/main_test.go b/cmd/gitmote/main_test.go
index 524acfe..77a44a2 100644
--- a/cmd/gitmote/main_test.go
+++ b/cmd/gitmote/main_test.go
@@ -559,7 +559,7 @@ func seedWorkflowRepo(t *testing.T, md *meta.Metadata, s store.Store) (*meta.Rep
}
}
git("init", "-b", "main", ".")
- wf := filepath.Join(src, ".github", "workflows")
+ wf := filepath.Join(src, ".gitmote", "workflows")
if err := os.MkdirAll(wf, 0o755); err != nil {
t.Fatal(err)
}
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 61ea2bd..edd93c7 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -45,7 +45,7 @@ The rest of the architecture is split by concern:
| [request-flows.md](request-flows.md) | The read path (clone/fetch) and the write path (push, and the compare-and-swap that makes it safe). |
| [safety.md](safety.md) | The concurrency & safety model — single writer, the content-before-pointer ordering invariant, and the accepted write-loss window. |
| [auth.md](auth.md) | Authentication and transport — smart HTTP + personal access tokens today, SSH deferred. |
-| [ci.md](ci.md) | CI — running `.github/workflows` on push: the dispatch seam, the one-runner-three-substrates model, `act` self-hosted vs. nested, secrets, and the no-in-Job-image-builds limitation. |
+| [ci.md](ci.md) | CI — running `.gitmote/workflows` on push: the dispatch seam, the one-runner-three-substrates model, `act` self-hosted vs. nested, secrets, and the no-in-Job-image-builds limitation. |
| [alternatives.md](alternatives.md) | Approaches considered and rejected or deferred, with the reasoning. |
| [open-questions.md](open-questions.md) | Unsettled decisions — the superset that links into [`../notes/`](../notes/). |
diff --git a/docs/architecture/ci.md b/docs/architecture/ci.md
index fbda82b..a500256 100644
--- a/docs/architecture/ci.md
+++ b/docs/architecture/ci.md
@@ -1,15 +1,25 @@
# gitmote — CI
-Run a repo's `.github/workflows` on push: on a successful ref advance, execute
+Run a repo's `.gitmote/workflows` on push: on a successful ref advance, execute
the workflow in an isolated container, keep logs, report pass/fail, surface
status in the UI. Deliberately **limited but real** — the ~80% of personal CI
that is "on push, run steps in a container, pass/fail, keep logs." No matrix
builds, marketplace, service containers, or caching.
The engine is **[`act`](https://github.com/nektos/act)**: it runs
-GitHub-Actions YAML, so the *same* workflow runs on gitmote's runner and on the
-GitHub mirror. Actions-compat is load-bearing (the mirror is break-glass), not a
-preference.
+GitHub-Actions YAML, so a workflow authored for Actions runs unmodified under
+gitmote's runner. Actions-compat is load-bearing (the GitHub mirror is
+break-glass — move the same YAML to `.github/workflows` and GitHub runs it), not
+a preference.
+
+**Why `.gitmote/workflows`, not `.github/workflows`.** GitHub only ever reads
+`.github`, so the directory a workflow lives in *declares which forge runs it*: a
+repo mirrored to GitHub puts CI in `.gitmote` to run only here, in `.github` to
+run only on GitHub, and so never double-runs on both. (gitmote's own repo keeps
+its deploy pipeline in `.github` and therefore never CI's on gitmote — the
+concrete case that motivated this.) `act` reads the directory via `--workflows`;
+the YAML is byte-identical to GitHub's, so moving a file between the two
+directories is the whole switch.
## Pipeline
diff --git a/docs/evolution/ci-runner.md b/docs/evolution/ci-runner.md
index 3781209..8f2c506 100644
--- a/docs/evolution/ci-runner.md
+++ b/docs/evolution/ci-runner.md
@@ -1,7 +1,7 @@
# CI runner (speculative)
> **Status: largely realized — see [architecture/ci.md](../architecture/ci.md).**
-> CI (run `.github/workflows` on push, `act` self-hosted on Scaleway Jobs, logs,
+> CI (run `.gitmote/workflows` on push, `act` self-hosted on Scaleway Jobs, logs,
> status UI, encrypted secrets) has landed. The one part **dropped**: the
> self-deploy loop below — the Serverless Job sandbox can't build container
> images (no user namespaces / privileged mode), so gitmote does not rebuild its
diff --git a/docs/ops.md b/docs/ops.md
index f16aabf..22bd634 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -156,7 +156,7 @@ from the env at startup** (one runner, three substrates — see
instead of a cloud job (this is what `make dev` uses).
3. else → **disabled**: runs and jobs are recorded, but nothing executes.
-**Prerequisite for both executing substrates:** the runner runs `.github/workflows`
+**Prerequisite for both executing substrates:** the runner runs `.gitmote/workflows`
with [`act`](https://github.com/nektos/act), which needs a reachable **Docker or
podman daemon** — in the cloud that's the Serverless Job image; locally it's the
daemon on the host (the same one MinIO uses under `make dev`). A leader-only ticker
diff --git a/internal/ci/dispatcher.go b/internal/ci/dispatcher.go
index 937f3f5..15169f3 100644
--- a/internal/ci/dispatcher.go
+++ b/internal/ci/dispatcher.go
@@ -1,6 +1,6 @@
// Package ci is the continuous-integration dispatch seam. On a successful push
// the write path hands each updated ref to Dispatcher.Dispatch, which reads the
-// pushed commit's .github/workflows/ directory and — when there is work —
+// pushed commit's .gitmote/workflows/ directory and — when there is work —
// records a run with one job per workflow file. It is fire-and-forget: dispatch
// must never block or fail a push (a missed run is a missed deploy, not a failed
// push — the content-before-pointer discipline applied to CI; see
@@ -25,8 +25,14 @@ const (
// branchPrefix is the ref namespace CI reacts to; tags and other refs are
// ignored.
branchPrefix = "refs/heads/"
- // workflowDir is the fixed, traversal-safe location engine (act) reads.
- workflowDir = ".github/workflows"
+ // workflowDir is the fixed, traversal-safe location the engine (act) reads.
+ // gitmote deliberately reads .gitmote/workflows, NOT .github/workflows: GitHub
+ // only ever sees .github, so which directory a workflow lives in *declares*
+ // which forge runs it — a repo mirrored to GitHub puts CI in .gitmote to run
+ // only here, in .github to run only there, and gets no double-run. (This is
+ // also why gitmote's own repo, which has only .github, never CI's on gitmote.)
+ // act reads it via `--workflows` (see internal/runner); the YAML is unchanged.
+ workflowDir = ".gitmote/workflows"
// cloneTokenTTL bounds the per-run clone credential: a run's max duration
// plus margin. The token is read-only and repo-scoped, so it grants no more
// than a fetch of the one repo, and expires long before it could be reused.
@@ -131,7 +137,7 @@ func NewDispatcher(cfg Config) *Dispatcher {
// Dispatch reads the pushed commit's workflows and records a run for a branch
// create/update that has any. Tag pushes and branch deletions do nothing; a repo
-// with no .github/workflows creates no run. Everything here is non-fatal: the
+// with no .gitmote/workflows creates no run. Everything here is non-fatal: the
// push has already committed, so errors are logged, never returned.
func (d *Dispatcher) Dispatch(ctx context.Context, ev Event) {
if !strings.HasPrefix(ev.Ref, branchPrefix) {
@@ -154,7 +160,7 @@ func (d *Dispatcher) Dispatch(ctx context.Context, ev Event) {
entries, err := repo.Tree(ctx, dir, ev.NewSHA, workflowDir)
if errors.Is(err, repo.ErrNotFound) {
d.logger.Debug("ci: no workflows", "repo", ev.RepoName, "ref", ev.Ref)
- return // no .github/workflows → no run
+ return // no .gitmote/workflows → no run
}
if err != nil {
d.logger.Error("ci: read workflows failed",
diff --git a/internal/ci/dispatcher_test.go b/internal/ci/dispatcher_test.go
index e937352..7a7e94d 100644
--- a/internal/ci/dispatcher_test.go
+++ b/internal/ci/dispatcher_test.go
@@ -134,7 +134,7 @@ func TestDispatchOneWorkflowCreatesRunAndJob(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
- ".github/workflows/ci.yml": "name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n",
+ ".gitmote/workflows/ci.yml": "name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n",
})
tr := &stubTrigger{}
@@ -175,8 +175,8 @@ func TestDispatchTwoWorkflowsCreateTwoJobs(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
- ".github/workflows/ci.yml": "name: CI\non: push\n",
- ".github/workflows/deploy.yaml": "name: Deploy\non: push\n",
+ ".gitmote/workflows/ci.yml": "name: CI\non: push\n",
+ ".gitmote/workflows/deploy.yaml": "name: Deploy\non: push\n",
})
newDispatcher(md, mz, &stubTrigger{}).Dispatch(ctx, branchEvent(r, head))
@@ -198,7 +198,7 @@ func TestDispatchTwoWorkflowsCreateTwoJobs(t *testing.T) {
func TestDispatchNoWorkflowsNoRun(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
- r, head := seedRepo(t, md, s, "app", nil) // no .github/workflows
+ r, head := seedRepo(t, md, s, "app", nil) // no .gitmote/workflows
newDispatcher(md, mz, &stubTrigger{}).Dispatch(ctx, branchEvent(r, head))
@@ -207,12 +207,30 @@ func TestDispatchNoWorkflowsNoRun(t *testing.T) {
}
}
+// TestDispatchGitHubWorkflowsIgnored: a repo whose workflows live in
+// .github/workflows (a GitHub-mirrored repo, or gitmote's own repo) triggers no
+// gitmote run — gitmote reads .gitmote/workflows, so the directory declares the
+// forge and there is no double-run.
+func TestDispatchGitHubWorkflowsIgnored(t *testing.T) {
+ ctx := context.Background()
+ md, s, mz := newFixture(t)
+ r, head := seedRepo(t, md, s, "app", map[string]string{
+ ".github/workflows/ci.yml": "name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n",
+ })
+
+ newDispatcher(md, mz, &stubTrigger{}).Dispatch(ctx, branchEvent(r, head))
+
+ if runs, _ := md.ListRuns(ctx, r.ID, 0); len(runs) != 0 {
+ t.Errorf("runs = %d, want 0 (.github/workflows is GitHub's, not gitmote's)", len(runs))
+ }
+}
+
func TestDispatchMalformedWorkflowFailsRun(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
- ".github/workflows/good.yml": "name: Good\non: push\n",
- ".github/workflows/bad.yml": "name: Bad\n : : not: valid: yaml\n\t- broken\n",
+ ".gitmote/workflows/good.yml": "name: Good\non: push\n",
+ ".gitmote/workflows/bad.yml": "name: Bad\n : : not: valid: yaml\n\t- broken\n",
})
// Must not panic; the push (simulated by the event) is unaffected.
@@ -236,7 +254,7 @@ func TestDispatchIgnoresTagsAndDeletes(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
- ".github/workflows/ci.yml": "name: CI\non: push\n",
+ ".gitmote/workflows/ci.yml": "name: CI\non: push\n",
})
d := newDispatcher(md, mz, &stubTrigger{})
@@ -258,7 +276,7 @@ func TestDispatchAllTriggersFailMarksRunError(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
- ".github/workflows/ci.yml": "name: CI\non: push\n",
+ ".gitmote/workflows/ci.yml": "name: CI\non: push\n",
})
tr := &stubTrigger{err: errors.New("scaleway down")}
@@ -278,8 +296,8 @@ func TestDispatchOneTriggerFailsOthersProceed(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
- ".github/workflows/a.yml": "name: A\non: push\n",
- ".github/workflows/b.yml": "name: B\non: push\n",
+ ".gitmote/workflows/a.yml": "name: A\non: push\n",
+ ".gitmote/workflows/b.yml": "name: B\non: push\n",
})
// Fail the first trigger, succeed the rest — one failure must not abort others.
@@ -334,7 +352,7 @@ func TestDispatchMintsScopedCloneToken(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
- ".github/workflows/ci.yml": "name: CI\non: push\n",
+ ".gitmote/workflows/ci.yml": "name: CI\non: push\n",
})
tr := &stubTrigger{}
@@ -372,7 +390,7 @@ func TestDispatchMintFailureLeavesRunUsable(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
- ".github/workflows/ci.yml": "name: CI\non: push\n",
+ ".gitmote/workflows/ci.yml": "name: CI\non: push\n",
})
// A mint failure is non-fatal: the run and job still record and trigger, with
@@ -407,7 +425,7 @@ func TestDispatchInjectsSecrets(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
- ".github/workflows/ci.yml": "name: CI\non: push\n",
+ ".gitmote/workflows/ci.yml": "name: CI\non: push\n",
})
tr := &stubTrigger{}
@@ -436,7 +454,7 @@ func TestDispatchSecretsFailureRunsWithout(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "app", map[string]string{
- ".github/workflows/ci.yml": "name: CI\non: push\n",
+ ".gitmote/workflows/ci.yml": "name: CI\non: push\n",
})
// A secrets error is non-fatal: the job still triggers, just without secrets.
diff --git a/internal/githttp/receive_test.go b/internal/githttp/receive_test.go
index ec4bd7c..31eb959 100644
--- a/internal/githttp/receive_test.go
+++ b/internal/githttp/receive_test.go
@@ -265,10 +265,10 @@ func initWorkflowCommit(t *testing.T) string {
if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
- if err := os.MkdirAll(filepath.Join(src, ".github", "workflows"), 0o755); err != nil {
+ if err := os.MkdirAll(filepath.Join(src, ".gitmote", "workflows"), 0o755); err != nil {
t.Fatal(err)
}
- if err := os.WriteFile(filepath.Join(src, ".github", "workflows", "ci.yml"),
+ if err := os.WriteFile(filepath.Join(src, ".gitmote", "workflows", "ci.yml"),
[]byte("name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n"), 0o644); err != nil {
t.Fatal(err)
}
@@ -313,7 +313,7 @@ func TestAfterCommitEnqueuesRun(t *testing.T) {
}
// TestAfterCommitNoWorkflowNoRun confirms the discovery gate end-to-end: a real
-// push of a repo without .github/workflows records no run.
+// push of a repo without .gitmote/workflows records no run.
func TestAfterCommitNoWorkflowNoRun(t *testing.T) {
ctx := context.Background()
srv, m, s, writer := newWriteServer(t)
diff --git a/internal/runner/act.go b/internal/runner/act.go
index bed9f10..4ad230d 100644
--- a/internal/runner/act.go
+++ b/internal/runner/act.go
@@ -22,10 +22,12 @@ const secretEnvPrefix = "GITMOTE_CI_SECRET_"
// act's default nested-container behavior, unchanged.
const actPlatformsEnv = "GITMOTE_ACT_PLATFORMS"
-// ActEngine runs .github/workflows with nektos/act — the locked engine
+// ActEngine runs the repo's workflows with nektos/act — the locked engine
// (tasks/16-ci.md Stage 0 #1). act runs each job in a container via the local
-// Docker/podman daemon, so one definition runs both self-hosted and on the
-// GitHub mirror. It requires `act` on PATH and a reachable Docker daemon.
+// Docker/podman daemon and speaks GitHub-Actions YAML, so a workflow is
+// byte-identical whether it lives in .gitmote/workflows (run here) or
+// .github/workflows (run on the GitHub mirror — break-glass). It requires `act`
+// on PATH and a reachable Docker daemon.
type ActEngine struct{}
// Run invokes act over workflowDir inside repoDir on the default (push) event,
diff --git a/internal/runner/act_test.go b/internal/runner/act_test.go
index 3538eae..4bbc82b 100644
--- a/internal/runner/act_test.go
+++ b/internal/runner/act_test.go
@@ -7,7 +7,7 @@ import (
)
func TestActArgsForwardsSecrets(t *testing.T) {
- args, extra := actArgs(".github/workflows", []string{
+ args, extra := actArgs(".gitmote/workflows", []string{
"PATH=/usr/bin",
"GITMOTE_CI_SECRET_API_TOKEN=s3cr3t",
"GITMOTE_URL=http://x", // not a secret; must be ignored
@@ -25,7 +25,7 @@ func TestActArgsForwardsSecrets(t *testing.T) {
}
func TestActArgsPlatforms(t *testing.T) {
- args, _ := actArgs(".github/workflows", []string{
+ args, _ := actArgs(".gitmote/workflows", []string{
"GITMOTE_ACT_PLATFORMS=ubuntu-latest=-self-hosted, ubuntu-22.04=-self-hosted",
})
if !hasFlag(args, "-P", "ubuntu-latest=-self-hosted") ||
@@ -35,7 +35,7 @@ func TestActArgsPlatforms(t *testing.T) {
}
func TestActArgsDefaultsNoOverrides(t *testing.T) {
- args, extra := actArgs(".github/workflows", []string{"HOME=/root"})
+ args, extra := actArgs(".gitmote/workflows", []string{"HOME=/root"})
if len(extra) != 0 {
t.Errorf("extraEnv = %v, want none", extra)
}
diff --git a/internal/runner/runner.go b/internal/runner/runner.go
index 2a2b9b2..c7fd54e 100644
--- a/internal/runner/runner.go
+++ b/internal/runner/runner.go
@@ -1,6 +1,6 @@
// Package runner is the CI job runner: the code that claims a queued job from
// gitmote's internal report API, checks out the repo at the pushed SHA, runs the
-// workflow engine over .github/workflows, and reports the combined log +
+// workflow engine over .gitmote/workflows, and reports the combined log +
// pass/fail back. It is deliberately substrate-agnostic — the same code runs as
// a local process (LocalTrigger, dev) and, later, inside a Scaleway Serverless
// Job container (cloud). It never touches s3lite or S3: the leader writes the
diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go
index 0b35dcb..06d71b7 100644
--- a/internal/runner/runner_test.go
+++ b/internal/runner/runner_test.go
@@ -14,7 +14,7 @@ const testSecret = "worker-secret"
var testSpec = spec{
JobID: 5, RunID: 1, Repo: "atmin/app", SHA: "deadbeef",
- Ref: "refs/heads/main", WorkflowDir: ".github/workflows",
+ Ref: "refs/heads/main", WorkflowDir: ".gitmote/workflows",
}
// capture records the completion the runner posts.
diff --git a/internal/webui/templates/ci_runs.html b/internal/webui/templates/ci_runs.html
index ffbb512..0dfce1d 100644
--- a/internal/webui/templates/ci_runs.html
+++ b/internal/webui/templates/ci_runs.html
@@ -19,6 +19,6 @@
</table>
{{if .More}}<p>Showing the most recent {{len .Runs}} runs; older runs exist.</p>{{end}}
{{else}}
-<p>No CI runs yet for {{.Repo}}. Push a commit with <code>.github/workflows</code>.</p>
+<p>No CI runs yet for {{.Repo}}. Push a commit with <code>.gitmote/workflows</code>.</p>
{{end}}
{{template "bottom" .}}{{end}}
diff --git a/scripts/dev.sh b/scripts/dev.sh
index 7f2c220..b7d59b8 100755
--- a/scripts/dev.sh
+++ b/scripts/dev.sh
@@ -26,7 +26,7 @@ export GITMOTE_COOKIE_KEY="dev-cookie-key-not-for-production"
# (bin/gitmote-runner) as a local process — the same runner code the cloud path
# uses, driven by the local trigger instead of a Scaleway job. GITMOTE_URL is
# where the runner clones + reports back; WORKER_SECRET authenticates the report
-# API. The runner executes .github/workflows with `act` (install: brew install
+# API. The runner executes .gitmote/workflows with `act` (install: brew install
# act), which needs the Docker/podman daemon MinIO already relies on.
export GITMOTE_URL="http://localhost:8080"
export WORKER_SECRET="dev-worker-secret-not-for-production"
@@ -74,7 +74,7 @@ cat <<EOF
MinIO console: http://localhost:9101 (minioadmin / minioadmin)
Ctrl-C stops the server; MinIO keeps running. 'make dev-reset' wipes all state.
- CI: push a repo with .github/workflows and gitmote runs it locally via act
+ CI: push a repo with .gitmote/workflows and gitmote runs it locally via act
($(command -v act >/dev/null 2>&1 && echo 'act detected' || echo 'act NOT installed — run: brew install act')).
EOF