Blueprints

A Blueprint defines a repository's build as a graph of Tasks. Workshop runs it as a Job. Each Task runs one command on its own disposable VM.

How a Job runs

  1. A push, pull request, schedule or user starts a Job at a pinned commit.
  2. Provision runs first. It checks out the commit, builds the Blueprint project, runs it to produce a plan, and freezes the source.
  3. Each Task starts on a new VM with a copy of the frozen source once its dependencies have succeeded.
  4. The Job ends when every Task has settled.

Consequences:

  • Tasks share no filesystem. Exchange files through artifacts.
  • Frozen source has no .git history. WORKSHOP_REVISION holds the commit.
  • A retry reuses the frozen source. New code needs a new Job.
  • A new head or base on a pull request cancels that pull request's running Job.
  • The Workshop owner configures triggers. A repository cannot.

Create a Blueprint

Workshop reads .workshop/blueprints.toml from the default branch. Provision builds the project at the Job's commit.

# .workshop/blueprints.toml
[[blueprints]]
key = "ci"
name = "CI"
project = ".workshop/Blueprints.csproj"
<!-- .workshop/Blueprints.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <Reference Include="CerebeWorkshop.Protocol"><HintPath>$(WorkshopSdkPath)</HintPath></Reference>
  </ItemGroup>
</Project>
// .workshop/Program.cs
using System.Text.Json;
using CerebeWorkshop.Protocol;

if (args is not ["ci", var output]) throw new ArgumentException("Expected <key> <plan-path>.");
var plan = new BlueprintPlan([
    new("checks", "Lint, type-check and unit test", [], new("bash", [".workshop/tasks/checks.sh"])),
    new("integration", "Integration tests", ["checks"], new("bash", [".workshop/tasks/integration.sh"]), WorkerSize: "heavy")
], WorkerSize: "medium");
plan.Ordered(); // Throws on an invalid plan.
await File.WriteAllTextAsync(output, JsonSerializer.Serialize(plan, Wire.Json));

Provision calls the program with <key> <plan-path>. The program must handle every declared key. Commands run from the repository root unless CommandSpec.Directory says otherwise.

Limits

Limit Value
Tasks per Job 100, excluding Provision. Target 30 or fewer
Task run time 60 minutes, including source restore
Retained output Latest 40,000 characters per Task
Artifact size 128 MiB stored, 512 MiB expanded
Plan size 128,000 characters of JSON
Task ID 63 characters: lowercase letters, digits and hyphens, starting with a letter. provision is reserved
Task name 100 characters
Blueprints per repository 100
blueprints.toml 64 KiB
Inputs per Blueprint 8

Workers

Size vCPU Memory
light 2 4 GiB
medium 2 8 GiB
heavy 4 16 GiB
  • BlueprintPlan.WorkerSize sets the default, which is light. TaskSpec.WorkerSize overrides it. Provision must fit light.
  • light and medium have the same CPU. Choose medium for memory and heavy for CPU.
  • Size from measurements. get_job returns each Task's peak memory, CPU time and OOM kills. Work inside containers under-reports memory.
  • Workers run Linux by default. WorkerOs selects windows for a plan or Task, and provisionOs in the TOML does so for Provision.
  • The Linux image includes Docker, Make, Git, jq, .NET 10, and pinned Go, Node, Helm, yq and uv under /opt/workshop-tools. /usr/local/share/workshop/container-image-toolchains.txt lists them. A Task installs anything else itself. Nothing is cached between Tasks.

Design Tasks

Every Task costs a VM boot, a source restore, tool setup and a worker slot. Bundle as many operations as form one coherent unit of work into one Task.

Split a Task only when:

  • Its parts run long enough that parallel execution shortens the Job.
  • Its parts need different worker sizes, operating systems or secrets.
  • A part must fail or retry independently.

Tasks can fail, and a Task fails and retries as a whole. Design for that:

  • Run every check in a bundled Task, then exit non-zero if any failed. One run then reports every failure.
  • Do not bundle long, unrelated work. A retry repeats all of it.
  • Make every Task safe to run again.

Keep the graph small:

  • Target 30 Tasks or fewer. A plan near the limit of 100 is a design problem.
  • Shard long suites by measured duration, not by file count.
  • Cap computed shard counts so the largest plan fits. Check full runs on the default branch, which usually plan the most Tasks.
  • Declare a dependency only when a Task needs another Task's output or success.

When Tasks fail

Event Task state Effect on the Job
Command exits non-zero Failed Dependents are skipped. Other Tasks finish. The Job ends Failed
Failure with AllowFailure: true Failed Dependents continue. The Job can still succeed
Worker lost or stopped Interrupted Dependents are skipped. Other Tasks finish. The Job ends Interrupted unless a required Task failed
Provision does not succeed Failed or Interrupted The Job ends. No other Task can run
  • RunAfterFailure: true starts a Task after its dependencies finish, even if they failed. Use it for report collectors.
  • FailFast: true on the plan ends the Job at the first required failure.
  • Nothing retries automatically. retry_tasks runs a finished Job's unsuccessful Tasks and everything downstream of them again.

Share artifacts

using var job = JobContext.FromEnvironment();
await job.StoreArtifact("build-output", "work/publish"); // File or folder.

// In a Task that depends on the producer:
await job.GetArtifact("build-output", "work/restored"); // The destination must not exist.
  • Declare a dependency on the producer. GetArtifact fails on a missing artifact and does not wait.
  • Names are unique within a Job, and a stored artifact cannot change. A retry removes what the retried Task stored.
  • Stored artifacts do not expire.
  • A Task reads artifacts from its own Job only.
  • Other languages use the SDK through a small command in the Blueprint project: dotnet run --project .workshop/Blueprints.csproj -- <action>.
  • list_artifacts and the Job page expose stored artifacts after the Job.

Read logs

  • Workers stream each Task's stdout and stderr live to the Job page.
  • Workshop keeps only the latest 40,000 characters per Task. It is not a durable log.
  • Print failures and a summary last, so they survive.
  • Store full logs as an artifact when they matter.
  • Flush output. A buffering program looks silent until it flushes.
  • get_task_output reads the retained text, up to 16,000 characters per call. Use offset to page.

Set priority

[[blueprints]]
key = "deploy"
name = "Deploy"
project = ".workshop/Blueprints.csproj"
priority = 10
  • priority is a whole number from 0 to 255. Lower runs first. The default is 100.
  • Waiting work runs in order of priority, then creation time. This covers every ready Task, including Tasks of running Jobs, across all of a client's repositories.
  • Lower-priority work waits for as long as higher-priority work is ready. Nothing ages.
  • Change priority only for a specific need. If every Blueprint is high priority, none is.
  • A new value applies after it merges to the default branch.

Use inputs and secrets

[[blueprints]]
key = "publish"
name = "Publish"
project = ".workshop/Blueprints.csproj"
[[blueprints.inputs]]
name = "services"
label = "Services"
choices = ["backend", "portal"]
defaults = ["backend", "portal"]
multiple = true
  • JobContext.InputsFromEnvironment() reads the selected values. They are frozen when the Job starts. Never use inputs for credentials.
  • SecretProfile on a Task requests a secret profile. The Workshop owner must approve it. Pull request Jobs and Provision never receive secrets.

Every Task receives:

Variable Value
WORKSHOP_JOB_ID, WORKSHOP_TASK_ID Job and Task IDs
WORKSHOP_REVISION The commit under test
WORKSHOP_PULL_REQUEST_NUMBER Pull request number, or empty
WORKSHOP_BASE_REVISION, WORKSHOP_HEAD_REVISION Pull request commits, or empty
WORKSHOP_LAST_SUCCEEDED_REVISION The branch's last successful commit, or empty
WORKSHOP_INPUTS_JSON Selected inputs

Diagnose a failed Job

Most failures belong to the repository. Workshop ran the command and reported its result. Fix the Blueprint, the Task or the code, and do not expect Workshop to change.

  1. get_job shows each Task's state and usage. Find the first Task that did not succeed.
  2. get_task_output shows why it stopped.
  3. Use this table to decide who acts.
Symptom Owner Action
Task Failed Repository Fix the code, test or script
Test passes on retry Repository Fix the flaky test
Provision Failed with a build error or exception Repository Fix the Blueprint project
A Blueprint must contain 1–100 Tasks Repository Bundle Tasks or cap shards
OOM kills, or peak memory near the total Repository Use a larger worker, or do less in the Task
Interrupted with Execution deadline reached Repository Split the Task or make it faster
Artifact missing, or its name conflicts Repository Add the dependency, or use a unique name
Tool not found Repository Install it in the Task
Task Interrupted with worker lost in the Job Workshop Retry. Report it if it recurs across Jobs
Worker terminated before claiming this Task Workshop Retry. Report it if it recurs across Jobs
Tasks stay Queued with no higher-priority work waiting Workshop Report it
No Job appears for a commit Workshop owner Check the trigger configuration

Report a Workshop problem with the Job URL, the Task ID and the relevant output.