CI/CD Pipelines

GitBlixt runs continuous integration pipelines for your repositories. It understands both GitLab CI (.gitlab-ci.yml) and GitHub Actions (.github/workflows/*.yml) configuration, so most repositories work without changing their existing config. Jobs execute in isolated Docker containers, and you watch logs stream live in the web UI.

How pipelines trigger

A pipeline is created automatically whenever you push to a repository (over SSH or HTTP). You can also start one manually from the Pipelines tab: pick a branch and click Run pipeline. Pipelines are scoped to the branch (ref) and commit (SHA) they ran against.

Turning off Run CI/CD in the repository's General settings (builds_access_level in the API) stops pipelines from pushes, merges, merge requests and the Run pipeline button, stops re-runs and CI job retries (a deploy job can still be retried), and refuses merge when pipeline succeeds. A push to the branch of an environment that deploys on every push, or of any auto-deploy environment while CI/CD is off, gets a pipeline with no jobs that only runs the deploy.

The pipeline list lives at /<namespace>/<repo>/-/pipelines. Each pipeline links to a detail page at /-/pipelines/<pipeline_id>, and each job to /-/pipelines/<pipeline_id>/jobs/<job_id>.

Configuration

When a pipeline runs, GitBlixt looks for config in this order — the first match wins:

  1. GitHub Actions workflows in .github/workflows/ (files ending in .yml or .yaml)
  2. GitLab CI in .gitlab-ci.yml at the repository root

Only GitHub Actions workflows whose on: trigger matches a push to the pipeline's branch are run. If neither config exists, or none of its jobs run on the branch, the pipeline is skipped: it never counts as passing CI, though merge when pipeline succeeds still fires on it. On a branch an environment auto-deploys, it runs the deploy instead and takes the deploy's result.

GitLab CI example

Jobs are grouped into stages, which run in order. If you don't declare stages:, the defaults are build, test, deploy. A job's script runs inside the container named by its image.

stages:
  - test

unit:
  stage: test
  image: elixir:1.17
  script:
    - mix deps.get
    - mix test

Supported .gitlab-ci.yml keywords

Jobs take stage, image, script, before_script, after_script, variables, services, parallel, tags, environment and allow_failure: true, which lets the pipeline pass and later stages run when the job fails. needs and artifacts are accepted but not used yet. Which jobs a pipeline creates follows GitLab:

  • workflow: rules decide whether the pipeline gets any jobs. The matched rule's variables reach every job.
  • rules match in order and the first match decides; a job no rule matches isn't created. A rule takes if, exists, changes and variables, which the job gets. exists globs take ** (any directories), *, ? and $VAR; {a,b}, [abc] and \ escapes aren't supported. changes always matches, as GitBlixt doesn't compare against a previous commit, and a rule's allow_failure is ignored.
  • only and except take branch names, /regexes/, keywords such as branches, pushes and merge_requests, or the refs/variables/changes map. In the map form only needs every key to match, while any one key of except excludes the job, so an except with changes always does. They can't be combined with rules. Pipelines started with the Run pipeline button or the API have CI_PIPELINE_SOURCE push, so web and api never match.
  • when: on_success and when: always create the job (always behaves like on_success). never doesn't, and neither do manual, delayed and on_failure, which aren't supported yet.

A job with tags (or default: tags) is meant for a runner on your own machine: a runner of a maintainer of the repository whose tags include every one of the job's gets it once its earlier stages passed. While such a runner has asked for work in the last 2 minutes, GitBlixt waits 5 minutes for it to claim the job before running it itself; otherwise it runs the job right away. A runner that hasn't reported for 5 minutes fails its job. Untagged jobs always run on GitBlixt.

Running CI on your own machine

Tag the jobs you want on your machine with local:

    test:
  tags: [local]
  script:
    - mix test

As a maintainer of the repository, create a personal access token with the api scope and save it to a file. Then, from a GitBlixt checkout on a machine with Docker, run:

mix gitblixt.runner --url https://git.yourdomain.com --token-file ~/.gitblixt-token

The runner runs up to 4 jobs at once (--concurrency) in Docker, as GitBlixt does, with services and the dependency cache kept under ~/.cache/gitblixt-runner (--work-dir), and streams their logs to the pipeline page. --tags takes other tags, --memory and --cpus limit each job. Stop it with Ctrl-C: jobs it was running fail after 5 minutes, and jobs nobody claims run on GitBlixt after 5 minutes.

An if expression sees the predefined and YAML variables and the project variables the job gets, matched to its environment: as described under CI/CD variables. It supports $VAR, quoted strings, null, ==, !=, =~ and !~ with a /regex/ (flags i, m, s), &&, || and parentheses. An expression or keyword GitBlixt can't evaluate fails the pipeline, and the reason, naming the job and the clause, goes to the server log. Rules are checked again when each job starts: a job they no longer create, say because a variable changed, fails with the reason in its log.

A push makes one pipeline, with CI_PIPELINE_SOURCE push. When its rules create no jobs and the branch has an open, ready merge request, it runs as that merge request's pipeline instead, with merge_request_event and the CI_MERGE_REQUEST_* variables, so configs that select jobs for merge request pipelines work.

GitHub Actions example

GitHub Actions jobs are translated into the same job/stage model. Stages are derived from the needs: dependency graph, and common setup actions are mapped to equivalent commands. Expressions like ${{ ... }} are evaluated where supported. A job's continue-on-error lets the pipeline pass when the job fails, and a step's lets the job go on to its next step.

A job's runs-on label picks its image. Ubuntu labels, hosted ones like blacksmith-4vcpu-ubuntu-2204 included, run on that Ubuntu release, self-hosted on the latest one, and any other label is taken as the image name. Of a list of labels, like [self-hosted, linux, x64], the first Ubuntu or self-hosted one wins.

name: CI
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Building on ${{ github.ref }}"
      - run: ./run-tests.sh

Stages and jobs

Each stage's jobs are dispatched when the previous stage completes; jobs in the earliest stage that has jobs start as soon as the pipeline begins. Every job is an entry in the pipeline detail view showing its stage, status, duration, and exit code.

Job names may not start with deploy:: that prefix belongs to GitBlixt's own deploy jobs, and a config that uses it fails its pipeline. A deploy only ever ships the branch its environment tracks.

Statuses

Pipelines and jobs share the same set of statuses:

Status Meaning
pending Created, waiting to start
running Jobs are executing
success All jobs finished successfully, or failed where allowed to
failed At least one job failed
canceled Stopped before completion
skipped Pipelines only: no job ran for the commit

Job logs

Open any job to see its log. Output streams live over the websocket while the job runs and is persisted so you can review it afterward. Use the Copy log button to grab the full output.

Manual actions

From a pipeline or job page you can:

  • Cancel — stop a pending or running pipeline; any running job containers are killed
  • Re-run — re-trigger a finished (success/failed/canceled) pipeline against the same branch and commit
  • Clear cache & re-run — wipe the repository's CI build cache, then re-trigger

CI/CD variables

Variables are defined per repository under Settings. They are injected as environment variables into CI job containers. Each variable has:

Field Description
key Name (letters, digits, underscores; must start with a letter or underscore)
value The value, encrypted at rest
masked Hides the value in logs and the UI (value must be at least 8 characters). A GitHub Actions workflow's ${{ secrets.NAME }} values are hidden whatever this flag.
protected Restricts exposure to protected branches/tags
expose_to_agents Also sets the variable in this repository's scheduled agent runs and Run AI agent jobs, except those started from an idea (no other AI job gets it). Allowed only on a key starting with AGENT_ (upper case), an unprotected variable, scope * and a value of at least 8 characters, which the jobs hide in their logs and output whatever masked says. Protecting or scoping an exposed variable is refused until exposure is turned off. A name the job already sets is never overridden. Turning exposure on is recorded in the audit log. Anything the agent reads can lead it to leak the value, and masking covers only logs and output: expose only low-privilege, revocable keys.
environment_scope Which environment the variable applies to (defaults to *, all). A * inside a scope, as in review/*, matches any characters.

A scoped variable reaches a job when its scope matches an environment that deploys from the pipeline's branch, preview environments included. On a protected branch, a job that names an environment with environment: gets that environment's variables instead, as in GitLab; on other branches the name is ignored, so a job can't widen what it gets. When several scopes set a key, an exact name beats a wildcard, which beats *. Protected variables remain the security boundary: only protected keeps a value off unprotected branches. Jobs that name an environment also get CI_ENVIRONMENT_NAME and CI_ENVIRONMENT_SLUG. A GitHub Actions environment: holding an expression GitBlixt can't evaluate names none.

CI/CD variables are for CI pipeline jobs, and scheduled agent and Run AI agent jobs for those exposed to agents. They are not passed to deployed applications — deployed apps get their own environment variables, configured separately on each app environment.

External runners

mix gitblixt.runner talks to these endpoints; you only need them to write a runner of your own. Authenticate with a personal access token with the api scope (PRIVATE-TOKEN or Authorization: Bearer). A runner is handed only tagged jobs of repositories its user is a maintainer or owner of, as owner, collaborator, team or organization member; being an instance admin doesn't count. The three endpoints share a limit of 1200 requests a minute per user, and a 429 carries Retry-After.

Endpoint Purpose
POST /api/v4/jobs/request Body {"tags": ["local"]}. Claims the oldest pending job whose tags are all among the runner's and whose earlier stages passed: 201 with the job, or 204 when there is none.
PATCH /api/v4/jobs/:id/trace Appends the raw body (at most 2 MB, else 413) to the job's log; invalid UTF-8 is replaced and NUL bytes dropped. Counts as a heartbeat.
PUT /api/v4/jobs/:id {"status": "running"} is a heartbeat; success, failed or canceled with an exit_code from 0 to 255 finishes the job and moves the pipeline on.

The claimed job carries id, name, stage, pipeline (id, ref, sha), project (id, path_with_namespace), git (the HTTP clone url and sha), image, scripts, services (name, image, env, ports, options, command), variables, ci_env ([name, value] pairs), masked (values to hide in the log), cache_key, timeout_seconds and claim_token. Send the token as the Job-Token header on every trace and status update. Once the job is no longer running under that claim (canceled, retried, or failed after 5 minutes without a report), both answer 403 with 403 Forbidden - Job is not running: stop the job.