Workflows

A SaturnCI pipeline is a Ruby file called a workflow. It lives in your repository at .saturnci/workflows/default_workflow.rb and it decides what runs on a commit.

How a workflow runs

When you push, SaturnCI creates a workflow run for the commit and evaluates your workflow: it runs the file in a container that already has the SDK installed, with the workflow run's id in the environment.

SaturnCI evaluates the workflow again every time a job in that workflow run finishes, and periodically while the run is still going. Each evaluation is a fresh process. The file doesn't wait for anything: it looks at the current state of the workflow run, starts whatever is now ready to start, and exits. When the pipeline is complete, it tells SaturnCI the workflow run is finished.

Because the same file is evaluated many times per commit, everything it creates is created with idempotent: true. The first evaluation creates the job run; every later evaluation gets back that same job run and its current status.

Inside the container, the environment contains:

  • WORKFLOW_RUN_ID — the id of the workflow run being evaluated
  • SATURNCI_ACCESS_TOKEN — an API token scoped to this evaluation
  • BRANCH_NAME, COMMIT_HASH, COMMIT_MESSAGE, AUTHOR_NAME — the commit's git metadata

A minimal workflow

This workflow runs the test suite and finishes.

# .saturnci/workflows/default_workflow.rb

class DefaultWorkflow
  def initialize(env: ENV)
    @env = env
  end

  def perform(io: $stdout)
    test_suite_run = workflow_run.test_suite_runs.create(
      job_name: "test_suite",
      task_adapter_name: "rails_rspec",
      environment_name: "rspec",
      service_name: "saturnci_test_app",
      command: "bundle exec rspec",
      idempotent: true
    )

    test_suite_run.start

    io.puts "Started test_suite run: id=#{test_suite_run.id} url=#{test_suite_run.url}"

    workflow_run.finish if test_suite_run_finished?
  end

  private

  def test_suite_run_finished?
    run = workflow_run.job_runs.list(job_name: "test_suite").first
    return false unless run

    run.passed? || run.failed?
  end

  def workflow_run
    @workflow_run ||= SaturnCI::WorkflowRun.find(
      client: saturnci_client,
      id: @env.fetch("WORKFLOW_RUN_ID")
    )
  end

  def saturnci_client
    SaturnCI::Client.new(
      SaturnCI::Credentials.new(api_token: @env.fetch("SATURNCI_ACCESS_TOKEN"))
    )
  end
end

DefaultWorkflow.new.perform if $PROGRAM_NAME == __FILE__

Read it as a sequence of evaluations:

  1. On push: the test suite run is created and started. It isn't finished yet, so the file returns.
  2. When the test suite finishes: create returns the existing run, now passed or failed, so the workflow run is finished.

test_suite is a job defined in your repository under .saturnci/jobs/.

Running jobs in parallel

Create and start several job runs in the same evaluation and they run concurrently. Later evaluations check each one.

rubocop_job_run = workflow_run.job_runs.create(
  job_name: "rubocop",
  task_adapter_name: "shell",
  idempotent: true
)

rubocop_job_run.start

javascript_job_run = workflow_run.job_runs.create(
  job_name: "javascript",
  task_adapter_name: "shell",
  idempotent: true
)

javascript_job_run.start

Running a job only after another one passes

Look up the earlier run and return if it hasn't passed yet.

test_suite_run = workflow_run.job_runs.list(job_name: "test_suite").first
return unless test_suite_run&.passed?

list returns [] when nothing with that name has been created yet.

Running a job only on one branch

return unless workflow_run.branch_name == "main"

Passing values to a job

Extra keyword arguments to create become the job's params, and reach the job's container as upper-cased environment variables.

deploy_job_run = workflow_run.job_runs.create(
  job_name: "deploy",
  task_adapter_name: "shell",
  container_image_url: "registry.example.com/app:abc123",
  idempotent: true
)

# The deploy job sees CONTAINER_IMAGE_URL=registry.example.com/app:abc123

Params can also be set after creation:

deploy_job_run.update(container_image_url: image_url)

Passing values between jobs

A job can read another job's params.

production_image_build_job_run = workflow_run.job_runs
  .list(job_name: "production_image_build")
  .find(&:passed?)

image_url = SaturnCI::JobRun
  .find(client: saturnci_client, id: production_image_build_job_run.id)
  .params["container_image_url"]

Nesting a run under another

Pass parent_job_run_id: to create to show a run nested under another one in the SaturnCI UI.

workflow_run.job_runs.create(
  job_name: "rubocop",
  task_adapter_name: "shell",
  parent_job_run_id: deploy_job_run.id,
  idempotent: true
)

Learn more