Mirror forgejo actions into GitHub actions #214

Closed
opened 2026-06-19 03:50:19 +00:00 by coilysiren · 6 comments
Owner

And keep in sync with a linter

And keep in sync with a linter
coilyco-ops added
P4
and removed
P3
labels 2026-06-24 17:20:01 +00:00
Member

Re-triage (goose-triage method, claude-macos-kais-macbook-pro-2 as the judgment engine, 2026-06-24)

  • P4 - Mirror forgejo actions into GitHub actions + linter sync, speculative, icebox.
  • consult - Needs a design decision on the sync mechanism.
<!-- goose-triage --> **Re-triage** (goose-triage method, claude-macos-kais-macbook-pro-2 as the judgment engine, 2026-06-24) - **P4** - Mirror forgejo actions into GitHub actions + linter sync, speculative, icebox. - **consult** - Needs a design decision on the sync mechanism.
Member

Re-triage (goose-triage method, claude-macos-kais-macbook-pro-2 as the judgment engine, 2026-06-24)

  • P4 - Mirror forgejo actions into GitHub actions + linter sync, speculative, icebox.
  • consult - Needs a design decision on the sync mechanism.
<!-- goose-triage --> **Re-triage** (goose-triage method, claude-macos-kais-macbook-pro-2 as the judgment engine, 2026-06-24) - **P4** - Mirror forgejo actions into GitHub actions + linter sync, speculative, icebox. - **consult** - Needs a design decision on the sync mechanism.
Member

🔎 ward agent advisor

ward agent advisor --driver claude ran a one-shot standard research pass on this question:

Design the mechanism to mirror ward's .forgejo/workflows into .github/workflows kept in sync (ward#214, body: 'And keep in sync with a linter') and post a dispatchable spec as a comment. Read-only. Cover: which forgejo workflows need github equivalents (relate to ward#477 - the github mirror's releases/tags are frozen at v0.5.8 vs canonical v0.242.0, a symptom of missing/broken mirrored CI), how to keep them in sync (a linter/check that fails on drift - propose whether it is a pre-commit hook or a CI check and where it lives), and the forgejo-actions vs github-actions translation gotchas (syntax, runners, secrets). Phased issue breakdown. End noting it is a plan to verify. Repo voice rules (no em-dashes, no semicolons in prose, name the actor).


ward#214 - mirror .forgejo/workflows into .github/workflows with a drift linter

This is a plan to verify, not a landed design. Read-only research against a fresh clone of coilyco-flight-deck/ward. The headline finding reframes the issue: a naive .forgejo -> .github mirror is the wrong shape, and blindly copying the release pipeline to GitHub is precisely the footgun that ward#477 documents. Below is what actually needs mirroring, the sync mechanism, the translation gotchas pulled from the real files, and a phased breakdown.

Current state (what the clone shows)

  • .forgejo/workflows/ - release.yml (canonical release pipeline) and mirror-to-github.yml (the mirror engine).
  • .github/workflows/ - only test.yml (go vet, go test, golangci-lint on ubuntu-latest).
  • So the split today is: release + mirror run only on Forgejo, and test CI runs only on GitHub. There is no test workflow on Forgejo at all.

Reframe: not every forgejo workflow wants a github twin

Each workflow has an intended home. A drift linter's first job is to pin that classification, not to force symmetry.

  • mirror-to-github.yml - Forgejo-only, exempt. It force-pushes Forgejo refs and metadata into GitHub. Running it on GitHub is circular and meaningless. It must never gain a .github twin.
  • release.yml - Forgejo-only, exempt. Forgejo is canonical (per docs/release.md and docs/github-mirror.md). The GitHub release objects are created by this pipeline's own "Publish the same assets to the GitHub mirror release" step pushing to GitHub. A duplicate release.yml running on GitHub Actions would be a competing release pipeline - exactly the retired .github/workflows semantic-release that left stranded v0.5.x releases behind, which mirror-to-github.yml now has an author-guarded scrub step to clean up (ward#477). Duplicating it re-creates the disease.
  • test.yml - the only genuine sync candidate, and the direction is the reverse of the issue's framing. It lives on GitHub but not Forgejo, so canonical pushes (which land straight on Forgejo main) currently get no go vet / go test / golangci-lint. The useful work is mirroring GitHub -> Forgejo so canonical work is vetted before it is mirrored out.

On the ward#477 relationship: the question frames the frozen mirror (v0.5.8 vs canonical v0.242.0) as "a symptom of missing/broken mirrored CI." The clone's docs/github-mirror.md tells a different root cause worth confirming: the freeze came from (1) actions/checkout never fetching tag objects so git push --tags pushed nothing, (2) stranded v0.5.x release objects from the retired GitHub semantic-release, and (3) a silent exit 0 on a missing PAT hiding the whole thing behind a green check. That fix has already landed (the current mirror-to-github.yml has fetch-tags: true, the scrub step, and loud ::error + exit 1 guards). So ward#477 is a caution against running release CI on GitHub, not evidence that ward#214 needs a mirrored release job. Phase 0 below re-verifies the live mirror state.

Sync mechanism: a manifest plus a drift linter

Byte-for-byte mirroring fails because runners, secrets, and some steps are forge-specific. The workable shape is a declared manifest the linter enforces:

  • mirrored - source/target pairs plus the allowed mechanical transforms (runner label, mainly). The linter normalizes both sides through the transforms, diffs them, and fails on drift with the diff printed.
  • forgejo_only - exempt workflows with a one-line reason (release.yml, mirror-to-github.yml).
  • github_only - exempt workflows with a reason (test.yml, until Phase 2 promotes it to a mirrored pair).

The linter fails when:

  1. A workflow file in either directory appears in zero manifest entries. This is the key guard - it catches "someone added a workflow and never decided its home," which is how the stranded GitHub pipeline happened in the first place.
  2. A mirrored pair has drifted (source edited, target not, modulo declared transforms).
  3. An exemption names a file that no longer exists (stale exemption rots).

Pre-commit hook vs CI check, and where it lives

Recommendation: a pre-commit hook is the primary gate, and it also rides CI for free.

  • ward already runs a language: system python3 local hook (issue-refs -> scripts/lint_issue_refs.py) and carries [tool.agentic-os.*] opt-outs in pyproject.toml. So the cheapest, most idiomatic home is a ward-local pre-commit hook calling scripts/check_workflow_mirror.py, reading the manifest from [tool.agentic-os.workflow_mirror] in pyproject.toml (matching the existing convention). CLAUDE.md's rule that pre-commit run --all-files runs before every commit makes this the gate.
  • Because pre-commit can be bypassed, it should also run in CI. Once Phase 2 lands a Forgejo test workflow, that workflow runs pre-commit run --all-files (or the check directly), giving a server-side backstop on the canonical forge.
  • Authoring vs rollout (CLAUDE.md law): start ward-local because the manifest is ward-specific and ward is where the mirror pattern is most exercised (the first instance sets the schema). Only if a second mirror repo (agentic-os itself, homebrew-tap) needs the same check, promote it to a catalog hook agentic_os/check_workflow_mirror.py in agentic-os and roll it out via the managed hook block. Do not build the catalog version speculatively on the first occurrence.
  • ward is a Go project, so an alternative is a go test in internal/ that rides the existing test CI with zero new infra. It is elegant but only fires where go test runs (today: GitHub only), so it does not gate a local commit. The pre-commit hook is the better primary gate. A Go test can be added later as defense in depth.

Forgejo-actions vs github-actions translation gotchas (from the actual files)

  • Runners. Forgejo uses runs-on: docker - a bare container with no jq but with node present. GitHub uses runs-on: ubuntu-latest with a full toolcache. The node -e '...' JSON parsing all over release.yml and mirror-to-github.yml is a docker-runner workaround for the missing jq. It is unnecessary but harmless on ubuntu-latest. The runner label is the one mandatory transform for any mirrored pair.
  • Custom action resolution is the top fragility. release.yml uses coilysiren/agentic-os/actions/tag-bump@main and .../create-release@main, which are GitHub-org paths. Forgejo must be configured to resolve uses: against GitHub (or a mirror). The same uses: string means different resolution behavior per forge, and a mismatch fails at run start, not lint time.
  • Secrets do not cross forges. GITHUB_MIRROR_PAT, CI_RELEASE_TOKEN, and TAP_WRITE_TOKEN are provisioned only in ward -> Settings -> Actions -> Secrets on Forgejo. They are absent on GitHub. Any mirrored workflow that reads a secret works only where that secret is provisioned, which is the core structural reason release.yml is forge-pinned and cannot simply run on GitHub.
  • The GITHUB_TOKEN name collision. Forgejo auto-injects its own token also spelled GITHUB_TOKEN, scoped to the Forgejo instance. That name clash is exactly why the mirror uses an explicit PAT rather than the auto token. A mirrored workflow that leans on the auto GITHUB_TOKEN behaves differently on each forge.
  • What translates 1:1. Forgejo maintains GitHub compatibility for actions/checkout@v6, actions/setup-go@v6, the ::error:: / ::warning:: workflow commands, and the $GITHUB_OUTPUT / $GITHUB_PATH / $GITHUB_ENV files. test.yml uses only these plus golangci/golangci-lint-action, so it is the low-risk one to mirror.
  • Trigger semantics. on.pull_request differs (Forgejo PRs vs the read-only mirror's external-contributor PRs). on.push to main fires on Forgejo's canonical push and on GitHub's force-pushed mirror, so a mirrored test workflow could double-run. Worth a concurrency guard or accepting the redundant GitHub run as belt-and-suspenders.
  • permissions:. contents: write maps to whatever the instance token scope allows, which differs per forge.

Phased breakdown (single repo - ward)

  • Phase 0 - verify the premise (read-only). Confirm the live GitHub mirror release/tag state (is it still frozen, or did ward#477's landed fix already re-converge it), confirm Forgejo has no test CI, and confirm both forge-specific workflows are intentionally single-home. If the mirror is already current, ward#214's "broken mirrored CI" framing is superseded and the scope collapses to the linter plus the reverse test mirror.
  • Phase 1 - author the drift linter (no behavior change). Add scripts/check_workflow_mirror.py, a language: system local hook in .pre-commit-config.yaml, and the [tool.agentic-os.workflow_mirror] manifest declaring release.yml and mirror-to-github.yml as forgejo_only and test.yml as github_only. This immediately pins the classification and fails on any future undeclared workflow. Lowest risk, ships first, unblocks the rest.
  • Phase 2 - close the real gap. Add .forgejo/workflows/test.yml mirroring the GitHub test job (translate ubuntu-latest -> docker or a Go-capable Forgejo runner, and confirm Go plus golangci-lint are available on that runner), then flip the manifest entry for test to a mirrored pair. Canonical pushes now get vetted, and the linter keeps the pair in sync. Depends on Phase 1's manifest existing.
  • Phase 3 - optional promotion. If a second mirror repo needs the check, promote the linter to a catalog hook in agentic-os and roll it out via the managed block. Do not do this until the second occurrence exists.

Assumptions to verify before implementing

  • Whether the GitHub mirror is still frozen or ward#477's fix already re-converged it (changes whether ward#214 is still live).
  • Whether a Forgejo docker runner has Go and golangci-lint available for a mirrored test workflow, or whether a different runner label is needed.
  • Whether Forgejo resolves the coilysiren/agentic-os/actions/* custom actions the same way GitHub would (only matters if any custom-action workflow ever becomes a mirrored pair - none do under this plan).
  • Whether the on.push[main] double-run on the mirror is acceptable or wants a concurrency guard.

Researched and posted automatically by ward agent advisor --driver claude (ward#179). This is one-shot research, not a carried change - verify before acting on it.

— Claude (she/her), via ward agent

### 🔎 ward agent advisor `ward agent advisor --driver claude` ran a one-shot **standard** research pass on this question: > Design the mechanism to mirror ward's .forgejo/workflows into .github/workflows kept in sync (ward#214, body: 'And keep in sync with a linter') and post a dispatchable spec as a comment. Read-only. Cover: which forgejo workflows need github equivalents (relate to ward#477 - the github mirror's releases/tags are frozen at v0.5.8 vs canonical v0.242.0, a symptom of missing/broken mirrored CI), how to keep them in sync (a linter/check that fails on drift - propose whether it is a pre-commit hook or a CI check and where it lives), and the forgejo-actions vs github-actions translation gotchas (syntax, runners, secrets). Phased issue breakdown. End noting it is a plan to verify. Repo voice rules (no em-dashes, no semicolons in prose, name the actor). --- ## ward#214 - mirror `.forgejo/workflows` into `.github/workflows` with a drift linter **This is a plan to verify, not a landed design.** Read-only research against a fresh clone of `coilyco-flight-deck/ward`. The headline finding reframes the issue: a naive `.forgejo` -> `.github` mirror is the wrong shape, and blindly copying the release pipeline to GitHub is precisely the footgun that ward#477 documents. Below is what actually needs mirroring, the sync mechanism, the translation gotchas pulled from the real files, and a phased breakdown. ### Current state (what the clone shows) * `.forgejo/workflows/` - `release.yml` (canonical release pipeline) and `mirror-to-github.yml` (the mirror engine). * `.github/workflows/` - only `test.yml` (go vet, go test, golangci-lint on `ubuntu-latest`). * So the split today is: **release + mirror run only on Forgejo**, and **test CI runs only on GitHub**. There is no test workflow on Forgejo at all. ### Reframe: not every forgejo workflow wants a github twin Each workflow has an intended home. A drift linter's first job is to pin that classification, not to force symmetry. * `mirror-to-github.yml` - **Forgejo-only, exempt.** It force-pushes Forgejo refs and metadata *into* GitHub. Running it *on* GitHub is circular and meaningless. It must never gain a `.github` twin. * `release.yml` - **Forgejo-only, exempt.** Forgejo is canonical (per `docs/release.md` and `docs/github-mirror.md`). The GitHub release objects are created by this pipeline's own "Publish the same assets to the GitHub mirror release" step pushing *to* GitHub. A duplicate `release.yml` running on GitHub Actions would be a **competing** release pipeline - exactly the retired `.github/workflows` semantic-release that left stranded `v0.5.x` releases behind, which `mirror-to-github.yml` now has an author-guarded scrub step to clean up (ward#477). Duplicating it re-creates the disease. * `test.yml` - **the only genuine sync candidate, and the direction is the reverse of the issue's framing.** It lives on GitHub but not Forgejo, so canonical pushes (which land straight on Forgejo `main`) currently get **no** `go vet` / `go test` / `golangci-lint`. The useful work is mirroring GitHub -> Forgejo so canonical work is vetted before it is mirrored out. **On the ward#477 relationship:** the question frames the frozen mirror (v0.5.8 vs canonical v0.242.0) as "a symptom of missing/broken mirrored CI." The clone's `docs/github-mirror.md` tells a different root cause worth confirming: the freeze came from (1) `actions/checkout` never fetching tag objects so `git push --tags` pushed nothing, (2) stranded `v0.5.x` release objects from the *retired* GitHub semantic-release, and (3) a silent `exit 0` on a missing PAT hiding the whole thing behind a green check. That fix has **already landed** (the current `mirror-to-github.yml` has `fetch-tags: true`, the scrub step, and loud `::error` + `exit 1` guards). So ward#477 is a **caution against** running release CI on GitHub, not evidence that ward#214 needs a mirrored release job. Phase 0 below re-verifies the live mirror state. ### Sync mechanism: a manifest plus a drift linter Byte-for-byte mirroring fails because runners, secrets, and some steps are forge-specific. The workable shape is a **declared manifest** the linter enforces: * **`mirrored`** - source/target pairs plus the allowed mechanical transforms (runner label, mainly). The linter normalizes both sides through the transforms, diffs them, and fails on drift with the diff printed. * **`forgejo_only`** - exempt workflows with a one-line reason (`release.yml`, `mirror-to-github.yml`). * **`github_only`** - exempt workflows with a reason (`test.yml`, until Phase 2 promotes it to a `mirrored` pair). The linter fails when: 1. A workflow file in either directory appears in **zero** manifest entries. This is the key guard - it catches "someone added a workflow and never decided its home," which is how the stranded GitHub pipeline happened in the first place. 2. A `mirrored` pair has drifted (source edited, target not, modulo declared transforms). 3. An exemption names a file that no longer exists (stale exemption rots). ### Pre-commit hook vs CI check, and where it lives **Recommendation: a pre-commit hook is the primary gate, and it also rides CI for free.** * ward already runs a `language: system` python3 local hook (`issue-refs` -> `scripts/lint_issue_refs.py`) and carries `[tool.agentic-os.*]` opt-outs in `pyproject.toml`. So the cheapest, most idiomatic home is a **ward-local pre-commit hook** calling `scripts/check_workflow_mirror.py`, reading the manifest from `[tool.agentic-os.workflow_mirror]` in `pyproject.toml` (matching the existing convention). CLAUDE.md's rule that `pre-commit run --all-files` runs before every commit makes this the gate. * Because pre-commit can be bypassed, it should **also** run in CI. Once Phase 2 lands a Forgejo test workflow, that workflow runs `pre-commit run --all-files` (or the check directly), giving a server-side backstop on the canonical forge. * **Authoring vs rollout (CLAUDE.md law):** start ward-local because the manifest is ward-specific and ward is where the mirror pattern is most exercised (the first instance sets the schema). Only if a **second** mirror repo (agentic-os itself, homebrew-tap) needs the same check, promote it to a catalog hook `agentic_os/check_workflow_mirror.py` in agentic-os and roll it out via the managed hook block. Do not build the catalog version speculatively on the first occurrence. * ward is a Go project, so an alternative is a `go test` in `internal/` that rides the existing test CI with zero new infra. It is elegant but only fires where `go test` runs (today: GitHub only), so it does not gate a local commit. The pre-commit hook is the better primary gate. A Go test can be added later as defense in depth. ### Forgejo-actions vs github-actions translation gotchas (from the actual files) * **Runners.** Forgejo uses `runs-on: docker` - a bare container with **no `jq`** but with `node` present. GitHub uses `runs-on: ubuntu-latest` with a full toolcache. The `node -e '...'` JSON parsing all over `release.yml` and `mirror-to-github.yml` is a docker-runner workaround for the missing `jq`. It is unnecessary but harmless on `ubuntu-latest`. The runner label is the one mandatory transform for any mirrored pair. * **Custom action resolution is the top fragility.** `release.yml` uses `coilysiren/agentic-os/actions/tag-bump@main` and `.../create-release@main`, which are GitHub-org paths. Forgejo must be configured to resolve `uses:` against GitHub (or a mirror). The same `uses:` string means different resolution behavior per forge, and a mismatch fails at run start, not lint time. * **Secrets do not cross forges.** `GITHUB_MIRROR_PAT`, `CI_RELEASE_TOKEN`, and `TAP_WRITE_TOKEN` are provisioned only in ward -> Settings -> Actions -> Secrets on Forgejo. They are absent on GitHub. Any mirrored workflow that reads a secret works only where that secret is provisioned, which is the core structural reason `release.yml` is forge-pinned and cannot simply run on GitHub. * **The `GITHUB_TOKEN` name collision.** Forgejo auto-injects its own token also spelled `GITHUB_TOKEN`, scoped to the Forgejo instance. That name clash is exactly why the mirror uses an explicit PAT rather than the auto token. A mirrored workflow that leans on the auto `GITHUB_TOKEN` behaves differently on each forge. * **What translates 1:1.** Forgejo maintains GitHub compatibility for `actions/checkout@v6`, `actions/setup-go@v6`, the `::error::` / `::warning::` workflow commands, and the `$GITHUB_OUTPUT` / `$GITHUB_PATH` / `$GITHUB_ENV` files. `test.yml` uses only these plus `golangci/golangci-lint-action`, so it is the low-risk one to mirror. * **Trigger semantics.** `on.pull_request` differs (Forgejo PRs vs the read-only mirror's external-contributor PRs). `on.push` to `main` fires on Forgejo's canonical push **and** on GitHub's force-pushed mirror, so a mirrored test workflow could double-run. Worth a concurrency guard or accepting the redundant GitHub run as belt-and-suspenders. * **`permissions:`.** `contents: write` maps to whatever the instance token scope allows, which differs per forge. ### Phased breakdown (single repo - ward) * **Phase 0 - verify the premise (read-only).** Confirm the live GitHub mirror release/tag state (is it still frozen, or did ward#477's landed fix already re-converge it), confirm Forgejo has no test CI, and confirm both forge-specific workflows are intentionally single-home. If the mirror is already current, ward#214's "broken mirrored CI" framing is superseded and the scope collapses to the linter plus the reverse test mirror. * **Phase 1 - author the drift linter (no behavior change).** Add `scripts/check_workflow_mirror.py`, a `language: system` local hook in `.pre-commit-config.yaml`, and the `[tool.agentic-os.workflow_mirror]` manifest declaring `release.yml` and `mirror-to-github.yml` as `forgejo_only` and `test.yml` as `github_only`. This immediately pins the classification and fails on any future undeclared workflow. Lowest risk, ships first, unblocks the rest. * **Phase 2 - close the real gap.** Add `.forgejo/workflows/test.yml` mirroring the GitHub test job (translate `ubuntu-latest` -> `docker` or a Go-capable Forgejo runner, and confirm Go plus golangci-lint are available on that runner), then flip the manifest entry for test to a `mirrored` pair. Canonical pushes now get vetted, and the linter keeps the pair in sync. Depends on Phase 1's manifest existing. * **Phase 3 - optional promotion.** If a second mirror repo needs the check, promote the linter to a catalog hook in agentic-os and roll it out via the managed block. Do not do this until the second occurrence exists. ### Assumptions to verify before implementing * Whether the GitHub mirror is still frozen or ward#477's fix already re-converged it (changes whether ward#214 is still live). * Whether a Forgejo `docker` runner has Go and golangci-lint available for a mirrored test workflow, or whether a different runner label is needed. * Whether Forgejo resolves the `coilysiren/agentic-os/actions/*` custom actions the same way GitHub would (only matters if any custom-action workflow ever becomes a mirrored pair - none do under this plan). * Whether the `on.push[main]` double-run on the mirror is acceptable or wants a concurrency guard. --- Researched and posted automatically by `ward agent advisor --driver claude` (ward#179). This is one-shot research, not a carried change - verify before acting on it. <!-- ward-agent-reply --> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Member

🔎 ward agent advisor

ward agent advisor --driver claude ran a one-shot standard research pass on this question:

Design the mechanism to mirror ward's .forgejo/workflows into .github/workflows kept in sync (ward#214, body: 'And keep in sync with a linter') and post a dispatchable spec as a comment. Read-only. Cover: which forgejo workflows need github equivalents (relate to ward#477 - the github mirror's releases/tags are frozen at v0.5.8 vs canonical v0.242.0, a symptom of missing/broken mirrored CI), how to keep them in sync (a linter/check that fails on drift - propose whether it is a pre-commit hook or a CI check and where it lives), and the forgejo-actions vs github-actions translation gotchas (syntax, runners, secrets). Phased issue breakdown. End noting it is a plan to verify. Repo voice rules (no em-dashes, no semicolons in prose, name the actor).


ward#214 - mirror .forgejo/workflows into .github/workflows with a drift linter

This is a plan to verify, not a landed design. Claude (she/her) ran a fresh read-only clone of coilyco-flight-deck/ward and confirmed the advisor pass already on this thread against the live files. The findings below hold. Scope is a single repo (ward), so this ships as one comment, not fanned-out child issues.

Verified current state

  • .forgejo/workflows/ holds release.yml (17.8 KB canonical release pipeline) and mirror-to-github.yml (5.7 KB mirror engine). Neither has a GitHub twin.
  • .github/workflows/ holds only test.yml (go vet, go test, golangci-lint@v9 on ubuntu-latest, triggered on push to main and pull_request).
  • Forgejo has no test workflow. Canonical pushes land straight on Forgejo main and get no vet, no test, no lint until they reach the GitHub mirror.
  • mirror-to-github.yml already carries the ward#477 fix: fetch-tags: true, git push --force --tags github, loud ::error + exit 1 guards on a missing GITHUB_MIRROR_PAT, and an author-guarded scrub step that deletes the stranded legacy v0.5.x release objects. So the mirror freeze root cause is fixed in tree.

Reframe: the issue title has the direction backwards

A naive .forgejo -> .github copy is the wrong shape, and it is the exact footgun ward#477 documents. Each workflow has one intended home. The linter's first job is to pin that classification, not to force symmetry.

  • mirror-to-github.yml - Forgejo-only, exempt. It force-pushes Forgejo refs and metadata into GitHub. Running it on GitHub is circular. It must never gain a .github twin.
  • release.yml - Forgejo-only, exempt. Forgejo is canonical. A duplicate release.yml on GitHub Actions would be a competing release pipeline, which is precisely the retired .github semantic-release that stranded the v0.5.x releases that mirror-to-github.yml now scrubs. Duplicating it re-creates the disease.
  • test.yml - the only genuine sync candidate, and the useful direction is GitHub -> Forgejo. The real gap is that canonical work is unvetted on the canonical forge. Mirroring the test job onto Forgejo closes it.

On the ward#477 relationship: the frozen mirror (v0.5.8 vs canonical v0.242.0) was not caused by missing mirrored CI. The verified root cause per docs/github-mirror.md and the live workflow was (1) checkout never fetching tag objects so git push --tags pushed nothing, (2) stranded v0.5.x objects from the retired GitHub semantic-release, and (3) a silent exit 0 on a missing PAT hiding it behind a green check. All three are fixed in the current mirror-to-github.yml. So ward#477 is a caution against running release CI on GitHub, not evidence ward#214 needs a mirrored release job. Phase 0 re-verifies the live mirror release state before anything else.

Sync mechanism: a declared manifest plus a drift linter

Byte-for-byte mirroring fails because runners, secrets, and some steps are forge-specific. The workable shape is a manifest the linter enforces, living in [tool.agentic-os.workflow_mirror] in pyproject.toml (matching the existing [tool.agentic-os.*] opt-out convention already in the repo):

  • mirrored - source/target pairs plus the allowed mechanical transforms (runner label, mainly). The linter normalizes both sides through the transforms, diffs, and fails on drift with the diff printed.
  • forgejo_only - exempt workflows with a one-line reason (release.yml, mirror-to-github.yml).
  • github_only - exempt workflows with a reason (test.yml, until Phase 2 promotes it to a mirrored pair).

The linter fails when:

  1. A workflow file in either directory appears in zero manifest entries. This is the key guard. It catches "someone added a workflow and never decided its home," which is how the stranded GitHub pipeline happened.
  2. A mirrored pair has drifted (source edited, target not, modulo declared transforms).
  3. An exemption names a file that no longer exists (stale exemption rots).

Pre-commit hook vs CI check, and where it lives

Recommendation: a ward-local pre-commit hook is the primary gate, and it rides CI for free.

  • ward already runs a repo: local, language: system python3 hook (issue-refs -> scripts/lint_issue_refs.py). The idiomatic home is a sibling scripts/check_workflow_mirror.py wired as another repo: local language: system hook. CLAUDE.md's rule that pre-commit run --all-files runs before every commit makes this the gate. No new tooling dependency, python3 is already assumed.
  • Because pre-commit is bypassable, the check should also run server-side. Once Phase 2 lands a Forgejo test workflow, that workflow runs pre-commit run --all-files (or the check directly), giving a backstop on the canonical forge.
  • Authoring vs rollout (CLAUDE.md law): an engineer authors this ward-local because the manifest is ward-specific and ward is where the mirror pattern is most exercised. Only if a second mirror repo (agentic-os, homebrew-tap) needs the same check should someone promote it to a catalog hook agentic_os/check_workflow_mirror.py in agentic-os and roll it out via the managed block. Do not build the catalog version on the first occurrence.
  • A go test in internal/ is an elegant alternative that rides existing test CI with zero new infra, but it only fires where go test runs (today GitHub only), so it does not gate a local commit. The pre-commit hook is the better primary gate. A Go test can be added later as defense in depth.

Forgejo-actions vs github-actions translation gotchas (from the real files)

  • Runners. Forgejo uses runs-on: docker, a bare container with no jq but with node. GitHub uses runs-on: ubuntu-latest with a full toolcache. The node -e '...' JSON parsing throughout release.yml is a docker-runner workaround for the missing jq. The runner label is the one mandatory transform for any mirrored pair.
  • Custom action resolution is the top fragility. release.yml uses coilysiren/agentic-os/actions/tag-bump@main and .../create-release@main, GitHub-org paths. Forgejo must be configured to resolve uses: against GitHub. A mismatch fails at run start, not lint time. This only bites if a custom-action workflow ever becomes a mirrored pair, and none do under this plan.
  • Secrets do not cross forges. GITHUB_MIRROR_PAT, CI_RELEASE_TOKEN, and TAP_WRITE_TOKEN are provisioned only in ward -> Settings -> Actions -> Secrets on Forgejo. They are absent on GitHub. Any mirrored workflow that reads a secret works only where that secret exists, which is the structural reason release.yml is forge-pinned.
  • The GITHUB_TOKEN name collision. Forgejo auto-injects its own token also spelled GITHUB_TOKEN, scoped to the Forgejo instance. That clash is exactly why the mirror uses an explicit PAT. A mirrored workflow leaning on the auto token behaves differently per forge.
  • What translates 1:1. Forgejo keeps GitHub compatibility for actions/checkout@v6, actions/setup-go@v6, the ::error:: / ::warning:: commands, and the $GITHUB_OUTPUT / $GITHUB_PATH / $GITHUB_ENV files. test.yml uses only these plus golangci/golangci-lint-action, so it is the low-risk one to mirror.
  • Trigger semantics. on.push to main fires on Forgejo's canonical push and on GitHub's force-pushed mirror, so a mirrored test workflow could double-run. Worth a concurrency guard or accepting the redundant GitHub run as belt-and-suspenders.

Phased breakdown (single repo, ward)

  • Phase 0 - verify the premise (read-only). Confirm the live GitHub mirror release/tag state. The scrub and fetch-tags fix have landed in tree, so an engineer confirms whether the mirror has already re-converged to v0.242.0. If it has, ward#214's "broken mirrored CI" framing is superseded and scope collapses to the linter plus the reverse test mirror.
  • Phase 1 - author the drift linter (no behavior change). An engineer adds scripts/check_workflow_mirror.py, a repo: local language: system hook in .pre-commit-config.yaml, and the [tool.agentic-os.workflow_mirror] manifest declaring release.yml and mirror-to-github.yml as forgejo_only and test.yml as github_only. This pins the classification and fails on any future undeclared workflow. Lowest risk, ships first, unblocks the rest.
  • Phase 2 - close the real gap. An engineer adds .forgejo/workflows/test.yml mirroring the GitHub test job (translate ubuntu-latest -> docker or a Go-capable Forgejo runner, and confirm Go plus golangci-lint are available there), then flips the manifest entry for test to a mirrored pair. Canonical pushes now get vetted, and the linter keeps the pair in sync. Depends on Phase 1's manifest.
  • Phase 3 - optional promotion. If a second mirror repo needs the check, an engineer promotes the linter to a catalog hook in agentic-os and rolls it out via the managed block. Not before the second occurrence exists.

Assumptions to verify before implementing

  • Whether the GitHub mirror has already re-converged past v0.5.8 now that ward#477's fix is in tree (changes whether ward#214 is still live at all).
  • Whether a Forgejo docker runner has Go and golangci-lint available for a mirrored test workflow, or whether a different runner label is needed.
  • Whether the on.push[main] double-run on the mirror is acceptable or wants a concurrency guard.

This is a plan to verify, not a landed change. An engineer should confirm the Phase 0 assumptions before acting on it.


Researched and posted automatically by ward agent advisor --driver claude (ward#179). This is one-shot research, not a carried change - verify before acting on it.

— Claude (she/her), via ward agent

### 🔎 ward agent advisor `ward agent advisor --driver claude` ran a one-shot **standard** research pass on this question: > Design the mechanism to mirror ward's .forgejo/workflows into .github/workflows kept in sync (ward#214, body: 'And keep in sync with a linter') and post a dispatchable spec as a comment. Read-only. Cover: which forgejo workflows need github equivalents (relate to ward#477 - the github mirror's releases/tags are frozen at v0.5.8 vs canonical v0.242.0, a symptom of missing/broken mirrored CI), how to keep them in sync (a linter/check that fails on drift - propose whether it is a pre-commit hook or a CI check and where it lives), and the forgejo-actions vs github-actions translation gotchas (syntax, runners, secrets). Phased issue breakdown. End noting it is a plan to verify. Repo voice rules (no em-dashes, no semicolons in prose, name the actor). --- ## ward#214 - mirror `.forgejo/workflows` into `.github/workflows` with a drift linter **This is a plan to verify, not a landed design.** Claude (she/her) ran a fresh read-only clone of `coilyco-flight-deck/ward` and confirmed the advisor pass already on this thread against the live files. The findings below hold. Scope is a single repo (ward), so this ships as one comment, not fanned-out child issues. ### Verified current state * `.forgejo/workflows/` holds `release.yml` (17.8 KB canonical release pipeline) and `mirror-to-github.yml` (5.7 KB mirror engine). Neither has a GitHub twin. * `.github/workflows/` holds only `test.yml` (`go vet`, `go test`, `golangci-lint@v9` on `ubuntu-latest`, triggered on push to main and pull_request). * Forgejo has **no** test workflow. Canonical pushes land straight on Forgejo `main` and get no vet, no test, no lint until they reach the GitHub mirror. * `mirror-to-github.yml` **already carries the ward#477 fix**: `fetch-tags: true`, `git push --force --tags github`, loud `::error` + `exit 1` guards on a missing `GITHUB_MIRROR_PAT`, and an author-guarded scrub step that deletes the stranded legacy `v0.5.x` release objects. So the mirror freeze root cause is fixed in tree. ### Reframe: the issue title has the direction backwards A naive `.forgejo` -> `.github` copy is the wrong shape, and it is the exact footgun ward#477 documents. Each workflow has one intended home. The linter's first job is to pin that classification, not to force symmetry. * `mirror-to-github.yml` - **Forgejo-only, exempt.** It force-pushes Forgejo refs and metadata into GitHub. Running it on GitHub is circular. It must never gain a `.github` twin. * `release.yml` - **Forgejo-only, exempt.** Forgejo is canonical. A duplicate `release.yml` on GitHub Actions would be a competing release pipeline, which is precisely the retired `.github` semantic-release that stranded the `v0.5.x` releases that `mirror-to-github.yml` now scrubs. Duplicating it re-creates the disease. * `test.yml` - **the only genuine sync candidate, and the useful direction is GitHub -> Forgejo.** The real gap is that canonical work is unvetted on the canonical forge. Mirroring the test job onto Forgejo closes it. **On the ward#477 relationship:** the frozen mirror (v0.5.8 vs canonical v0.242.0) was not caused by missing mirrored CI. The verified root cause per `docs/github-mirror.md` and the live workflow was (1) checkout never fetching tag objects so `git push --tags` pushed nothing, (2) stranded `v0.5.x` objects from the retired GitHub semantic-release, and (3) a silent `exit 0` on a missing PAT hiding it behind a green check. All three are fixed in the current `mirror-to-github.yml`. So ward#477 is a **caution against** running release CI on GitHub, not evidence ward#214 needs a mirrored release job. Phase 0 re-verifies the live mirror release state before anything else. ### Sync mechanism: a declared manifest plus a drift linter Byte-for-byte mirroring fails because runners, secrets, and some steps are forge-specific. The workable shape is a manifest the linter enforces, living in `[tool.agentic-os.workflow_mirror]` in `pyproject.toml` (matching the existing `[tool.agentic-os.*]` opt-out convention already in the repo): * **`mirrored`** - source/target pairs plus the allowed mechanical transforms (runner label, mainly). The linter normalizes both sides through the transforms, diffs, and fails on drift with the diff printed. * **`forgejo_only`** - exempt workflows with a one-line reason (`release.yml`, `mirror-to-github.yml`). * **`github_only`** - exempt workflows with a reason (`test.yml`, until Phase 2 promotes it to a `mirrored` pair). The linter fails when: 1. A workflow file in either directory appears in **zero** manifest entries. This is the key guard. It catches "someone added a workflow and never decided its home," which is how the stranded GitHub pipeline happened. 2. A `mirrored` pair has drifted (source edited, target not, modulo declared transforms). 3. An exemption names a file that no longer exists (stale exemption rots). ### Pre-commit hook vs CI check, and where it lives **Recommendation: a ward-local pre-commit hook is the primary gate, and it rides CI for free.** * ward already runs a `repo: local`, `language: system` python3 hook (`issue-refs` -> `scripts/lint_issue_refs.py`). The idiomatic home is a sibling `scripts/check_workflow_mirror.py` wired as another `repo: local` `language: system` hook. CLAUDE.md's rule that `pre-commit run --all-files` runs before every commit makes this the gate. No new tooling dependency, python3 is already assumed. * Because pre-commit is bypassable, the check should also run server-side. Once Phase 2 lands a Forgejo test workflow, that workflow runs `pre-commit run --all-files` (or the check directly), giving a backstop on the canonical forge. * **Authoring vs rollout (CLAUDE.md law):** an engineer authors this ward-local because the manifest is ward-specific and ward is where the mirror pattern is most exercised. Only if a **second** mirror repo (agentic-os, homebrew-tap) needs the same check should someone promote it to a catalog hook `agentic_os/check_workflow_mirror.py` in agentic-os and roll it out via the managed block. Do not build the catalog version on the first occurrence. * A `go test` in `internal/` is an elegant alternative that rides existing test CI with zero new infra, but it only fires where `go test` runs (today GitHub only), so it does not gate a local commit. The pre-commit hook is the better primary gate. A Go test can be added later as defense in depth. ### Forgejo-actions vs github-actions translation gotchas (from the real files) * **Runners.** Forgejo uses `runs-on: docker`, a bare container with no `jq` but with `node`. GitHub uses `runs-on: ubuntu-latest` with a full toolcache. The `node -e '...'` JSON parsing throughout `release.yml` is a docker-runner workaround for the missing `jq`. The runner label is the one mandatory transform for any mirrored pair. * **Custom action resolution is the top fragility.** `release.yml` uses `coilysiren/agentic-os/actions/tag-bump@main` and `.../create-release@main`, GitHub-org paths. Forgejo must be configured to resolve `uses:` against GitHub. A mismatch fails at run start, not lint time. This only bites if a custom-action workflow ever becomes a mirrored pair, and none do under this plan. * **Secrets do not cross forges.** `GITHUB_MIRROR_PAT`, `CI_RELEASE_TOKEN`, and `TAP_WRITE_TOKEN` are provisioned only in ward -> Settings -> Actions -> Secrets on Forgejo. They are absent on GitHub. Any mirrored workflow that reads a secret works only where that secret exists, which is the structural reason `release.yml` is forge-pinned. * **The `GITHUB_TOKEN` name collision.** Forgejo auto-injects its own token also spelled `GITHUB_TOKEN`, scoped to the Forgejo instance. That clash is exactly why the mirror uses an explicit PAT. A mirrored workflow leaning on the auto token behaves differently per forge. * **What translates 1:1.** Forgejo keeps GitHub compatibility for `actions/checkout@v6`, `actions/setup-go@v6`, the `::error::` / `::warning::` commands, and the `$GITHUB_OUTPUT` / `$GITHUB_PATH` / `$GITHUB_ENV` files. `test.yml` uses only these plus `golangci/golangci-lint-action`, so it is the low-risk one to mirror. * **Trigger semantics.** `on.push` to `main` fires on Forgejo's canonical push and on GitHub's force-pushed mirror, so a mirrored test workflow could double-run. Worth a concurrency guard or accepting the redundant GitHub run as belt-and-suspenders. ### Phased breakdown (single repo, ward) * **Phase 0 - verify the premise (read-only).** Confirm the live GitHub mirror release/tag state. The scrub and fetch-tags fix have landed in tree, so an engineer confirms whether the mirror has already re-converged to v0.242.0. If it has, ward#214's "broken mirrored CI" framing is superseded and scope collapses to the linter plus the reverse test mirror. * **Phase 1 - author the drift linter (no behavior change).** An engineer adds `scripts/check_workflow_mirror.py`, a `repo: local` `language: system` hook in `.pre-commit-config.yaml`, and the `[tool.agentic-os.workflow_mirror]` manifest declaring `release.yml` and `mirror-to-github.yml` as `forgejo_only` and `test.yml` as `github_only`. This pins the classification and fails on any future undeclared workflow. Lowest risk, ships first, unblocks the rest. * **Phase 2 - close the real gap.** An engineer adds `.forgejo/workflows/test.yml` mirroring the GitHub test job (translate `ubuntu-latest` -> `docker` or a Go-capable Forgejo runner, and confirm Go plus golangci-lint are available there), then flips the manifest entry for test to a `mirrored` pair. Canonical pushes now get vetted, and the linter keeps the pair in sync. Depends on Phase 1's manifest. * **Phase 3 - optional promotion.** If a second mirror repo needs the check, an engineer promotes the linter to a catalog hook in agentic-os and rolls it out via the managed block. Not before the second occurrence exists. ### Assumptions to verify before implementing * Whether the GitHub mirror has already re-converged past v0.5.8 now that ward#477's fix is in tree (changes whether ward#214 is still live at all). * Whether a Forgejo `docker` runner has Go and golangci-lint available for a mirrored test workflow, or whether a different runner label is needed. * Whether the `on.push[main]` double-run on the mirror is acceptable or wants a concurrency guard. **This is a plan to verify, not a landed change. An engineer should confirm the Phase 0 assumptions before acting on it.** --- Researched and posted automatically by `ward agent advisor --driver claude` (ward#179). This is one-shot research, not a carried change - verify before acting on it. <!-- ward-agent-reply --> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Member

🔒 Reserved by ward agent --driver claude — container engineer-claude-ward-214 on host kais-macbook-pro-2.local is carrying this issue (reserved 2026-07-02T17:40:04Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `engineer-claude-ward-214` on host `kais-macbook-pro-2.local` is carrying this issue (reserved 2026-07-02T17:40:04Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Author
Owner

⚠️ Reopened: this run's work did not land on main

An ephemeral ward container (claude mode) dispatched for this issue finished but its work was not merged to main, so the reaper preserved it on a branch before teardown and reopened the issue (its closes #214 never reached main). Recover from the salvage branch below.

  • Repo: coilyco-flight-deck/ward
  • Salvage branch: ward-salvage/ward-33ebc07c
  • Reason: merge conflict integrating onto main
  • Container uptime at reap: 7m (age of the baked Forgejo PAT snapshot; a long-lived container is likelier to carry a rotated token)

Reap diagnostics

--- reap diagnostics ---
ward version:      v0.319.0
version source:    pinned via WARD_VERSION/--ward-version (v0.319.0)
HEAD:              f635d287f3cb
origin/main:       3228f8657c4d
ancestry:          HEAD is NOT yet on origin/main - residual work remains to land
decision gate:     provenance missing or unreadable
reason:            merge conflict integrating onto main
provenance:        missing or unreadable
run-owned landed:  no
working tree:      10 dirty path(s)
container uptime:  7m (baked Forgejo PAT age proxy)
--- end reap diagnostics ---

Recover

git fetch https://forgejo.coilysiren.me/coilyco-flight-deck/ward.git ward-salvage/ward-33ebc07c
git checkout -b ward-salvage/ward-33ebc07c FETCH_HEAD

Working tree at reap time

A  .forgejo/workflows/test.yml
M  .github/workflows/test.yml
M  .pre-commit-config.yaml
M  .ward/ward.yaml
M  Makefile
M  docs/FEATURES.md
M  docs/README.md
A  docs/workflow-mirror.md
A  scripts/check_workflow_mirror.py
A  scripts/check_workflow_mirror_test.go

— Claude (she/her), via ward agent

## ⚠️ Reopened: this run's work did not land on `main` An ephemeral `ward container` (claude mode) dispatched for this issue finished but its work was **not merged to `main`**, so the reaper preserved it on a branch before teardown and reopened the issue (its `closes #214` never reached `main`). Recover from the salvage branch below. - **Repo:** `coilyco-flight-deck/ward` - **Salvage branch:** `ward-salvage/ward-33ebc07c` - **Reason:** merge conflict integrating onto main - **Container uptime at reap:** 7m (age of the baked Forgejo PAT snapshot; a long-lived container is likelier to carry a rotated token) ## Reap diagnostics ``` --- reap diagnostics --- ward version: v0.319.0 version source: pinned via WARD_VERSION/--ward-version (v0.319.0) HEAD: f635d287f3cb origin/main: 3228f8657c4d ancestry: HEAD is NOT yet on origin/main - residual work remains to land decision gate: provenance missing or unreadable reason: merge conflict integrating onto main provenance: missing or unreadable run-owned landed: no working tree: 10 dirty path(s) container uptime: 7m (baked Forgejo PAT age proxy) --- end reap diagnostics --- ``` ## Recover ```bash git fetch https://forgejo.coilysiren.me/coilyco-flight-deck/ward.git ward-salvage/ward-33ebc07c git checkout -b ward-salvage/ward-33ebc07c FETCH_HEAD ``` ## Working tree at reap time ``` A .forgejo/workflows/test.yml M .github/workflows/test.yml M .pre-commit-config.yaml M .ward/ward.yaml M Makefile M docs/FEATURES.md M docs/README.md A docs/workflow-mirror.md A scripts/check_workflow_mirror.py A scripts/check_workflow_mirror_test.go ``` <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
coilyco-flight-deck/ward#214
No description provided.