PolyCI

module
v0.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 31, 2026 License: MIT

README

PolyCI

PolyCI is a local debugger for CI pipelines. Instead of editing a script, pushing, and waiting for a remote runner to fail again, you run the failing job on your own machine, pause it step-by-step, and drop into a real shell inside the exact container where it broke — no separate debug action, no guesswork about what the runner's environment looked like.

It works across GitLab CI, CircleCI, and GitHub Actions: it parses your existing .gitlab-ci.yml, CircleCI config.yml, or GitHub Actions workflow file, converts it into one internal provider-agnostic pipeline model (jobs → steps), and runs it against your local Docker engine, streaming logs to the terminal exactly the way real CI would.

Why this project exists

No local CI tool — including act — ships a built-in step-by-step debugger or an interactive on-failure shell. That's the actual gap PolyCI fills. Building it properly meant not tying the debugger to one provider's format, which turned out to close a second gap for free: GitLab CI and CircleCI have no local runner at all today, act-style or otherwise, so PolyCI covers those too.

Relationship to act

act is a mature, actively developed local runner for GitHub Actions, with a large community and editor integrations. PolyCI is not trying to replace or out-compete act on GitHub Actions — it already does that job well, and duplicating it isn't where this project adds value.

PolyCI's actual differentiator is the debugger:

  1. A built-in step-by-step debugger and shell-on-fail (pause after every step, choose continue/abort, or drop into a real shell inside the failing container) — a genuine gap even for GitHub Actions users of act, and the reason this project exists.
  2. It happens to also cover GitLab CI and CircleCI, where no equivalent local runner exists at all otherwise — a side effect of building the debugger on a provider-agnostic engine rather than a consequence of targeting those providers specifically.
  3. One tool for repos that use more than one CI provider, instead of juggling a separate local runner per provider.

If you only use GitHub Actions and don't need step-by-step debugging or shell-on-fail, act remains the more mature, better-supported choice on its own.

Installation

PolyCI is a single Go binary with no runtime dependencies beyond a running Docker engine.

Requirements:

  • A running Docker engine reachable from DOCKER_HOST or the active Docker CLI context — Docker Desktop, Colima, Rancher Desktop, or any other engine that speaks the standard Docker API all work
  • Go 1.27 or later, only if installing via go install or building from source
Install via Homebrew

The recommended way to install on macOS or Linux:

brew tap sqweyyy-create/polyci
brew install polyci

(On newer Homebrew versions, tapping a third-party repository for the first time may ask you to run brew trust sqweyyy-create/polyci before it will install from it.)

Alternative: go install
go install github.com/sqweyyy-create/PolyCI/cmd/polyci@latest

This puts polyci in $(go env GOPATH)/bin — make sure that's on your $PATH.

Alternative: build from source
git clone https://github.com/sqweyyy-create/PolyCI.git
cd PolyCI
go build -o polyci ./cmd/polyci

This produces a polyci binary in the current directory. Move it onto your $PATH (e.g. mv polyci /usr/local/bin/) if you want to run it from anywhere.

Usage

Run polyci run from the root of the project whose pipeline you want to debug or test — the current directory is bind-mounted read-write into every job's container at /workspace, so jobs see your actual files, the same way a real CI checkout would.

Debugging

Add -debug to pause after every step, see its result, and choose whether to continue or abort — or drop into a manual shell in the job's container by answering s:

polyci run -debug
polyci run -provider circleci -debug
polyci run -provider github-actions -f .github/workflows/ci.yml -debug
[build] step "script[0]" done — continue? [Y/n/a=abort/s=shell]

Add -shell-on-fail to automatically drop into an interactive shell in a job's container the moment a step fails, without needing -debug's per-step prompts:

polyci run -shell-on-fail

Both flags can be combined: shell on failure, then still get asked to continue or abort once you exit the shell.

When a step failed and you enter its shell (either way), exiting the shell offers a third option alongside continue/abort:

[build] step "script[0]": [c]ontinue/[a]bort/[r]etry?

r re-runs that exact step — same container, same command — instead of moving on or stopping the pipeline. If it fails again, you get the same prompt again; if it succeeds, the pipeline continues normally from the next step.

Planning (dry run)

polyci plan parses a config and prints its resolved structure — jobs, their dependencies, and which would run in parallel versus sequentially — without running anything. No Docker container is created and no Docker engine connection is even attempted, so it works even without Docker running:

polyci plan
polyci plan -provider circleci
polyci plan -provider github-actions -f .github/workflows/ci.yml
Plan for .gitlab-ci.yml (3 job(s)):

Level 0: build
  - build [stage=build image=alpine:3.19] depends on: (none)
Level 1 (parallel): test, lint
  - test [stage=test image=alpine:3.19] depends on: build
  - lint [stage=test image=alpine:3.19] depends on: build

Levels run one after another; jobs within the same level run in parallel.
Checking compatibility

polyci check parses a config and reports how faithfully PolyCI can run it — every job/step is classified as Supported, Emulated (handled, but not identically to the real provider — e.g. checkout is a no-op because the workspace is already mounted), or Unsupported (recognized but not implemented, which may change job behavior). Like plan, it never touches Docker and never runs anything:

polyci check
polyci check -provider circleci
polyci check -provider github-actions -f .github/workflows/ci.yml
Compatibility check for .circleci/config.yml (3 job(s) runnable, 0 job(s) skipped, 6 step(s)):

Emulated (approximated, not a faithful implementation):
  - [build/checkout[0]] checkout: Emulated — checkout is a no-op: the repo is already mounted at /workspace

Category breakdown:
  Environment:              Supported
  Shell/working-directory:  Not Present
  Filesystem/checkout:      Emulated
  Services:                 Not Present
  Expressions:              Not Present

Overall confidence: MEDIUM

Rather than a single percentage — which would average "emulated" and "fully supported" into one misleadingly precise number — check breaks fidelity down by category (Environment, Shell/working-directory, Filesystem/checkout, Services, Expressions; "Not Present" means the config never exercises that category at all), then reduces that to an overall HIGH/MEDIUM/LOW confidence label: HIGH means every category the config actually uses is fully Supported; MEDIUM/LOW scale with how many categories are Emulated or Unsupported (each Unsupported category counts double an Emulated one).

Any job PolyCI recognized but couldn't run at all (see "Partial job execution" below) is listed by name with its reason, separately from the Emulated/Unsupported feature findings.

Run this before polyci run on an unfamiliar real-world config to see what won't behave identically to the real provider — see COMPATIBILITY.md for a compatibility pass against 15 real-world configs.

Partial job execution

A job PolyCI can't run at all — most commonly a GitHub Actions job with no container:, but also a CircleCI job on a non-docker: executor, or a workflow job referencing something not defined under jobs: (e.g. an orb-provided job) — no longer fails the whole file. It's individually skipped, with a clear reason, while every other job in the same file still runs normally. A job that needs:/requires: a skipped job is skipped too, since it can't run without it, but that never affects jobs unrelated to it. Both polyci run and polyci check (and polyci plan) report every skipped job by name and reason:

==> [legacy-deploy] SKIPPED: no container: specified — running jobs on a bare runs-on: runner (without an explicit container image) is not supported
==> [publish] SKIPPED: depends on skipped job "legacy-deploy": no container: specified — running jobs on a bare runs-on: runner (without an explicit container image) is not supported
==> [build] stage=level-0 image=alpine:3.19
...

This never changes what a runnable job does — it only stops one unsupported job from silently taking three working ones down with it.

Running a pipeline

The same polyci run works across all three providers:

GitLab CI:

# Looks for .gitlab-ci.yml in the current directory by default
polyci run

# Or point at a specific file
polyci run -provider gitlab -f path/to/.gitlab-ci.yml

CircleCI:

# Looks for .circleci/config.yml in the current directory by default
polyci run -provider circleci

# Or point at a specific file
polyci run -provider circleci -f path/to/config.yml

GitHub Actions:

Workflow files can be named anything under .github/workflows/, so there's no sensible default — -f is required:

polyci run -provider github-actions -f .github/workflows/ci.yml

Only jobs with an explicit container: are runnable (see Known Limitations).

A step's shell: and working-directory: are both honored:

steps:
  - run: ls
    shell: bash
    working-directory: subdir

shell: supports sh (the default if unset) and bash; anything else (pwsh, python, cmd, a custom command {0} form) is a clear error rather than a silent fallback to sh. working-directory: may be relative (resolved against /workspace) or absolute.

A small, deliberately minimal subset of ${{ }} expression syntax is also evaluated — everything else in the real GitHub expression language (functions like contains(), secrets.*, needs.*.outputs, steps.*, etc.) is left as the literal, unexpanded text and reported by polyci check as an unsupported expression rather than silently passed through or guessed at:

  • ${{ matrix.<key> }} — substituted from the job's own strategy.matrix; see matrix expansion below.
  • ${{ env.<KEY> }} — substituted from the workflow/job's own env:.
  • ${{ github.sha }} / ${{ github.ref }} — substituted from the local git repository at the workspace root (git rev-parse HEAD and the current branch's full ref). Falls back to an empty string (with a Finding, not silently) if the workspace isn't a git repository, or (for github.ref only) if HEAD is detached.

A job's strategy.matrix: is expanded into one job per combination — each combination is scheduled and runs independently, with ${{ matrix.<key> }} substituted per-job — rather than running the job once with the matrix ignored:

jobs:
  build:
    container: node:20
    strategy:
      matrix:
        node: [18, 20]
    steps:
      - run: echo "testing on node ${{ matrix.node }}"
$ polyci plan -provider github-actions -f .github/workflows/ci.yml
Plan for .github/workflows/ci.yml (2 job(s)):

Level 0 (parallel): build (node=18), build (node=20)
  - build (node=18) [stage=level-0 image=node:20] depends on: (none)
  - build (node=20) [stage=level-0 image=node:20] depends on: (none)

A job that needs: a matrix job waits for every one of its combinations. matrix.include:/matrix.exclude: aren't applied — only the cartesian product of list-valued matrix keys is expanded — and a Finding notes that explicitly when either is present.

Service containers

Jobs that need a database or other backing service can declare one — GitLab's services: and CircleCI's additional docker: entries are both supported. Each service starts in its own container on a network shared with the job, so the job can reach it by hostname; both the service containers and the network are cleaned up when the job finishes, whether it succeeds or fails.

GitLab CI:

test:
  image: postgres:16-alpine
  services:
    - name: postgres:16-alpine
      alias: postgres
      variables:
        POSTGRES_PASSWORD: testpass
  script:
    - psql -h postgres -U postgres -c "SELECT 1"

A bare string (- postgres:16-alpine) works too — the hostname then defaults to the image name without its registry path or tag, so postgres:16-alpine becomes reachable as postgres. services: can also be set once at the top level as the default for every job; a job's own services: replaces that default rather than adding to it.

CircleCI:

jobs:
  build:
    docker:
      - image: cimg/base:2023.03
      - image: postgres:16-alpine
        name: postgres
        environment:
          POSTGRES_PASSWORD: testpass
    steps:
      - run: psql -h postgres -U postgres -c "SELECT 1"

The first docker: entry is the job's own image; every entry after it becomes a service, reachable by its name: (or, if that's omitted, the same default-from-image-name rule as GitLab).

Known Limitations

  • The workspace bind mount requires the Docker engine to be able to see the host path being mounted. Colima (a common local Docker engine for macOS) only shares $HOME into its VM by default — a project outside your home directory will fail to mount with "bind source path does not exist". If you hit this, either move the project under $HOME or add its path to Colima's mounts: config (~/.colima/default/colima.yaml) and restart Colima.
  • Containers run as root by default, so files a job writes into /workspace typically show up on the host owned by root (or whatever UID the container image defaults to) rather than your own user — a known annoyance shared with other local-CI-runner tools, not yet addressed here (e.g. by matching the container's user to the host UID).
  • GitHub Actions' equivalent of service containers (a job's services:) isn't supported yet — only GitLab CI and CircleCI have it today (see Service containers above).
  • The GitLab parser resolves extends: one level deep (including variables: deep-merging, everything else child-wins-if-present) — a job extending another job that itself uses extends: only gets that first level merged, not the full chain; polyci check flags this case as Unsupported so it's visible rather than silently under-merged. include: is recognized but not fetched or expanded — only jobs defined directly in the file being parsed are considered.
  • The CircleCI parser only supports the docker executor (not machine, macos, or windows), doesn't expand orbs: or top-level commands:, and doesn't support aliasing a job to a different name via name: in a workflow's job list. Unsupported builtin steps (save_cache, restore_cache, persist_to_workspace, attach_workspace, store_artifacts, store_test_results, setup_remote_docker, add_ssh_keys, deploy) become a visible no-op log line rather than erroring, so real-world configs still parse and run.
  • The GitHub Actions parser only runs jobs that declare an explicit container: — GitHub's default runner model executes steps directly on a VM (runs-on: ubuntu-latest) rather than in a container, and approximating that with a Docker image (as act does, with its own curated image set) is out of scope; a job without container: is individually skipped with a clear reason (see Partial job execution above) rather than silently doing nothing or failing the whole file. Only a small, deliberately minimal subset of ${{ }} expression syntax is evaluated — matrix.<key>, env.<KEY>, github.sha, and github.ref (see Running a pipeline above) — everything else (functions like contains(), secrets.*, needs.*.outputs, steps.*) is left as literal, unexpanded text and reported by polyci check as an unsupported expression rather than silently passed through (which would usually just error in the shell) or guessed at. strategy: matrix: is expanded into one job per combination for list-valued matrix keys, but matrix.include: and matrix.exclude: aren't applied. It doesn't support uses: a local/composite action. Only one workflow file is run per invocation, not every file in that directory. A step's shell: and working-directory: are honored (see Running a pipeline above), but only sh and bash are supported shells — pwsh, python, cmd, and the custom command {0} form all fail clearly rather than silently running under sh. There's no support yet for setting shell: or working-directory: once for a whole job via defaults: run:. The GitLab and CircleCI parsers don't populate either field yet either — GitLab's script: has no equivalent keyword at all, but CircleCI's run: step does have its own real shell:/working_directory: keys that just aren't wired up yet — so every step from either of those two providers still runs as sh -c at the workspace root regardless of what the config says.
  • Partial job execution (see above) only skips a job for a job-level reason — a config-wide problem (invalid YAML, a missing top-level jobs: section, GitLab's include:, multi-document YAML) still fails the whole file, since there's no individual job to skip in the first place. A needs:/requires: reference to a name that was never declared as a job at all (a real typo, not a recognized-but- unsupported job) is still a hard parse error too, to keep that distinct from "PolyCI recognizes this job but can't run it."
  • Phase 3's shell-on-fail feature is verified manually with a real TTY, not by an automated test — a github.com/creack/pty-based Go test was attempted but hung unreliably and was removed rather than left flaky in the suite.

See CLAUDE.md for the full build rationale, phase history, and development conventions.

License

MIT

Directories

Path Synopsis
cmd
polyci command
Command polyci runs CI/CD pipelines locally in Docker containers.
Command polyci runs CI/CD pipelines locally in Docker containers.
internal
circleci
Package circleci parses CircleCI's config.yml (2.1) into the same provider-agnostic pipeline model the GitLab parser produces, so it runs on the existing Docker executor and debugger unchanged.
Package circleci parses CircleCI's config.yml (2.1) into the same provider-agnostic pipeline model the GitLab parser produces, so it runs on the existing Docker executor and debugger unchanged.
dag
Package dag resolves a named dependency graph into levels: each node's distance from having no unmet dependencies, and a flat execution order grouped by ascending level.
Package dag resolves a named dependency graph into levels: each node's distance from having no unmet dependencies, and a flat execution order grouped by ascending level.
debugger
Package debugger implements the pause/resume layer on top of the Docker executor: after every step it shows the step's result and asks the user whether the pipeline should continue or abort, and on failure it can drop the user into an interactive shell in the same container.
Package debugger implements the pause/resume layer on top of the Docker executor: after every step it shows the step's result and asks the user whether the pipeline should continue or abort, and on failure it can drop the user into an interactive shell in the same container.
executor
Package executor runs a provider-agnostic pipeline.Pipeline by creating a Docker container per job and executing its steps inside it via the Docker Engine API (never by shelling out to the docker CLI).
Package executor runs a provider-agnostic pipeline.Pipeline by creating a Docker container per job and executing its steps inside it via the Docker Engine API (never by shelling out to the docker CLI).
githubactions
Package githubactions parses a GitHub Actions workflow file into the same provider-agnostic pipeline model the other parsers produce, so it runs on the existing Docker executor, debugger, and workspace mount unchanged.
Package githubactions parses a GitHub Actions workflow file into the same provider-agnostic pipeline model the other parsers produce, so it runs on the existing Docker executor, debugger, and workspace mount unchanged.
gitlab
Package gitlab parses .gitlab-ci.yml files into the provider-agnostic pipeline model defined in internal/pipeline.
Package gitlab parses .gitlab-ci.yml files into the provider-agnostic pipeline model defined in internal/pipeline.
pipeline
Package pipeline defines the provider-agnostic pipeline model that every CI provider's parser converts into, so the Docker execution engine and the debugger are written once and reused across providers.
Package pipeline defines the provider-agnostic pipeline model that every CI provider's parser converts into, so the Docker execution engine and the debugger are written once and reused across providers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL