◆ docs

CI / Actions

rickub runs your continuous-integration workflows automatically. Add a workflow file, push, and watch it run.

Add a workflow

Create a YAML file under .github/workflows/ in your repository. On the next push, rickub picks it up and runs it. You may instead keep rickub-native workflows in .rickub/workflows/, which takes precedence: if that directory exists, rickub reads workflows only from it and ignores .github/workflows/ entirely. A minimal workflow:

# .github/workflows/ci.yml
name: CI
on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: |
          make build
          make test

Triggers

The on: key controls when a workflow runs — for example push, pull_request, or a manual, button-triggered workflow_dispatch. A workflow_dispatch can be asked for from the Run workflow form on the Actions tab, the REST API, or MCP — with one exception: a deploy gate's break-glass override (the override-input: of a needs-workflows: job) is a signed-in human's act and is accepted only from the Run workflow form. The API and MCP refuse it with breakglass_refused, so a script or an agent holding an admin token cannot skip the deploy gate; an admin has to tick the box themselves. See migrating from GitHub for the gate itself.

The wired set is pull_request, push, release, schedule, workflow_call and workflow_dispatch; any other on: verb (issue_comment, pull_request_target, workflow_run, merge_group, …) parses but never fires. The Actions tab names the workflows carrying unwired verbs — with the verbs — so an inert trigger is never silent, and every run of a mixed workflow (on: [push, workflow_run]) repeats them on its run page. The migration guide's trigger table is pinned to the parser's own event set by a test, so it cannot drift.

Jobs and steps

A workflow has one or more jobs; each job runs a series of steps. A step either runs a shell command (run:) or uses a reusable action (uses:). Jobs run on a fresh runner each time.

Secrets & variables

Store sensitive values as secrets and non-sensitive configuration as variables, at the repository or organization level, under Settings → Secrets and Settings → Variables. Reference them in a workflow:

steps:
  - name: Deploy
    run: ./deploy.sh
    env:
      API_TOKEN: ${{ secrets.API_TOKEN }}
      REGION: ${{ vars.REGION }}
Heads up

Secrets are write-only once saved and are masked in logs — never echo a secret or print it to the run output.

Push to your repository from a workflow

A workflow can commit and push back to its own repository — the pattern behind generated changelogs, version bumps, formatter fixes and scheduled data snapshots. You do not need a personal access token. Every job is handed a short-lived GITHUB_TOKEN scoped to that one repository, and the checkout's origin is already set up to use it, so a plain git push works:

name: Snapshot
on:
  schedule:
    - cron: "0 * * * *"

permissions:
  contents: write        # required — a job is READ-ONLY unless it asks

jobs:
  collect:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./collect.sh > data/latest.json
      - run: |
          git config user.name  "rickub-actions[bot]"
          git config user.email "actions@rickub.com"
          git add -A
          git diff --quiet --cached || git commit -m "chore: refresh snapshot"
          git push

Two rules decide whether the push is allowed, and both are the same ones GitHub applies:

  • The workflow must declare permissions: contents: write. A job that declares no permissions: block is read-only, so its push is refused.
  • A job running a fork's pull request can never push, whatever it declares. Untrusted code is capped at read — exactly as a fork's GITHUB_TOKEN is on GitHub.
Note

The bare git push above works because a job for a branch is checked out on that branch, with its upstream set. A job for a tag — or for a fork pull request's merge ref — is checked out with a detached HEAD, exactly as on GitHub: there is no branch such a build belongs on. Pushing from one of those jobs needs an explicit target, e.g. git push origin HEAD:refs/heads/main.

The token only ever reaches its own repository: a job cannot push to, or fetch from, any other repository with it. To write somewhere else you still need a credential for that repository, stored as a secret.

Note

A push made with GITHUB_TOKEN does not start new workflow runs. That is deliberate, and matches GitHub: without it a workflow that commits its own output would trigger itself forever. Webhooks still fire for the push. If you genuinely need the push to start another workflow, push with a token belonging to a user (a PAT stored as a secret) — and be sure you have thought about the loop.

View runs & logs

Every run appears under the repository's Actions tab. Open a run to see its jobs and steps and to stream the logs live as it executes.

A workflow run showing its status, timing, and jobs arranged by stage
A run: status and timing up top, then its jobs laid out stage by stage. Open a job for its steps and streaming logs.

Live example: portal's Actions tab — run 4 is a green ci run on main, run 3 a red one you can open and read.

Re-run & cancel

Re-run a finished run to execute it again at the same commit — handy for flaky failures — and Cancel an in-flight run you no longer need. Both are available from the run page and from the CLI (rickub run rerun, rickub run cancel).

Retry a step on network errors

A step that downloads things — go mod download, docker build pulling a base image, npm ci — can fail on a one-off network error that has nothing to do with your change. retry: runs such a step again, only when its failure was network-class:

# .rickub/workflows/build.yml
jobs:
  image:
    runs-on: large
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: docker build -t app .
        retry:
          attempts: 2     # total runs, the first one included (2 or 3)
          on: network     # the only class; may be omitted
  • What counts as network-class. The step's own last 40 lines of output before it exited show one of: connect: no route to host, a transfer cut mid-pack (fetch-pack: unexpected disconnect, fatal: early EOF), or an image pinned by digest (@sha256:…) reported not found / manifest unknown by its registry. If those same lines also show a real failure — a failing test or a build failure from Go, Rust, pytest, Maven, Gradle or jest, a compile error, a coverage-gate verdict — the step is not retried. A compile error or a red test never runs twice.
  • The pause. The retry starts 15 seconds after the failed attempt, because these errors rarely clear within a second. The pause and the retry both count against the step's timeout-minutes:; a step whose timeout fires is never retried.
  • What you see. The failed attempt's output stays in the log, followed by a warning annotation — attempt 1/2 failed with a network-class error (Network transient) — retrying in 15s — then the retry's output. A job that went green on a retry is visible as such on the run page. If the retry fails too, the job's failure reason is the last attempt's: a retry that gets past the network and then hits a compile error reads as that compile error, not as a network problem.
  • Idempotence is yours. The whole run: script runs again in the same workspace. Anything it appended the first time — to a file, to $GITHUB_OUTPUT, $GITHUB_ENV, $GITHUB_PATH or the step summary — is still there, and the retry appends again. Write retried steps so a second run is harmless (overwrite rather than append, or clean up first).
  • Only run: steps. A uses: action step cannot declare retry: (its staging and post steps are not re-enterable) — that is a parse error.
  • Known limit. A digest that was really deleted or garbage-collected upstream looks exactly like a registry hiccup, so it is retried once and reported as Network transient on every run. If that label repeats on the same pinned digest, re-pin the image.
Heads up

retry: is a rickub extension and works only in .rickub/workflows/. In .github/workflows/ it is a parse error, for the same reason as reports: below: those files must stay valid on github.com.

Test reports

A job can declare the test reports it produces, and rickub parses them into a Tests tab on the run page: which tests failed, the assertion that failed, the stack, and a per-suite breakdown — instead of scrolling a log looking for the word FAIL.

Nothing is re-executed. reports: names files your steps have already written: rickub matches the globs against the workspace after the last step and reads what is on disk. Your test command runs exactly once — in the step where you wrote it — and declaring a report adds no time to the job.

# .rickub/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - id: go                       # give the test step an id…
        run: go test ./... -json > report.json

    # a key on the JOB, not a step
    reports:
      go:                        # the report's name; several per job
        tests: report.json       # a glob, or a list of globs
        from: go                 # …and point the report at it
        format: go-json          # optional; inferred when omitted
      rust:
        tests: target/nextest/ci/junit.xml
        on-missing: warn         # warn (default) | ignore | fail
Heads up

reports: is a rickub extension and works only in .rickub/workflows/. In .github/workflows/ it is a parse error, on purpose: those files are also meant to run on github.com, where reports: is invalid — silently accepting it here would hand you one file that behaves differently on the two hosts. Move the file to .rickub/workflows/ to use it. Creating .rickub/workflows/ is itself a switch: rickub reads workflows from one directory per repository, so the moment that directory exists every workflow still under .github/workflows/ is ignored — move them or delete the directory. See Migrating from GitHub Actions.

Because reports: is attached to the job rather than being a step, it is collected after the last step whatever the job concluded. That is the point of the design: everywhere else in this category the report is a step, and a failing test step skips it — which is why GitLab documents when: always, Jenkins post { always }, Azure succeededOrFailed() and dorny if: !cancelled(). Here there is nothing to remember.

The keys

  • tests: (required) — a glob or a list of globs, relative to the workspace, matching the report files. Several matches are merged into one report, so one file per package or per shard is fine.
  • format: — junit or go-json. Omit it and the format is inferred from the file's own contents, falling back to its extension.
  • from: — the id: of the step that produced the report. The panel shows that step's own run: text on its meta line, so the command is written once and cannot drift from a retyped copy. An id the job does not declare is a parse error naming the id and the ones the job declares — a typo fails the run loudly instead of rendering a label that describes nothing. If both from: and command: are given, from: wins.
  • command: — a label shown on the panel's meta line. It is never executed: it is text describing a command that already ran, not an instruction to run one. It is the fallback for a report no single step produced (a composite action, a matrix leg) — prefer from: whenever a step exists.
  • time-unit: — seconds, milliseconds or nanoseconds, for a producer whose durations do not follow its format's convention.
  • on-missing: — what to do when the glob matches nothing: warn (the default — the run says so), ignore, or fail (the only way a report can change a job's conclusion).

What each format gives you

JUnit XML is the wire format because every language already emits it — pytest, Maven Surefire, Gradle, Jest, Vitest, gotestsum, cargo-nextest, PHPUnit, RSpec. There is no single JUnit schema (the three usually cited as authoritative contradict each other), so rickub parses the union leniently: every attribute is optional, no missing field ever loses a report, and every count is recomputed from the individual tests rather than trusted from the file's own totals.

  • JUnit XML — suites, per-test durations, failure messages and stacks, skips, captured output for failures, and Surefire's rerun/flaky records. Producers disagree about the root element (Maven Surefire writes a bare <testsuite> with no <testsuites> wrapper; pytest always wraps), and both parse. pytest's junit_family has defaulted to xunit2 since pytest 6.1, and xunit2 drops the file and line attributes, so failures from it cannot point at a source line; junit_family=xunit1 keeps them — see Python: pytest. cargo-nextest omits skipped tests unless you set report-skipped, and rickub says so on the panel rather than showing a skip count of 0 as fact.
  • go test -json — statuses, per-test durations (10 ms granularity) and the package as the suite. Go emits no structured failure field at all, so the assertion line is recovered from the file_test.go:12: message convention; a test that fails without following it shows its first output line instead. Subtest output is attributed to the root test, as gotestsum also does, because Go's own attribution is unreliable under t.Parallel.

Java: Maven and Gradle

Maven's Surefire plugin already writes JUnit XML — you do not add a step for it. It puts one file per test class in target/surefire-reports/, and the Failsafe plugin puts your integration tests in target/failsafe-reports/. Point tests: at both and the two arrive as one report per key:

# .rickub/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'
      - run: mvn --batch-mode verify
        id: verify

    reports:
      unit:
        tests: target/surefire-reports/*.xml
        from: verify
      integration:
        tests: target/failsafe-reports/*.xml
        from: verify
        on-missing: ignore     # nothing to report if you run no IT classes

Neither key here sets format:: these are JUnit XML files and rickub infers that from their contents, so it is only worth spelling out for a producer whose output is ambiguous. Both use from: to point at the step that ran the tests, and the panel shows that step's own run: text — the command is written once, on the step, and the report cannot describe anything else.

A multi-module build writes one report directory per module. Use **, which matches any number of directories — including none, so the root module's own reports come along:

    # still the job's own key
    reports:
      unit:
        tests: "**/target/surefire-reports/*.xml"   # every module, at any depth
        from: verify

The two wildcards are different and both are useful: * matches within ONE path segment and never crosses a /, while ** stands for whole segments. So */target/surefire-reports/*.xml is exactly "the modules directly beside the root" — write that when you mean it, and ** when the nesting is deeper or you would rather not care. ** has to be a path segment on its own: **.xml is an error rather than a synonym for *.xml, and the run says so instead of quietly matching nothing.

Note

A ** starting at the workspace root walks the tree, so it walks node_modules/ and every target/ on the way. The walk is capped and the run says so if it hit the cap — but naming a directory level (*/target/surefire-reports/*.xml) is both faster and more precise when you already know the layout.

Gradle is the same shape with a different path: the test task writes to build/test-results/test/, so tests: build/test-results/test/*.xml after a ./gradlew test step, or **/build/test-results/test/*.xml across a multi-project build.

Tip

Surefire is also the dialect that records retries. Run it with -Dsurefire.rerunFailingTestsCount=2 and an attempt that eventually passed is written as <flakyFailure>, while one that never did keeps a <rerunFailure> per attempt. rickub reads both. A <flakyFailure> counts as a pass — the test did pass — but the panel marks it flaky in its suite's case list, counts it on the suite header beside "passed", and says how many the report holds at the top, so a green run that only went green on a retry never looks like a clean one. A <rerunFailure> is still a failure, reported once with its retry count, not as three separate failures of the same test.

Python: pytest

pytest writes JUnit XML with --junit-xml (spelled --junitxml too — both are the same option). There is no plugin to install:

# .rickub/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.13'
      - run: pip install -r requirements.txt
      - id: pytest
        run: pytest --junit-xml=report.xml -o junit_family=xunit1

    reports:
      pytest:
        tests: report.xml
        from: pytest
Heads up

The -o junit_family=xunit1 above is the whole point of this example. pytest's junit_family default changed to xunit2 in pytest 6.1, and xunit2 does not write the file and line attributes on <testcase> — the two things a failure needs to point back at the source line that produced it. Everything else survives; only the location is gone, and nothing warns you. Set it on the command line as above, or once in your config: junit_family = xunit1 under [pytest] in pytest.ini, [tool:pytest] in setup.cfg, or [tool.pytest.ini_options] in pyproject.toml.

Splitting the suite across shards? Give each one its own file (pytest --junit-xml=reports/${{ matrix.shard }}.xml) and point tests: at reports/*.xml — several matches merge into one report, with the counts recomputed from the cases rather than summed from the files' own totals.

Note

Ingest is bounded and never fails your job: 10 MB per file, 25 MB per job, 10,000 tests per run, and 8 KB of failure text per test. Past any of those, rickub stores what fits and says on the panel that it did. Reports are kept for the newest 10 runs per job; a report's passing tests are dropped after a week, its failures are kept.

Status badges

Show the latest CI status in your README with a status badge image that links to the Actions tab.

CI-minute quotas

Each plan includes a monthly allowance of CI minutes — the compute time your runs consume. Usage resets every month; the allowance on each plan is listed on the pricing page.

On the Free plan, CI minutes and AI reviews are one pool per account: your personal namespace and every free organization you own draw on the same monthly allowance, so creating another organization does not add another one. A namespace on a paid plan has its own pool. Your usage page names the namespaces a figure counts.

Was this page helpful?