# 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](../adrs/0010-2026-10-03.forgejo-actions-compatible-config.md). ## 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 `/`. 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..needs: ` | `trigger.afterSuccessOf` | one predecessor | | `jobs..if: github.ref_name == '…' \|\| …` | `trigger.branches` of a follow-up | literal names only | | `jobs..runs-on`, `jobs..name` | accepted, no effect | the host decides the runtime | | `jobs..container.image` | `docker.image` | effective only where the host enables Docker | | `jobs..env`, `container.env` | `docker.env` and the build environment | | | `jobs..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/workflows` too, so that one directory serves Forgejo and Gitea, whose Actions read `.gitea/workflows` or `.github/workflows`? Which default should the converter write to? - Does Gitea Actions take the subset unchanged, `upload-artifact@v3` included? - Should `on.pull_request` map to the pull-request gate, which is host policy today? - How should a job with `container.image` behave on a werkdock host: run in the host's rootfs, or be refused? - Should `uses: actions/cache` map 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 `statusContext` or Forgejo's ` / ()`? ## 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: 1. **Both forges run Gitea 1.27.3, not Forgejo.** Gitea Actions reads `.gitea/workflows`, or `.github/workflows` when that is missing, but not `.forgejo/workflows`. For now, `--output-dir .gitea/workflows` writes where Gitea reads. Whether `.github/workflows` serves both forges, and which default the converter should have, is an open question. 2. **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.image` with the same toolchain. 3. **Locally built images are in no registry.** hs.hsadmin.ng builds `hsadmin-ng-build-env:latest` from its Dockerfile on the Werkator host; a runner can only pull an image from a registry. 4. **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_OVERRIDE` from rootlesskit. None of that is in a workflow, and the converted `DOCKER_HOST=unix:///root/werkdock.sock` is wrong on a runner; the runner must provide the socket. 5. **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. 6. **Definitions share a worktree.** keluna's samples, werkator's `deploy` and werkcrappit's `publish` keep 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. 7. **Werkator has no environment outside a sandbox.** `werkdock.env` and `docker.env` mix values of the sandbox (`PATH`, `JAVA_HOME`, `DOCKER_HOST`) with values of the project (`DEPLOY_PREVIEW_PATH`, `WERKJOURNAL_CI_DEPLOY`). 8. **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. 9. **Repository keys are not mentioned.** `watcher.newBranchMaxAge`, `watcher.pullRequestGate` and the artifact retention are not build-definition keys, so the converter passes over them without a word. 10. **No cache.** Werkator keeps a Gradle cache per repository or a shared Gradle home; a runner starts empty, and the nightly `release` of 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-.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.atTimes` learns the cron subset the translator accepts, so `on.schedule` and Werkator mean the same times. - `trigger.afterSuccessOf` takes a list, like `needs`; the follow-up runs after the last of its predecessors went green on the same commit. - `buildCommand` gets an alternative: a list of named steps, each with `run`, which the converter maps one to one to `steps[]` and the build log shows as sections. - A build definition gets an `env` of its own, independent of the sandbox, for the values of the project; `docker.env` and `werkdock.env` keep 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](#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.