No description
  • Shell 91.6%
  • Open Policy Agent 8.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-11-14 12:22:37 -05:00
.circleci ci: inject the dev orb under its reference name 2026-11-14 12:22:37 -05:00
policies feat: fork gatekeeper, commit-messages, preview-comment commands 2026-11-14 07:30:38 -05:00
src refactor: write included helper files from small scripts (orb review RC009) 2026-11-14 12:16:09 -05:00
tests test: check the global git identity for the fallback 2026-11-14 12:19:50 -05:00
tools fix: run conftest with --all-namespaces so the circleci policies evaluate 2026-11-14 08:20:00 -05:00
.gitignore refactor: generic setup job and registry orb metadata 2026-11-14 07:33:14 -05:00
.yamllint refactor: generic setup job and registry orb metadata 2026-11-14 07:33:14 -05:00
LICENSE refactor: generic setup job and registry orb metadata 2026-11-14 07:33:14 -05:00
README.md docs: document the doorbell body form, scan mode, stale rules and event routing 2026-11-14 08:42:08 -05:00
renovate.json ci: add a self-hosted Renovate workflow and renovate.json 2026-11-14 08:25:19 -05:00

magicbeandev/ci

A small CircleCI orb for MagicBeanDev repos hosted on Forgejo. YAML plus a few bash scripts (curl, jq, git); no Python. Native CircleCI and Forgejo features first.

orbs:
  mb: magicbeandev/[email protected]

Pinned dependencies: circleci/[email protected], circleci/[email protected].

Elements and examples

Job setup

Path-filtering for one pipeline event, then continues with shared-file + event-file. Parameters: event-file (required), shared-file, mapping, base-revision, extra-params, force-full. Mappings use the three-column form regex parameter value. extra-params is a JSON object; values produced by the mapping win. force-full: true adds the pipeline parameter full: true.

- mb/setup:
    event-file: .circleci/events/pr.yml
    mapping: .circleci/mapping.conf
    extra-params: '{"release_dry_run": true}'

Job fork-doorbell (forks project only)

Approval is sticky: a fork PR runs while it is open and carries ci: approved; the doorbell never removes the label. It continues only when the PR (re-read from the Forgejo API) is open, from a fork, carries the label, and refs/pull/N/head equals the PR head SHA. A SHA that already has a ci/circleci: gate status is skipped (duplicate events) unless the newest ci: approved add on the timeline is newer than that status, so re-adding the label reruns a commit and unrelated label changes do not. It posts pending and continues with shared-file + event-file (default events/fork.yml) and the parameters fork_pr / fork_sha / full: true (an approved fork run builds everything that applies; declare a boolean full parameter). Otherwise it halts cleanly, reason in the log. No PR code runs.

The webhook body is only a hint. Pass it in the one documented form, webhook-body: << pipeline.trigger_parameters.webhook.body >> (it reaches the script as WEBHOOK_BODY). The PR number is parsed defensively (JSON, a JSON-encoded string, or a "number": N match); when the body is unusable or empty the doorbell scans the open fork PRs carrying the label and picks the first whose head SHA has no gate status, so a body problem never silently means "no CI". Parameters: repo (required), webhook-body, forgejo-url, shared-file, event-file. Context: forgejo-bot.

- mb/fork-doorbell:
    repo: MyOrg/my-repo
    webhook-body: << pipeline.trigger_parameters.webhook.body >>
    context: forgejo-bot

Job fork-stale (forks project, daily schedule)

Expires approvals and unsticks runs, for open fork PRs carrying ci: approved. It fails closed: if the statuses or the timeline cannot be read, the PR is skipped and nothing is changed.

  • A ci/circleci: gate status still pending after 6 hours becomes error ("CI run did not finish; re-add ci: approved").
  • When the newer of the last push and the last ci: approved add (the PR creation if neither exists) is older than days, the label is removed and one comment is posted ("Approval expired after N days without new commits. A maintainer can re-add ci: approved to resume CI.").

Non-fork PRs are ignored. Parameters: repo (required), days (14), forgejo-url. Context: forgejo-bot.

- mb/fork-stale: { repo: MyOrg/my-repo, context: forgejo-bot }

Job fork-report

One curl: posts ci/circleci: gate as success or failure on the fork head SHA. Parameters: state, fork-sha, repo (required), forgejo-url. Two jobs are enough, no per-job variants:

- mb/fork-report: { name: fork-report-ok, state: success, fork-sha: << pipeline.parameters.fork_sha >>, repo: MyOrg/my-repo, requires: [fork-gate], context: forgejo-bot }
- mb/fork-report: { name: fork-report-fail, state: failure, fork-sha: << pipeline.parameters.fork_sha >>, repo: MyOrg/my-repo, requires: [{ fork-gate: [not_run, failed, canceled] }], context: forgejo-bot }

Job gate

Final required job; branch protection requires its status. No parameters.

- mb/gate: { requires: [build, test] }

Job release-sign / release-publish

Thin wrappers around the repo's own scripts/release.py, always taken from origin/main (the commit must be on main). Both halt cleanly (no sign, no publish) when the tag is empty, or the workspace has release.skip or a release.env with an empty TAG=. release-sign (context release-signing): workspace, dry-run; signs <workspace>/files. release-publish (context forgejo-bot): tag (optional, default empty: TAG and REF are then read from <workspace>/release.env, where nightly tags live), workspace, dry-run. Both need python3 in the image.

- mb/release-sign: { context: release-signing, requires: [package] }
- mb/release-publish: { tag: << pipeline.git.tag >>, context: forgejo-bot, requires: [mb/release-sign] }

Command setup-mise

Pinned mise release (sha256 per arch) unless present, mise trust, mise install, with a cache restored always and saved only on main for non-fork runs. Parameters: mise-toml, git-branch, fork-pr.

- mb/setup-mise: { git-branch: << pipeline.git.branch >>, fork-pr: << pipeline.parameters.fork_pr >> }

Command save-cache-main

save_cache only when git-branch is main and fork-pr is 0. Parameters: key, paths, git-branch, fork-pr.

- mb/save-cache-main: { key: v1-deps-{{ checksum "go.sum" }}, paths: ~/go/pkg, git-branch: << pipeline.git.branch >> }

Command get-source

checkout, or in fork mode (fork-pr above 0) an anonymous fetch of refs/pull/N/head plus main's history, failing unless the SHA equals fork-sha. Parameters: fork-pr, fork-sha, repo-url.

- mb/get-source: { fork-pr: << pipeline.parameters.fork_pr >>, fork-sha: << pipeline.parameters.fork_sha >>, repo-url: "https://git.magicbean.dev/MyOrg/my-repo.git" }

Command commit-subjects

Fails if a non-merge subject in base..HEAD is not a Conventional Commit (feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert; optional scope and !). Parameter: base (default origin/main).

- mb/commit-subjects

Command preview-comment

Validates preview-url.txt (at most 512 bytes, not a symlink, and strictly https://output.circle-artifacts.com/output/job/<uuid>/artifacts/<n>/preview/.../index.html), then upserts one marker comment authored by the bot. The URL is written by another job, so it is not bound to the commenting job. Residual risk: a fork could point the link at another CircleCI artifact; it is still only a circle-artifacts link. Parameters: repo (required), forgejo-url, pr, preview-url-file, commit-sha.

- mb/preview-comment: { repo: MyOrg/my-repo }

Command policy-check

Pinned conftest. Merges shared with each file in events-dir (yq, as path-filtering does) and tests every merged result against the orb's Rego policies (shipped inside the orb), so contexts attached in event files are checked; forks (optional) is tested as is. Parameters: shared, events-dir, forks. Needs yq (in cimg/base).

- mb/policy-check: { shared: .circleci/shared.yml, events-dir: .circleci/events, forks: .circleci/forks.yml }

Wiring a repo

One setup job per event in .circleci/config.yml, each with a native expression filter. Keep the filters mutually exclusive; an event with no job does nothing.

version: 2.1
setup: true
orbs: { mb: magicbeandev/[email protected] }
workflows:
  pr:
    when:
      and:
        - equal: [pull_request, << pipeline.event.name >>]
        - not: { equal: [main, << pipeline.git.branch >>] }
        - equal: ["", << pipeline.git.tag >>]
    jobs: [{ mb/setup: { event-file: .circleci/events/pr.yml, mapping: .circleci/mapping.conf } }]
  main:
    when:
      and:
        - equal: [push, << pipeline.event.name >>]
        - equal: [main, << pipeline.git.branch >>]
        - equal: ["", << pipeline.git.tag >>]
    jobs: [{ mb/setup: { event-file: .circleci/events/main.yml } }]
  release:
    when:
      and:
        - equal: [push, << pipeline.event.name >>]
        - matches: { pattern: "^v.*", value: << pipeline.git.tag >> }
    jobs: [{ mb/setup: { event-file: .circleci/events/release.yml } }]

Route on pipeline.event.name (pull_request, push, schedule, api, custom_webhook), not pipeline.trigger_source (deprecated, and not documented for Forgejo). Other events work the same way (schedule, api; pipeline.schedule.name separates schedules). Repo layout: shared.yml (jobs), events/*.yml (workflows), mapping.conf, forks.yml (the doorbell).

Forks

Forgejo has no native fork trigger, so the forks project uses CircleCI triggers:

  1. Add a custom webhook trigger to the forks project (config .circleci/forks.yml on main) and note its URL. In the Forgejo organization settings add a webhook to that URL and tick Pull request label and Pull request synchronized (JSON body). Forgejo only lets writers add labels, so adding ci: approved is the approval.
  2. Add a daily schedule trigger to the same project for the stale sweep.
  3. forks.yml wires the webhook trigger to mb/fork-doorbell and the schedule trigger to mb/fork-stale (see the fork-doorbell example), and the repo's events/fork.yml runs the fork workflows using fork_pr / fork_sha (get-source, then fork-report and gate).

Flow: label present + a push (synchronized) runs CI on that SHA; a repeat event for a SHA that already has a gate status is ignored; re-adding the label reruns the current commit; removing the label stops further runs; with no new commits for 14 days the sweep removes the label, so a maintainer must re-add it.

Development

  • bats tests runs the script tests (bats-core 1.13.0; tests/install-bats.sh installs the pinned one). The doorbell tests shadow curl with tests/stubs/curl, which answers from Forgejo 17 shaped JSON.
  • shellcheck -x src/scripts/*.sh tools/*.sh tests/*.sh tests/stubs/curl
  • circleci orb pack src > /tmp/orb.yml && circleci orb validate /tmp/orb.yml
  • tools/validate.sh REPO_PATH merges shared.yml with each events/*.yml of a repo, inlines the locally packed orb as mb, runs circleci config validate, then conftest test with policies/.
  • CI (.circleci/config.yml): orb-tools lint, pack, review (RC010 off: element names are hyphenated), shellcheck, bats, conftest verify. test-deploy.yml publishes a dev orb on branches and a semver on vX.Y.Z tags, context orb-publishing.

Dependency updates (Renovate)

This repo runs a self-hosted Renovate from its own .circleci/config.yml: a renovate workflow that runs only when the pipeline parameter renovate is true (the normal pipeline is skipped then). The job uses the renovate/renovate image pinned by digest (the tag is in a comment) and runs renovate, with RENOVATE_PLATFORM=forgejo, autodiscovery of MagicBeanDev/* and no custom scripts. This repo's own renovate.json extends config:recommended and docker:pinDigests, waits 7 days (minimumReleaseAge) and lets the circleci manager update the orbs and images in .circleci/ and src/.

Onboarding flow (the standard one):

  1. Each discovered repo gets a "Configure Renovate" PR (RENOVATE_ONBOARDING=true). Its renovate.json comes from RENOVATE_ONBOARDING_CONFIG: config:recommended, minimumReleaseAge 7 days, docker pinDigests, minor and patch updates grouped, ignorePaths tools.lock and **/third_party/**, label dependencies. Maintainers can edit the PR before merging.
  2. RENOVATE_REQUIRE_CONFIG=required: until the maintainers merge that PR, Renovate does nothing in the repo.

Setup:

  1. Bot token. Create a Forgejo bot user (it is also the git author, RENOVATE_GIT_AUTHOR) and a token with these scopes, per Renovate's Forgejo docs: repository read/write, user read, issue read/write, organization read. Give the bot write access to the repositories to scan.
  2. Context renovate. In CircleCI create a context renovate with RENOVATE_TOKEN, restricted to the main branch so no other branch or fork can read the token.
  3. Schedule. Add a weekly (not nightly) schedule trigger to this project on main that sends the pipeline parameter renovate: true.