Skip to content

Orgabot is in private alpha. Request access, or join our Discord.

← All documentation
Using Orgabot

CI/CD migration: plan, convert and cut over safely

Dry-run a registry-wide CI move, convert workflows into reviewable bundles, shadow-test, then cut over or roll back one target at a time.

Last updated

What the migration does

orgabot ci-migrate moves the CI of every registered project onto Orgabot's provider-neutral pipeline, one target at a time. A target is one job of one workflow (for example ci/test is the test job in .github/workflows/ci.yml).

Three rules hold throughout:

  • The registry is the scope. The plan covers every project in the installation registry, including projects hosted outside GitHub and projects with no CI at all. A repository the connection can see but nobody registered is never touched. An unreadable registry stops the plan instead of reading as an empty portfolio.
  • The legacy workflow stays authoritative until that target's explicit cutover. Conversion only adds files; it never edits or deletes an existing workflow.
  • Missing approvals keep the migration open. Every essential approval is a concrete human task, and the migration does not report complete while one is open.

Lifecycle of a target

Each target moves through inventoried, planned, configured, shadow_validated, cutover and verified. Three more states are explicit rather than silent: blocked (with the reason), exception (an approved remote backend is the final placement) and no_ci (a recorded no-pipeline disposition).

StateWhat moves it there
plannedci-migrate apply recorded the plan
configuredthe generated files are observed on the default branch
shadow_validateda shadow run agreed with the legacy check for the same revision
cutoverthe cutover change is observed on the default branch
verifieda post-cutover run passed

States only move on observed facts. A conversion or cutover pull request that never merged did not happen, and the ledger says so.

Plan with a dry run

orgabot ci-migrate plan            # every registered project
orgabot ci-migrate plan premail    # one project
orgabot ci-migrate plan --json     # machine-readable, including each diff

A plan is always a dry run. It writes no file, files no issue and launches nothing, and still shows every target, its placement, its blockers and the human tasks it will create. To plan from an inventory file instead of the live registry, pass --manifest <file>: each entry is checked against the registry again, entries that are no longer registered are refused, and registered projects the file left out are added back.

Apply a batch

orgabot ci-migrate apply --all --yes --concurrency 2
orgabot ci-migrate apply --resume --yes        # continue the last unfinished batch

apply needs --yes. For each project it checks again that the project is still registered at the same root, then:

  1. records the plan in the migration ledger;
  2. writes a reviewable conversion bundle under ~/.orgabot/ci-migration-bundles/<project>/<bundle>/: the provider-neutral pipeline (.orgabot/ci/pipeline.json), one script per target, a remote wrapper only where policy keeps a target on GitHub runners, a unified diff, policy changes, and the required-check and environment steps a human performs;
  3. files one linked conversion task in the tracking repository (--tracking-repo, default MattSenter/orgabot, linked to --epic, default #2514) when no seeded work package covers the project;
  4. with --launch, starts the ordinary mission for that task, which opens a draft pull request through the usual verify, review and merge gates;
  5. reconciles every target with what the default branch now contains.

A batch records its progress before the first project and after each one, so a failure (an expired grant, a network drop) leaves the rest of the batch intact and --resume retries only what did not finish. Every step is idempotent: a rerun reuses the recorded issue, branch, pull request and mission and never creates a second one.

Seeded work packages

A project counts as covered only when that coverage is recorded against its registry id:

orgabot ci-migrate cover weatherling MattSenter/orgabot#2539

Coverage is never inferred from a product name in an issue title. Record coverage for the seeded issues before the first apply, or each of those projects gets its own conversion task too.

Orgabot finds a task it filed earlier by a hidden marker in the issue body. It adopts a marked issue only when the issue is open and was opened by an Orgabot identity (a configured self login or a GitHub App) or an author on your issue allowlist. Any other marked issue is a collision: apply refuses and names the issue number. Close that issue, or record the real task with orgabot ci-migrate cover.

What converts, and what becomes a blocker

Workflow contentConversion
run: stepscopied into the target's script, in order, with working-directory kept
actions/checkoutthe immutable source snapshot of the revision under test
setup-node, setup-python, setup-go, setup-java, Rust and Swift toolchain setupthe placement's toolchain; the requested version is recorded for parity
actions/cache, Swatinem/rust-cacheno-op; local backends keep their own caches
upload-artifact, download-artifactthe run's local output directory
github.sha, github.ref, github.workspace, runner.os, env.XORGABOT_CI_* variables the runner exports
any other action, other ${{ }} expressions, reusable workflows, service containers, OIDC tokensa tracked blocker on the target; the job stays in the legacy workflow
GitLab CI, CircleCI, Jenkins, Azure Pipelines, Bitbucket, Travis, Cloud Buildrepresented and tracked as a blocker; no conversion path yet

A blocker never deletes anything. It names the dependency it waits on (for example the local Actions compatibility adapter) and the target keeps running exactly where it ran before.

Placement and policy

Policy resolves organization, then project, then target:

orgabot ci-migrate policy show --project premail
orgabot ci-migrate policy set --preferred local_docker,native_macos --hosted off --lock hostedAllowed
orgabot ci-migrate policy set --project premail --shadow-runs 4
  • The default is local-first: local Docker, then native workers, then local Actions compatibility. Hosted execution is not authorized by default.
  • An organization can lock preferred, hostedAllowed or fallback. A narrower override of a locked field is refused and reported, never silently applied.
  • Forbidden placements and required approvals only accumulate, and shadow budgets only shrink, as scope narrows.
  • A target whose platforms no authorized backend covers is unplaced: a capability gap with a human task to enroll a worker or approve an exception.

Supported backends today

PlacementStatus
local_dockerused for shadow runs when Docker is installed
native_macos, native_linux, native_windowsthe host's own operating system; other machines need worker enrollment
local_actions_compatreported as a gap: the compatibility adapter has not shipped
github_self_hosted, github_hostedonly through an approved exception or --hosted on; dispatched through a generated remote wrapper

Local Actions compatibility is not GitHub's service. It has no OIDC parity, and the migration never fakes an OIDC token or swaps an identity-bound signing setup for a local credential.

The Premail hybrid example

Premail's portable checks run locally while its Windows installer build and Azure-backed signing stay on GitHub-hosted Windows:

orgabot ci-migrate plan premail
orgabot ci-migrate exception premail release/release --placement github_hosted \
  --reason "Windows MSI/NSIS build and Azure OIDC signing stay on hosted Windows" --approved-by owner
orgabot ci-migrate apply premail --yes

After the exception is approved, the plan places the security audit, test and Linux targets locally, the macOS bridge build on the native macOS worker, and the release job in a remote wrapper that keeps its original steps and signing identity. The wrapper has only a workflow_dispatch trigger, so it cannot fire alongside the legacy release workflow. Release, signing and deploy targets are never shadow-run; they move only after their approval tasks carry evidence and the joint release validation is recorded with ci-migrate evidence.

Shadow validation and resource limits

orgabot ci-migrate shadow premail security-audit/tests

A shadow run executes the target's generated script against an immutable snapshot of one revision and compares it with the legacy check run for the same commit.

  • No side effects. Release, deploy, signing and publish targets are refused before anything runs. No secret is passed in; a target that references one runs without it, and the evidence lists what was withheld. HOME is a throwaway directory, never yours.
  • Bounded compute. Each run has a wall-clock cap (default 30 minutes), each batch has a run cap (default 10), and runs on one host are serialized (default 1). Container runs are limited to 2 CPUs, 4 GB of memory and 1024 processes.
  • Evidence, not confidence. Parity is recorded only from an observed exit code and an observed legacy result. When the legacy result cannot be read, parity is unknown, never a match.

Cutover also needs failure-gate evidence: a shadow run that failed where the legacy check failed, proving the new gate can fail.

Cut over and roll back

orgabot ci-migrate rollback-test premail security-audit/tests
orgabot ci-migrate cutover premail security-audit/tests            # write the reviewable change
orgabot ci-migrate cutover premail security-audit/tests --launch   # and deliver it as a mission
orgabot ci-migrate verify premail security-audit/tests
orgabot ci-migrate rollback premail security-audit/tests

Cutover is refused unless parity, failure-gate and a tested rollback are on record, the legacy file still matches the planned rollback revision, no blocker or essential approval is open, and no other target of the same project is mid-cutover. Only one trigger owner moves at a time.

The cutover change disables only that job in the legacy file (if: ${{ false }} with a marker that keeps the original condition), so the rollback restores the file byte for byte. Both changes are written as reviewable files and delivered through an ordinary mission. The ledger moves only when apply observes the change on the default branch.

With --launch, Orgabot files a cutover (or rollback) task in the tracking repository carrying the exact one-line diff, links it to the epic, and launches the ordinary issue-fix mission for it: role routing, verification, independent review and a draft pull request all apply. It needs the same autonomy policy as apply --launch. Running the command again for the same request reuses that task and mission instead of filing or launching a second one. A launch that is queued or waiting for a role assignment is reported as held, not as running.

Withdrawing a pending cutover (rollback before it merged) after --launch tells you which mission and task delivered it: cancel that mission and close its pull request. If it merges anyway, drift reports the job as an unowned trigger.

Human tasks

orgabot ci-migrate task list
orgabot ci-migrate task done '<task-id>' --evidence https://github.com/owner/repo/settings/branches
orgabot ci-migrate no-ci plain-site --reason "static site; Orgabot verification covers it"

Tasks cover required checks, environment protection, signing and deployment approvals, secret references, scheduled duties, exceptions and no-CI dispositions. Closing one requires evidence. A required-check task is done after verification, when the new check is required and the legacy one is retired.

New projects and drift

orgabot project add onboards every new registration: its targets are recorded under the organization's policy, the legacy workflows keep owning them, and capability gaps are printed at registration time.

orgabot ci-migrate drift              # exits 1 on a critical finding
orgabot ci-migrate drift --onboard    # bring unmanaged registrations and targets under control

Drift reports a registration with no migration record, a new workflow job outside migration control, a legacy file edited since planning, a cut-over job whose legacy trigger fires again (duplicate triggers), a legacy job disabled with no cutover pending (an unowned trigger: nothing runs it), and records for a project that is no longer registered.

Once a migration has started (the ledger has any record), the recovery sweep runs the same check about once an hour. It onboards new registrations and new workflow targets automatically, writing only the ledger and a bundle: no issue, no mission, no repository change. It logs each new set of critical findings once and keeps the latest result in the ledger, where orgabot ci-migrate status shows it under Background drift check. Duplicate triggers, edited legacy files and unowned triggers are reported, never fixed automatically, because each needs a decision. An install that never started a migration is left alone. You can still run ci-migrate drift as a check that fails a pipeline.

Least-privilege setup

  • Planning needs only read access to the registered roots.
  • apply writes the ledger and bundles under the Orgabot home. Filing conversion tasks uses the governed issue connector (issues read and write on the tracking repository).
  • --launch requires the project's autonomy policy to allow commits and pull requests, and the mission still passes every verify, review and merge gate.
  • Nothing in ci-migrate merges, deploys, changes branch protection, or edits repository settings. Those stay human tasks.

Offline behavior

Planning reads local files and works offline. Reconciliation fetches each default branch when it can; offline, it reads the last fetched view, which can only be behind, so a transition is delayed and never faked. Filing tasks and reading legacy check results need the network; without it a batch records the failed step and --resume continues later.

Troubleshooting

SymptomCause and fix
the project registry could not be readthe registry store is unavailable; nothing was planned. Repair it and rerun
a project shows failed at revalidateit was unregistered or its root moved since the manifest was read; re-plan
cutover refused: ... re-plan firstthe legacy workflow changed since planning; run apply again so the rollback revision is current
parity is unknownthe legacy check run for that commit could not be read; pass --revision for a commit with a completed legacy run
a target stays plannedits generated files are not on the default branch yet; merge the conversion pull request, then apply again
a target went blocked after cutoverits legacy trigger was re-enabled; roll back or restore the cutover
unowned_trigger in drifta legacy job was disabled with no cutover pending; restore its original if: or re-plan and cut it over through the gates
a launched cutover mission shows heldit is queued behind the launch throttle or waiting for a role assignment; answer the role prompt, and the same mission continues
another process holds the CI migration ledgera second batch is running; wait for it or resume later

Rollback

Rolling back a target restores its legacy job exactly and returns trigger ownership to the legacy workflow once the change is observed on the default branch. The rollback stays available until the target is verified and its required-check task is closed, and the legacy file is never deleted, so a rolled-back target can be validated and cut over again later.