WIP: ci: run checks, signed releases and fork PR checks on CircleCI #65

Draft
drcrow wants to merge 14 commits from drcrow/LongshotLauncher:ci/circleci into main
Member

Why

Pull requests get only a title check today; the build and tests run on main alone, so a broken PR is found after it merges. This moves CI and the release pipeline to CircleCI, which builds and tests every PR and only does the work a change needs.

What changed

  • ci: .circleci/config.yml has one small setup job per event (pull request, push to main, v* tag, nightly schedule, manual API run). Each one looks at which areas changed (.circleci/mapping.conf: app, core, tests, packaging, release scripts, docs) and continues with .circleci/shared.yml plus that event's file in .circleci/events/. A docs-only PR runs lint and gate; a Core change builds and tests; packaging changes also package and run the Wine self-check.
  • ci: the shared setup, fork handling, release sign/publish steps and gate come from one CircleCI orb, magicbeandev/ci, used by all three repos (source in a new MagicBeanDev/orb repo). This repo keeps only its own jobs.
  • ci: releases follow release.yml: version check from main's copy of scripts/release.py, package.sh, SHA256SUMS, a sign job that holds only the signing key, then a publish job that holds only the Forgejo token (notes, publish, prune old nightlies). Main pushes publish the nightly only after everything passes; v* tags do rc or stable. release_dry_run runs it all with a throwaway key and stops before publishing.
  • ci: every run ends in ci/circleci: gate, the one check branch protection needs. Build and test jobs run on CircleCI's Arm (Gen2) containers, the cheapest option; packaging stays on x86 for Wine and the AppImage tools. Caches are saved from main only.
  • ci: fork PRs: a maintainer adds the ci: approved label (Forgejo only lets people with write access do that). A separate CircleCI project then runs the checks on that exact commit with no secrets, and posts the gate status. The label stays on, so new pushes keep running. If a fork PR goes 14 days without activity, the label is removed automatically.
  • test: tests/longshot.ref pins the Longshot commit the contract checks run against; a nightly also checks Longshot main. The release tests run under pytest with JUnit output, from hash-pinned requirements.
  • build(dev): global.json pins the .NET SDK; mise.toml pins the tools and has lint, test and validate tasks. Also adds pre-commit hooks (Conventional Commit messages included), a devcontainer, a Chunk sidecar config, and docs/ci.md.
  • PR title and description checks stay on Forgejo. The Forgejo workflows stay until CircleCI has been green for a while; a later PR removes them.

Testing

Tested: circleci config validate on every event's merged config with the orb inlined; the orb's conftest policies (24 pass); mise run lint and mise run test (build plus 28 release tests) in a clean Ubuntu 24.04 container and on a fresh Chunk sidecar.
Not tested: a live CircleCI run (it starts once this is on main and the orb is published), publishing (needs the signing key and bot token in CircleCI), and the fork flow end to end (needs the org webhook).

Feedback very welcome, especially on the path areas in .circleci/mapping.conf and on whether the release chain matches how you cut releases today.

## Why Pull requests get only a title check today; the build and tests run on main alone, so a broken PR is found after it merges. This moves CI and the release pipeline to CircleCI, which builds and tests every PR and only does the work a change needs. ## What changed - ci: `.circleci/config.yml` has one small setup job per event (pull request, push to main, `v*` tag, nightly schedule, manual API run). Each one looks at which areas changed (`.circleci/mapping.conf`: app, core, tests, packaging, release scripts, docs) and continues with `.circleci/shared.yml` plus that event's file in `.circleci/events/`. A docs-only PR runs lint and `gate`; a Core change builds and tests; packaging changes also package and run the Wine self-check. - ci: the shared setup, fork handling, release sign/publish steps and gate come from one CircleCI orb, `magicbeandev/ci`, used by all three repos (source in a new `MagicBeanDev/orb` repo). This repo keeps only its own jobs. - ci: releases follow `release.yml`: version check from main's copy of `scripts/release.py`, `package.sh`, SHA256SUMS, a **sign** job that holds only the signing key, then a **publish** job that holds only the Forgejo token (notes, publish, prune old nightlies). Main pushes publish the nightly only after everything passes; `v*` tags do rc or stable. `release_dry_run` runs it all with a throwaway key and stops before publishing. - ci: every run ends in `ci/circleci: gate`, the one check branch protection needs. Build and test jobs run on CircleCI's Arm (Gen2) containers, the cheapest option; packaging stays on x86 for Wine and the AppImage tools. Caches are saved from main only. - ci: fork PRs: a maintainer adds the `ci: approved` label (Forgejo only lets people with write access do that). A separate CircleCI project then runs the checks on that exact commit with no secrets, and posts the gate status. The label stays on, so new pushes keep running. If a fork PR goes 14 days without activity, the label is removed automatically. - test: `tests/longshot.ref` pins the Longshot commit the contract checks run against; a nightly also checks Longshot main. The release tests run under pytest with JUnit output, from hash-pinned requirements. - build(dev): `global.json` pins the .NET SDK; `mise.toml` pins the tools and has `lint`, `test` and `validate` tasks. Also adds pre-commit hooks (Conventional Commit messages included), a devcontainer, a Chunk sidecar config, and `docs/ci.md`. - PR title and description checks stay on Forgejo. The Forgejo workflows stay until CircleCI has been green for a while; a later PR removes them. ## Testing Tested: `circleci config validate` on every event's merged config with the orb inlined; the orb's conftest policies (24 pass); `mise run lint` and `mise run test` (build plus 28 release tests) in a clean Ubuntu 24.04 container and on a fresh Chunk sidecar. Not tested: a live CircleCI run (it starts once this is on main and the orb is published), publishing (needs the signing key and bot token in CircleCI), and the fork flow end to end (needs the org webhook). Feedback very welcome, especially on the path areas in `.circleci/mapping.conf` and on whether the release chain matches how you cut releases today.
drcrow force-pushed ci/circleci from d3ab541e52
Some checks failed
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Compile and test (pull_request) Has been cancelled
to c2c0d02de7
Some checks failed
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Compile and test (pull_request) Has been cancelled
2026-11-14 14:15:00 +00:00
Compare
drcrow force-pushed ci/circleci from c2c0d02de7
Some checks failed
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Compile and test (pull_request) Has been cancelled
to a5837a0b18
Some checks failed
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Compile and test (pull_request) Has been cancelled
2026-11-14 14:15:47 +00:00
Compare
drcrow force-pushed ci/circleci from a5837a0b18
Some checks failed
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Compile and test (pull_request) Has been cancelled
to e615da0fba
Some checks failed
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Compile and test (pull_request) Has been cancelled
2026-11-14 14:37:30 +00:00
Compare
mooki left a comment

Looked over what runs when, at e615da0f (read at a5837a0b; the push since then adds diagrams to docs/ci.md and changes nothing else). Three things would stop the flow as written, then points that depend on the orb or on settings I cannot see.

1. The fork workflow's condition is always true. .circleci/events/fork.yml:4-5 has when: greater: [<< pipeline.parameters.fork_pr >>, 0]. greater is not one of CircleCI's logic statements (and, or, not, equal, matches), and the compiler does not reject it: an unknown operator counts as true. I compiled a small config with exactly that condition through CircleCI's config compiler: it is valid, and the workflow is kept with fork_pr at 0 as well as at 7. With the expression when: pipeline.parameters.fork_pr > 0 it is dropped at 0 and kept at 7. So circleci config validate passes, and the fork jobs run whenever this file is continued into, whatever fork_pr is. The same line is in Longshot and Site, and in Longshot the same operator guards the checkout (noted there). The orb's get-source is worth checking for it too. One caution for step-level conditions: inside when:/unless: condition: the expression string was also taken as true in my test; not: {equal: [0, << pipeline.parameters.fork_pr >>]} compiled correctly there.

2. A v* tag never reaches the release. .circleci/config.yml:58-68: the release workflow has a tag condition, but its mb/setup job has no tag filter. CircleCI does not run a job for a tag unless that job has a tag filter (https://circleci.com/docs/guides/orchestrate/workflows/#executing-workflows-for-a-git-tag), so by that rule the setup job that would continue into events/release.yml does not start, and the filters inside events/release.yml come too late. I have not seen a live tag run. noop-non-release-tag (lines 90-100) has the same gap. Longshot's config.yml has filters: pipeline.git.tag starts-with "v" on its tag job; this one needs the same.

3. The version job runs main's release.py from /tmp, where it cannot find the repository. .circleci/shared.yml:338-348 writes origin/main:scripts/release.py to /tmp/release-main.py and runs it there. scripts/release.py:64 takes the repository root from the script's own location, so the root becomes /. Run that way from a checkout of this branch:

  • nightly-name fails with git rev-list --count ...: fatal: not a git repository;
  • check-tag v1.2.3-rc.1 is refused as "not vX.Y.Z or vX.Y.Z-rc.N of this product's numbers (from v3.0.0)", because with no Core/ReleaseTrust.cs under that root the script takes the launcher for Longshot.

In place, nightly-name prints nightly-54-a5837a0b. So every nightly on main and every launcher tag stops at version. The copy has to sit one folder below the checkout's root (as scripts/ does), or the script needs a root argument. Longshot's pipeline writes its copy to .ci/main-release/, which keeps git working but makes the root <checkout>/.ci.

Depends on the orb or on settings:

4. Two publishers. .forgejo/workflows/release.yml still publishes and prunes on every push to main and on v* tags (lines 25-28 and 188-191), and events/main.yml:19-29 publishes for real as well. Once 2 and 3 are fixed, both write the same nightly and both prune. Which one is the only writer while they run side by side?

5. Paths with no rule. mapping.conf has no rule for scripts/translations.py (imported by tests/test_launcher_core.py:118-123), mise.lock, .actionlint.yml or .forgejo/. Unless the orb treats an unmapped path as "full", a change to only those gets lint and the gate. What does the orb do with an unmapped path, and what does it compare against (merge base, previous push)?

6. "Main runs everything, the nightly ships only if it all passes." On main, version requires lint, build-test and release-scripts. contract-longshot-main is required neither by version nor by gate (events/main.yml:10-11 and 30-37), and its check ends in || true (shared.yml:295). If that is meant as advice only, the wording could say so.

7. A failed check and the gate. The gates use plain requires, so when a required job fails the gate job does not run. Does something else then post a failed ci/circleci: gate on the commit, or does the check stay missing? Either blocks the merge; only one tells the author why.

8. "Never has secrets." The new text in docs/ci.md says the forks project never has secrets. .circleci/forks.yml:20-23 and :29-32 give the doorbell and the stale sweep the forgejo-bot context, and events/fork.yml gives it to the two report jobs. That may be the intended split (the jobs that run fork code ask for no context), in which case the sentence could say so.

9. The manual dry run asks for the real contexts. events/manual.yml:14-23 gives sign and publish the release-signing and forgejo-bot contexts although the dry run is meant to use a throwaway key and not publish.

Checked and fine: the orb is pinned to an exact version in all three files; a docs-only change selects lint and gate; docs plus Core keeps the Core checks; packaging stays on x86 with the Wine check; signing and publishing ask for separate contexts and the build jobs for none; tests/longshot.ref is a full commit that exists in Longshot; the pytest requirements are hash-pinned.

One request: https://git.magicbean.dev/MagicBeanDev/orb is not readable (404), and the setup, fork handling, signing, publishing and gate all live in the orb. Those parts of the flow cannot be reviewed from this pull request until its source can be read.

Looked over what runs when, at `e615da0f` (read at `a5837a0b`; the push since then adds diagrams to `docs/ci.md` and changes nothing else). Three things would stop the flow as written, then points that depend on the orb or on settings I cannot see. **1. The fork workflow's condition is always true.** `.circleci/events/fork.yml:4-5` has `when: greater: [<< pipeline.parameters.fork_pr >>, 0]`. `greater` is not one of CircleCI's logic statements (and, or, not, equal, matches), and the compiler does not reject it: an unknown operator counts as true. I compiled a small config with exactly that condition through CircleCI's config compiler: it is valid, and the workflow is kept with `fork_pr` at 0 as well as at 7. With the expression `when: pipeline.parameters.fork_pr > 0` it is dropped at 0 and kept at 7. So `circleci config validate` passes, and the fork jobs run whenever this file is continued into, whatever `fork_pr` is. The same line is in Longshot and Site, and in Longshot the same operator guards the checkout (noted there). The orb's `get-source` is worth checking for it too. One caution for step-level conditions: inside `when:`/`unless:` `condition:` the expression string was also taken as true in my test; `not: {equal: [0, << pipeline.parameters.fork_pr >>]}` compiled correctly there. **2. A `v*` tag never reaches the release.** `.circleci/config.yml:58-68`: the `release` workflow has a tag condition, but its `mb/setup` job has no tag filter. CircleCI does not run a job for a tag unless that job has a tag filter (https://circleci.com/docs/guides/orchestrate/workflows/#executing-workflows-for-a-git-tag), so by that rule the setup job that would continue into `events/release.yml` does not start, and the filters inside `events/release.yml` come too late. I have not seen a live tag run. `noop-non-release-tag` (lines 90-100) has the same gap. Longshot's `config.yml` has `filters: pipeline.git.tag starts-with "v"` on its tag job; this one needs the same. **3. The version job runs main's `release.py` from `/tmp`, where it cannot find the repository.** `.circleci/shared.yml:338-348` writes `origin/main:scripts/release.py` to `/tmp/release-main.py` and runs it there. `scripts/release.py:64` takes the repository root from the script's own location, so the root becomes `/`. Run that way from a checkout of this branch: - `nightly-name` fails with `git rev-list --count ...: fatal: not a git repository`; - `check-tag v1.2.3-rc.1` is refused as "not vX.Y.Z or vX.Y.Z-rc.N of this product's numbers (from v3.0.0)", because with no `Core/ReleaseTrust.cs` under that root the script takes the launcher for Longshot. In place, `nightly-name` prints `nightly-54-a5837a0b`. So every nightly on main and every launcher tag stops at `version`. The copy has to sit one folder below the checkout's root (as `scripts/` does), or the script needs a root argument. Longshot's pipeline writes its copy to `.ci/main-release/`, which keeps git working but makes the root `<checkout>/.ci`. Depends on the orb or on settings: **4. Two publishers.** `.forgejo/workflows/release.yml` still publishes and prunes on every push to main and on `v*` tags (lines 25-28 and 188-191), and `events/main.yml:19-29` publishes for real as well. Once 2 and 3 are fixed, both write the same nightly and both prune. Which one is the only writer while they run side by side? **5. Paths with no rule.** `mapping.conf` has no rule for `scripts/translations.py` (imported by `tests/test_launcher_core.py:118-123`), `mise.lock`, `.actionlint.yml` or `.forgejo/`. Unless the orb treats an unmapped path as "full", a change to only those gets lint and the gate. What does the orb do with an unmapped path, and what does it compare against (merge base, previous push)? **6. "Main runs everything, the nightly ships only if it all passes."** On main, `version` requires lint, build-test and release-scripts. `contract-longshot-main` is required neither by `version` nor by `gate` (`events/main.yml:10-11` and `30-37`), and its check ends in `|| true` (`shared.yml:295`). If that is meant as advice only, the wording could say so. **7. A failed check and the gate.** The gates use plain `requires`, so when a required job fails the gate job does not run. Does something else then post a failed `ci/circleci: gate` on the commit, or does the check stay missing? Either blocks the merge; only one tells the author why. **8. "Never has secrets."** The new text in `docs/ci.md` says the forks project never has secrets. `.circleci/forks.yml:20-23` and `:29-32` give the doorbell and the stale sweep the `forgejo-bot` context, and `events/fork.yml` gives it to the two report jobs. That may be the intended split (the jobs that run fork code ask for no context), in which case the sentence could say so. **9. The manual dry run asks for the real contexts.** `events/manual.yml:14-23` gives `sign` and `publish` the `release-signing` and `forgejo-bot` contexts although the dry run is meant to use a throwaway key and not publish. Checked and fine: the orb is pinned to an exact version in all three files; a docs-only change selects lint and gate; docs plus Core keeps the Core checks; packaging stays on x86 with the Wine check; signing and publishing ask for separate contexts and the build jobs for none; `tests/longshot.ref` is a full commit that exists in Longshot; the pytest requirements are hash-pinned. One request: https://git.magicbean.dev/MagicBeanDev/orb is not readable (404), and the setup, fork handling, signing, publishing and gate all live in the orb. Those parts of the flow cannot be reviewed from this pull request until its source can be read.
Author
Member

Thanks @mooki, great review. Fixed in e615da0f..c2eb0f4:

  1. greater: is always true: confirmed with circleci config process. The fork workflow now uses when: pipeline.parameters.fork_pr > 0, which is dropped at 0 and kept at 7 (7b916d2).
  2. v* never reaching the release: the tag setup job and setup-noop now have tag filters, and every job in events/release.yml already had one (e6699df).
  3. release.py from /tmp: main's copy now goes to .mb-main/release.py in the checkout, so its root is the checkout. nightly-name prints nightly-56-e6699df1 from there (2652b3a). The orb's sign and publish jobs had the same bug and are fixed the same way.

Orb and settings:

  1. Two publishers: until cutover, CircleCI runs the main and tag release chains as dry runs, so Forgejo's release.yml stays the only publisher (e78c02d, c1f4d3a). The cutover PR turns CircleCI live and removes release.yml in the same commit.
  2. Unmapped paths: an unmapped path sets no parameter, so only lint and gate run. The diff is against the merge base with origin/main on PRs, and HEAD~1 on main. I added rules for scripts/translations.py (core and tests), mise.lock (full), and .actionlint.yml and .forgejo/ (lint) (8c63d70).
  3. contract-longshot-main: it's meant as advice only, and docs/ci.md now says so (c2eb0f4).
  4. A failed job and the gate: each job posts its own ci/circleci: <job> status, so the cause shows red on the commit. gate doesn't appear, and once it's required that blocks the merge. I'd rather not put an API token on PR pipelines just to turn gate red.
  5. "Never has secrets": reworded. Jobs that run fork code get no secrets; the doorbell, stale sweep and report jobs hold the bot token and run main's code and the orb, never fork code.
  6. The manual dry run's contexts: removed (4a31ce1).

Orb source: MagicBeanDev/orb is being created now, and the first PR there will have the full source.

Thanks @mooki, great review. Fixed in `e615da0f..c2eb0f4`: 1. **`greater:` is always true:** confirmed with `circleci config process`. The fork workflow now uses `when: pipeline.parameters.fork_pr > 0`, which is dropped at 0 and kept at 7 (7b916d2). 2. **`v*` never reaching the release:** the tag setup job and `setup-noop` now have tag filters, and every job in `events/release.yml` already had one (e6699df). 3. **`release.py` from `/tmp`:** main's copy now goes to `.mb-main/release.py` in the checkout, so its root is the checkout. `nightly-name` prints `nightly-56-e6699df1` from there (2652b3a). The orb's sign and publish jobs had the same bug and are fixed the same way. Orb and settings: 4. **Two publishers:** until cutover, CircleCI runs the main and tag release chains as dry runs, so Forgejo's `release.yml` stays the only publisher (e78c02d, c1f4d3a). The cutover PR turns CircleCI live and removes `release.yml` in the same commit. 5. **Unmapped paths:** an unmapped path sets no parameter, so only lint and `gate` run. The diff is against the merge base with `origin/main` on PRs, and `HEAD~1` on main. I added rules for `scripts/translations.py` (core and tests), `mise.lock` (full), and `.actionlint.yml` and `.forgejo/` (lint) (8c63d70). 6. **`contract-longshot-main`:** it's meant as advice only, and `docs/ci.md` now says so (c2eb0f4). 7. **A failed job and the gate:** each job posts its own `ci/circleci: <job>` status, so the cause shows red on the commit. `gate` doesn't appear, and once it's required that blocks the merge. I'd rather not put an API token on PR pipelines just to turn `gate` red. 8. **"Never has secrets":** reworded. Jobs that run fork code get no secrets; the doorbell, stale sweep and report jobs hold the bot token and run main's code and the orb, never fork code. 9. **The manual dry run's contexts:** removed (4a31ce1). **Orb source:** MagicBeanDev/orb is being created now, and the first PR there will have the full source.
Some checks are pending
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Compile and test (pull_request) Blocked by required conditions
This pull request is marked as a work in progress.
Some workflows are waiting to be reviewed.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u 'ci/circleci:drcrow-ci/circleci'
git switch 'drcrow-ci/circleci'
Sign in to join this conversation.
No reviewers
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
MagicBeanDev/LongshotLauncher!65
No description provided.