- **ADR 0010** (accepted): the build configuration stays expressible as a Forgejo Actions workflow. A new build-definition key needs a workflow counterpart; host-side keys are exempt. - **RFC 0006** (proposed): one workflow for Forgejo and Werkator, and the steps towards it. Step 1 is implemented here. - **`werkator export:forgejo`**, wrapped by **`tools/werkator-to-forgejo`**: converts the committed `.werkator.yml` (never the `.git` layers or `~/.werkator.yml`) into `.forgejo/workflows/werkator-<build>.yml`, one per definition that triggers on its own, follow-ups as jobs with `needs`. Untranslatable constructs become comments and stderr lines; `--strict` refuses. - `AGENTS.md`: the ADR 0010 rule as a hard invariant; column-aligned Markdown tables. - v1.28.0 On Werkator's own `.werkator.yml` the converter names two constructs: `builds.deploy` has no committed trigger (the host adds it), and `werkdock.env` is sandbox environment. PR-doc: `docs/prs/2026-10-03-PR#92-forgejo-actions-compatible-config.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: mhoennig <michael@hoennig.de> Reviewed-on: #92
17 KiB
RFC 0006: One Workflow for Forgejo and Werkator
Status:
- proposed: 2026-10-03
- accepted: -
- rejected: -
- implemented: step 1 on 2026-10-03 (PR#92)
Proposal: Werkator reads a defined subset of the Forgejo Actions workflow format, so that a developer can write one workflow for their own project that runs on Forgejo and on Werkator unchanged. Werkator does not accept arbitrary foreign workflows: the subset is a contract for files written on purpose, and everything outside it is refused by name. This is the first step of ADR 0010.
Context and Problem Statement
A project that builds on Werkator describes its CI in .werkator.yml.
The same project on Forgejo needs .forgejo/workflows/*.yml as well.
Two descriptions of the same build drift apart, and moving a project from one system to the other means writing its CI anew.
ADR 0010 keeps Werkator's build configuration expressible as a workflow. That alone does not let one file serve both systems: Werkator has to read the file.
Goals
- One workflow file, unchanged, gives the same build on Forgejo Actions and on Werkator.
- The subset is small, documented, and checked: a file outside it fails loudly, naming the construct.
- The host keeps its policy: sandbox, Docker, network, credentials and trust stay pinned, whatever the workflow says.
Non-Goals
- No action runtime:
uses:works only for the few actions listed below, which Werkator replaces by its own means. - No expression language beyond what the subset names.
- No artifact or cache server API.
- No reading of every workflow in every repository: a repository opts in.
The Proposal
Opt-in and file location
The committed .werkator.yml names the workflow files Werkator reads, for example workflows: [".forgejo/workflows/ci.yml"].
A repository with workflows written for Forgejo alone is therefore never refused, and a branch can try out a workflow like any other branch configuration.
The workflow is read from the branch being built, like the committed .werkator.yml itself (git show for the watcher, the worktree at build time).
Position in the layers
The translated workflow is a layer of its own, directly below the committed .werkator.yml of the same branch.
The .werkator.yml therefore wins, and can add what the workflow cannot say (activeWithin, docker.dockerfile).
The pinned keys are stripped and written back from the host's layers exactly as for a branch layer today, so a workflow can neither enable nor disable a sandbox.
The subset
Each job becomes a build definition named <workflow>/<job>.
The subset covers what the converter of step 1 writes, so its output reads back.
| Workflow | Werkator | Notes |
|---|---|---|
on.push.branches, on.push.branches-ignore |
trigger.onPush, trigger.branches (! for ignore) |
globs as Forgejo reads them |
on.schedule with cron: "M H * * *" |
trigger.atTimes: ["HH:MM"] |
other cron forms refused |
on.workflow_dispatch |
accepted, no trigger | a manual build exists anyway |
jobs.<id>.needs: <job> |
trigger.afterSuccessOf |
one predecessor |
jobs.<id>.if: github.ref_name == '…' || … |
trigger.branches of a follow-up |
literal names only |
jobs.<id>.runs-on, jobs.<id>.name |
accepted, no effect | the host decides the runtime |
jobs.<id>.container.image |
docker.image |
effective only where the host enables Docker |
jobs.<id>.env, container.env |
docker.env and the build environment |
|
jobs.<id>.timeout-minutes |
timeout |
the host's executor.buildTimeout still caps it |
steps[].run, name, env, working-directory |
one script as buildCommand |
bash -e, as Forgejo runs run by default |
uses: actions/checkout@… |
nothing | the worktree is checked out already |
uses: actions/upload-artifact@… with path |
artifactDirs |
name and retention ignored |
if: always() on the upload step |
nothing | Werkator stores the artifacts of a failed build too |
Everything else is refused for that workflow: other events and filters (pull_request, tags, paths), other uses:, strategy.matrix, services, outputs, if beyond the two forms above, continue-on-error, several needs, secrets.*, and every ${{ … }} expression.
A refusal fails the builds of that branch, like a branch configuration that Werkator must not read today.
Environment
A workflow step expects some variables.
Werkator sets a compatible subset in every build of a translated job: CI=true, GITHUB_SHA, GITHUB_REF_NAME, GITHUB_WORKSPACE, and their FORGEJO_* twins.
Open Questions
- Does Forgejo read
.github/workflowstoo, so that one directory serves Forgejo and Gitea, whose Actions read.gitea/workflowsor.github/workflows? Which default should the converter write to? - Does Gitea Actions take the subset unchanged,
upload-artifact@v3included? - Should
on.pull_requestmap to the pull-request gate, which is host policy today? - How should a job with
container.imagebehave on a werkdock host: run in the host's rootfs, or be refused? - Should
uses: actions/cachemap to the Gradle cache volume, or stay refused? - Is the workspace difference acceptable: Forgejo checks out fresh per job, Werkator reuses the worktree after its
cleanCommand? - Which name does the Gitea commit status get, Werkator's
statusContextor Forgejo's<workflow> / <job> (<event>)?
Trial Run on Existing Repositories
On 2026-10-03, export:forgejo converted the committed .werkator.yml of every repository the mih09 instance builds — the seven public ones on git.javagil.de and the private michael.hoennig.de — and of hs.hsadmin.ng on dev.hostsharing.net, which vm4006 builds.
Every file converted without an error.
The table lists what the converter named, and what it could not see, because it is not in the committed file.
| Repository | Workflows | Named by the converter | Not visible in the committed file |
|---|---|---|---|
| werkator | 3 | deploy without trigger, activeWithin, werkdock.env, a status context |
JDK 21 rootfs, Testcontainers socket, the self-deployment |
| werkbaum | 2 | two status contexts | Node and JDK toolchain |
| werkcrappit | 2 | publish without trigger |
publish builds on the worktree of default |
| werkdock | 1 | nothing | Go toolchain |
| werkjournal | 2 | werkdock sandbox and rootfs, werkdock.env, two status contexts |
JDK 25 and Playwright in the rootfs |
| werkprobe | 1 | werkdock.env |
Testcontainers socket |
| keluna | 1 | a follow-up that builds on its predecessor's worktree | JDK and Node toolchain |
| michael.hoennig.de | 3 | werkdock.env of two deployments, two status contexts |
deployments write into the host's filesystem |
| hs.hsadmin.ng | 2 | Dockerfile, docker.network: host, docker.socket |
the locally built image, the Testcontainers host (PR#86) |
The trial changed the converter in this PR: a builds.default without a trigger is the settings base and gets no workflow, a Dockerfile is named even when the image has a name, werkdock.env is converted instead of dropped, and a follow-up with an empty cleanCommand is named.
The problems it found, most consequential first:
- Both forges run Gitea 1.27.3, not Forgejo.
Gitea Actions reads
.gitea/workflows, or.github/workflowswhen that is missing, but not.forgejo/workflows. For now,--output-dir .gitea/workflowswrites where Gitea reads. Whether.github/workflowsserves both forges, and which default the converter should have, is an open question. - The toolchain lives on the host.
A werkdock rootfs or a Dockerfile provides JDK 21 or 25, Go, Node and Playwright; mostly the host config names it, not the committed file.
A runner's default image has none of them, so every converted job needs a runner label or a
container.imagewith the same toolchain. - Locally built images are in no registry.
hs.hsadmin.ng builds
hsadmin-ng-build-env:latestfrom its Dockerfile on the Werkator host; a runner can only pull an image from a registry. - Testcontainers needs a Docker engine.
werkator, werkprobe and hs.hsadmin.ng get one from Werkator: the host's socket or werkdock's API socket, and
TESTCONTAINERS_HOST_OVERRIDEfrom rootlesskit. None of that is in a workflow, and the convertedDOCKER_HOST=unix:///root/werkdock.sockis wrong on a runner; the runner must provide the socket. - Deployments run on the Werkator host. werkator installs itself and restarts the instance, werkcrappit publishes, and michael.hoennig.de copies its site into the host's filesystem. On a runner each needs credentials and remote access, and Werkator has no secrets concept to convert; their triggers are host policy and not in the committed file either.
- Definitions share a worktree.
keluna's samples, werkator's
deployand werkcrappit'spublishkeep the predecessor's build output (cleanCommand: ""). Jobs share no files, so the output has to travel as an artifact; the converter can name it only where the follow-up is committed. - Werkator has no environment outside a sandbox.
werkdock.envanddocker.envmix values of the sandbox (PATH,JAVA_HOME,DOCKER_HOST) with values of the project (DEPLOY_PREVIEW_PATH,WERKJOURNAL_CI_DEPLOY). - Status contexts change.
Five repositories name their own (
werkator/mutation,werkator/backend, …); Gitea Actions names a status after workflow and job, so a branch protection rule that requires a context breaks on the switch. - Repository keys are not mentioned.
watcher.newBranchMaxAge,watcher.pullRequestGateand the artifact retention are not build-definition keys, so the converter passes over them without a word. - No cache.
Werkator keeps a Gradle cache per repository or a shared Gradle home; a runner starts empty, and the nightly
releaseof hs.hsadmin.ng already needs most of its 60 minutes.
Steps
The steps move .werkator.yml and Werkator's features towards Forgejo compatibility before Werkator reads any workflow.
Each step is useful on its own, and each is one pull request unless it says otherwise.
The early steps need no change to the build model; the later ones decide about the deviations ADR 0010 lists.
Step 1: tools/werkator-to-forgejo converts a .werkator.yml into workflows
Implemented in PR#92.
tools/werkator-to-forgejo runs Werkator's export:forgejo command, so the file is bound exactly as Werkator binds it, and the tests run in the existing Kotest suite.
It takes the target repository, or any directory inside it, reads the .werkator.yml in its root, and writes the workflows into it.
It reads the committed file only: host layers are runner and instance configuration in Forgejo terms, and they hold secrets.
A trigger belongs to a workflow in Forgejo, but to a build definition in Werkator.
So every definition that triggers on its own becomes a workflow, .forgejo/workflows/werkator-<build>.yml, and its follow-ups become jobs of that workflow with needs.
A job inherits nothing in a workflow, so each one is written out with the effective settings, builds.default applied.
What the tool cannot translate it writes as a comment where it belongs, and it lists the same constructs on stderr; --strict writes nothing and exits 1 instead.
Why first:
- It shows today, on every real
.werkator.yml, how far the format is from the workflow format, and it keeps showing it as the format changes. - It makes ADR 0010's rule checkable: a new build-definition key is not done until the converter translates it.
- It gives a project a way out to Forgejo, which is the cheapest proof that the two formats describe the same thing.
On Werkator's own .werkator.yml it reports two constructs: builds.deploy has no trigger in the committed file, because the host adds it, and werkdock.env is the environment of Werkator's sandbox.
Step 2: Werkator sets the variables a workflow step expects
CI=true, GITHUB_SHA, GITHUB_REF_NAME, GITHUB_WORKSPACE, and their FORGEJO_* twins, in every build.
A build script written for Forgejo then finds what it looks for, with or without a translated workflow.
This needs no new configuration key.
Step 3: Close the deviations that are cheap to close
Each one in its own pull request, each one checked with the converter:
trigger.atTimeslearns the cron subset the translator accepts, soon.scheduleand Werkator mean the same times.trigger.afterSuccessOftakes a list, likeneeds; the follow-up runs after the last of its predecessors went green on the same commit.buildCommandgets an alternative: a list of named steps, each withrun, which the converter maps one to one tosteps[]and the build log shows as sections.- A build definition gets an
envof its own, independent of the sandbox, for the values of the project;docker.envandwerkdock.envkeep the values of the sandbox (trial, problem 7). - The converter names the repository keys it passes over (trial, problem 9).
Step 3b: Build environments as images
The werkdock rootfs archives and the Dockerfiles become images in the forge's container registry, so a runner label or a container.image can name the same toolchain (trial, problems 2 and 3).
A rootfs archive imports as an image with docker import; Werkator itself keeps using the archive.
Step 4: Decide about the deviations that stay
trigger.activeWithin, docker.dockerfile and docker.context, the log names and the retention keys have no workflow counterpart.
Deployments on the Werkator host and the Docker engine Werkator hands a build need a decision too: secrets and runner configuration, or a Werkator extension (trial, problems 4 and 5).
Each gets a recorded decision: kept as a Werkator extension in .werkator.yml (which wins over a translated workflow), reshaped, or dropped.
The converter writes the kept ones as comments, so a project moving to Forgejo knows what it loses.
Step 5: Werkator reads the subset
The translator of The Proposal: opt-in, layer position, subset, refusals; with a test per row of the subset table.
The converter's fixtures become round-trip tests: .werkator.yml to workflow and back must give the same build definitions.
Step 6: One workflow on both systems, in production
Documentation and an example workflow that runs on Forgejo and on Werkator. A repository on git.javagil.de builds with it, if Gitea Actions takes the same subset; otherwise a repository on a Forgejo instance.
Later: one source
Once a workflow can say everything a project's .werkator.yml says, a new ADR may decide whether the workflow becomes the primary format and .werkator.yml the place for Werkator extensions only.
This RFC does not decide that.