How yesod publishes yesod-mirror

Copybara on a nightly Forgejo Actions job, run through Bazel. A write-up for someone setting up the same pattern on their own forge and CI.

Space for context from the operator: why the mirror exists, who reads it, anything that isn't in the code.

1. The pieces at a glance

yesod is a private monorepo on a self-hosted Forgejo. yesod-mirror is a public repo on the same Forgejo that holds a filtered snapshot of yesod's main. Once a night a CI job runs Copybara. Copybara reads yesod, drops the paths that must not leave the private repo, renames one directory, and pushes the result as a single new commit to the mirror. A Sourcebot instance, source-mirror, indexes the mirror, so the public subset can be browsed and searched. Every link in this document goes through it.

yesod private · main Nightly CI job bazel run //tools/copybara copy.bara.sky: exclude · move · squash yesod-mirror public · main source-mirror Sourcebot index fetch (token) push (token) poll
Data flows one way. Nothing is ever written back to yesod, and nobody commits to the mirror by hand.
PieceFileJob
Trigger and runner.forgejo/workflows/publish-yesod-mirror.ymlNightly cron plus a manual button. Sets up git credentials and runs Copybara.
Copybara binaryMODULE.bazel (copybara_jar) and tools/copybara/BUILD.bazelPins the upstream release jar by SHA-256 and wraps it as a runnable Bazel target.
What gets copiedtools/copybara/yesod-mirror/copy.bara.skyOrigin, destination, include and exclude globs, path moves, authorship.
CredentialsRepository secret YESOD_MIRROR_TOKENOne Forgejo token that can read yesod and push to yesod-mirror.
ConsumerSourcebot config in k8s/configs/environments/sourcebot/main.jsonnetA second Sourcebot instance that indexes only acmcarther/yesod-mirror, with anonymous access.

Dependencies, in order: the job needs a CI runner with git and Bazel (via bazelisk). Bazel fetches a JDK and the Copybara jar. Copybara needs network access and credentials for both repos. Sourcebot only needs to be able to read the mirror.

Notes on the overview.

2. The CI workflow

Source: publish-yesod-mirror.yml. In yesod it lives under .forgejo/workflows/. In the mirror it appears under .forgejo-disabled/workflows/ (see the move). The file is short, so here are its steps with the internal details generalised.

Trigger

on:
  schedule:
    - cron: '0 1 * * *'   # 01:00 nightly
  workflow_dispatch:      # manual "Run workflow" button

It does not run on push. The mirror is up to a day behind main, which is fine for a browse-and-search copy. Use workflow_dispatch when you want an update now.

The workflow also declares permissions: packages: write. That line was copied from the Homebrew workflow, which does publish packages. The mirror job doesn't need it.

Container

The job runs on a docker-labelled Forgejo runner, inside yesod's own CI base image. That image already contains git and a bazelisk binary installed as bazel. A Copybara job needs only those two things. Java comes from Bazel (next section), so the image needs no JDK.

Bazelisk and the binary cache

- name: Link baked-in bazelisk
  run: |
    mkdir -p "${GITHUB_WORKSPACE}/bin/"
    ln -sf "$(command -v bazel)" "${GITHUB_WORKSPACE}/bin/bazel"
    echo "${GITHUB_WORKSPACE}/bin" >> $GITHUB_PATH

Older versions of this workflow downloaded bazelisk with curl on every run. Now the image ships it, and this step only puts it on PATH. When the Copybara step runs, it sets BAZELISK_BASE_URL to an internal binary cache. Bazelisk then downloads the Bazel release named in .bazelversion from that cache instead of from GitHub. This is a speed and reliability choice only. On a hosted runner you can leave BAZELISK_BASE_URL unset and bazelisk will use GitHub releases.

Credentials: the git insteadOf rewrite

Copybara runs plain git underneath, against the clean URLs in copy.bara.sky. The workflow puts the credential in git's global config, not in the Copybara config:

git config --global user.name  "Copybara"
git config --global user.email "copybara@<your-domain>"
git config --global \
  url."https://<user>:${{ secrets.YESOD_MIRROR_TOKEN }}@<forge>/<owner>/yesod-mirror.git".insteadOf \
      "https://<forge>/<owner>/yesod-mirror.git"
git config --global \
  url."https://<user>:${{ secrets.YESOD_MIRROR_TOKEN }}@<forge>/<owner>/yesod.git".insteadOf \
      "https://<forge>/<owner>/yesod.git"

Running Copybara, and why exit code 4 means success

bazel run //tools/copybara:copybara --config=remote --java_runtime_version=remotejdk_21 -- \
  migrate \
  "${GITHUB_WORKSPACE}/tools/copybara/yesod-mirror/copy.bara.sky" \
  --force \
  || { EC=$?; [ "$EC" -eq 4 ] || exit "$EC"; }

Copybara exits with code 4 (NO_OP) when there is nothing to migrate: the origin revision has already been migrated, or the filtered tree is the same as what the destination has. If yesod's main didn't change any mirrored file since the last run, the nightly job hits this case. Without the wrapper the job would go red on every quiet day. The || clause swallows only exit code 4 and passes any other non-zero code through, so real failures (auth errors, a bad config, push rejected) still fail the job.

--config=remote turns on yesod's shared Bazel remote cache. It is optional. The build is just one java_binary around a downloaded jar, and it builds fine without a cache.

Notes on the workflow.

3. Running Copybara under Bazel

Copybara publishes a self-contained copybara_deploy.jar on its GitHub releases. yesod pins one of them in MODULE.bazel:

http_file(
    name = "copybara_jar",
    downloaded_file_path = "copybara_deploy.jar",
    sha256 = "a1feedd74d85d55f5e2c965aa81d42b6bba2f8d99674453e6926c4bb0dbd8feb",
    urls = ["https://github.com/google/copybara/releases/download/v20260112/copybara_deploy.jar"],
)

It wraps the jar in tools/copybara/BUILD.bazel:

load("@rules_java//java:defs.bzl", "java_binary", "java_import")

java_import(
    name = "copybara_import",
    jars = ["@copybara_jar//file"],
)

java_binary(
    name = "copybara",
    main_class = "com.google.copybara.Main",
    runtime_deps = [":copybara_import"],
    visibility = ["//visibility:public"],
)

If you don't use Bazel, you need the same three things by other means: a JDK 21, the pinned jar, and java -jar copybara_deploy.jar migrate copy.bara.sky --force. Bazel only gives yesod reproducible pins for the jar and the JDK.

Notes on the Bazel wrapping.

4. The Copybara config

Source: tools/copybara/yesod-mirror/copy.bara.sky. Here is the whole file, with the URLs generalised:

sourceUrl      = "https://<forge>/<owner>/yesod.git"
destinationUrl = "https://<forge>/<owner>/yesod-mirror.git"

core.workflow(
    name = "default",
    origin = git.origin(
        url = sourceUrl,
        ref = "main",
    ),
    destination = git.destination(
        url = destinationUrl,
        fetch = "main",
        push = "main",
    ),
    origin_files = glob(["*", "**/*"], exclude = [
        "notes/**",
        "**/*.sops.yaml",
        "experimental/users/acmcarther/k8s/**",
    ]),
    authoring = authoring.pass_thru("Copybara <copybara@<your-domain>>"),
    transformations = [
        core.move(".forgejo", ".forgejo-disabled"),
    ],
)

(The file's header comment says tools/homebrew/copy.bara.sky. That is a leftover from copying the Homebrew config and doesn't matter.)

Origin and destination

git.origin reads main of the private repo. git.destination fetches the mirror's main, so Copybara can find the last migrated revision, and pushes the new commit to the same branch. No mode is set, so the workflow uses Copybara's default, SQUASH: each run makes one destination commit containing the filtered tree at the origin's head (see §5).

What is excluded, and why

ExcludeWhy
notes/**Personal notes. Not secret, but not meant to be public.
**/*.sops.yamlSOPS-encrypted secret files. The values are encrypted, but the mirror has no use for them, and encrypted secrets are better kept off a public repo: key names, file layout and ciphertext all leak something, and an encryption key could be compromised later.
experimental/users/acmcarther/k8s/**The operator's personal Kubernetes environments. These are excluded as a whole subtree, not file by file.

The include glob is ["*", "**/*"]. * matches top-level files and **/* matches everything below. Copybara applies origin_files to the whole tree, and excluded files simply don't exist in the destination. The mirror's API confirms this: notes/, experimental/users/acmcarther/k8s/ and, for example, k8s/configs/environments/monitoring/secrets.sops.yaml all return 404 there.

Glob gotcha. **/*.sops.yaml does not match a top-level file named exactly .sops.yaml, and that file is in the mirror. In yesod it is harmless: it is the SOPS config (creation rules that list public age recipients) and holds no secrets. The lesson still applies. Test your excludes against the real tree (see the checklist) instead of trusting how a glob reads.

Authoring: authoring.pass_thru

pass_thru(default) keeps the origin commit's author. Copybara's reference says the default author "is used in squash mode workflows or if author cannot be determined". This workflow runs in SQUASH mode, so every mirror commit is authored and committed by Copybara. pass_thru would only start passing real authors through if you switched to ITERATIVE. The message is always:

Project import generated by Copybara.

GitOrigin-RevId: <full SHA of the yesod commit>

The other modes are authoring.overwrite(...), which always uses one fixed author, and authoring.allowed(...), which passes authors through only from an allow-list. For a public mirror, think about whether you want contributors' names and emails published. SQUASH mostly answers that question for you.

The .forgejo → .forgejo-disabled move

Forgejo Actions runs any workflow it finds under .forgejo/workflows/ (and GitHub-style .github/workflows/). Mirrored as-is, yesod's CI definitions would try to run in the mirror every time Copybara pushed. They would fail for lack of secrets and runners, or worse, run on a runner that happens to be shared. core.move(".forgejo", ".forgejo-disabled") keeps the workflows readable in the mirror, which is how you are reading this one, while Forgejo no longer treats them as workflows. Excluding .forgejo/** would also work, but then the CI setup would be unpublished.

The move is also the reason source-mirror links to workflows go through .forgejo-disabled/.

Notes on the config: other excludes considered, plans to widen or narrow the subset.

5. History, squash and --force

yesod main yesod-mirror night 1 night 2 night 3
SQUASH mode: each nightly run makes one mirror commit with the filtered tree at yesod's head, tagged GitOrigin-RevId. yesod's own history (authors, messages, intermediate states) is never published.

To work out what is new, Copybara reads the destination's history and looks for the most recent GitOrigin-RevId: label. That commit is the last one migrated, and anything after it in the origin is pending.

The mirror currently has about 70 Copybara commits, starting in January 2026. yesod's own history has thousands.

Tightening the exclude list doesn't take anything back. Adding a path to exclude removes it from the next snapshot. Every earlier mirror commit still contains it. If something sensitive was ever mirrored, fixing the config is not enough: you have to rewrite or recreate the destination repo, rotate whatever leaked, and re-run with --force.

Notes on history, for example whether the mirror has ever been recreated.

6. A second example: the Homebrew tap

The same pattern publishes yesod's internal CLI tools as a public Homebrew tap, yesod-homebrew-tools. It differs from the mirror in three useful ways.

Sources: publish-homebrew-packages.yml, tools/copybara/homebrew/copy.bara.sky, homebrew/README.md.

yesod-mirrorHomebrew tap
SelectionEverything minus excludesAn allow-list: origin_files = glob(["homebrew/*.rb"])
Path move.forgejo → .forgejo-disabledcore.move("homebrew", ""), so formulas land at the tap's root, where Homebrew looks for them
OriginThe remote yesod repoThe CI workspace: the job generates fresh .rb formulas, commits them locally, and runs sed to point sourceUrl at file://${GITHUB_WORKSPACE}. That commit is never pushed to yesod. Copybara reads it from the local checkout.
TriggerNightly and manualPush to main touching tools/multitool/**, homebrew/** or tools/copybara/homebrew/**; also nightly and manual
CredentialYESOD_MIRROR_TOKENHOMEBREW_REPO_TOKEN, push to the tap only. The local file:// origin needs no credential.

Before Copybara runs, the tap job builds binaries for several platforms and uploads them to Forgejo's generic package registry. It treats HTTP 409 ("version exists") as success, the same idea as exit code 4. It then generates formulas that point at those uploads, and it skips Copybara entirely when git diff --cached --quiet shows the formulas didn't change. Exit-4 handling and --force are the same as in the mirror job.

Takeaway for porting: an allow-list (glob(["dir/**"])) is much safer than a deny-list when the public part is small. The mirror uses a deny-list only because nearly the whole repo is meant to be public.

Notes on the tap.

7. Downstream: code search over the mirror

yesod runs two Sourcebot (Zoekt-based code search) instances. The private one indexes yesod and a few other repos. The second, source-mirror, has a single connection to Forgejo that lists one repo, acmcarther/yesod-mirror, and it has anonymous access turned on. Sourcebot polls the forge, so new mirror commits show up in search shortly after the nightly push. In practice the searchable copy is about a day behind main. The configuration is in k8s/configs/environments/sourcebot/main.jsonnet.

Because the index covers only the mirror, a public search can never surface excluded files: they were never pushed. If you want to share search without sharing the whole private repo, point the public index at the mirror, not at the source with a path filter.

yesod's own tooling links to the mirror too. Its internal cluster dashboard can link to source files through source-mirror, and its code-search CLI docs use repo:yesod-mirror as the example filter.

Notes on source-mirror.

8. Porting checklist

What you need

What you decide

Pitfalls

Notes for the peer's own setup.

9. Source files

All links go to the public mirror through source-mirror. CI files are under .forgejo-disabled/ there. Paths that are excluded from the mirror are deliberately not linked.

Further links.