You already have a workshop. You run workshop run test on your laptop and get the same environment every time; that’s the whole point of writing a workshop.yaml instead of a page of setup steps nobody follows. Then CI shows up and you build that environment a second time: a Dockerfile, or a stack of apt-get, actions/setup-python, and a cache action glued together until the green check appears. Now you have to maintain two implementations of the same conceptual environment, and they start drifting apart immediately, because of course they do.
The canonical/launch-workshop action skips the second definition. It installs Workshop on a GitHub-hosted runner and launches the same workshop you use locally, so the thing CI tests is the thing you actually develop in. I built a small repo to prove it end to end (akcano/workshop-ci-demo), and the rest of this post is what I found, including the bits that were less shiny than the pitch.
The action in one breath
Three things happen when the action runs: it downloads and installs the Workshop snap on the runner; it launches a workshop from your repo; and then your own steps run commands inside it. That’s the entire model. Here’s the smallest workflow that does something useful:
# .github/workflows/test.yaml
name: test
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: canonical/launch-workshop@v1
with:
workshop: dev-noble
- run: workshop run dev-noble unit-tests
The last line is the part you should care about. workshop run dev-noble unit-tests is the exact command you would run on your laptop, not a CI-flavoured reapproximation of it. The action puts a real workshop binary on the runner’s PATH, so Workshop is just there, the same way it’s there at home.
The shape of the demo
The repo has two workshop definitions, a trivial Python project, and two workflows. The definitions live under .workshop/, one per Ubuntu release:
# .workshop/dev-noble.yaml
name: dev-noble
base: ubuntu@24.04
sdks:
- name: uv
actions:
unit-tests: |
uv sync
uv run pytest -q
dev-jammy.yaml is identical apart from two lines: name: dev-jammy and base: ubuntu@22.04. The project itself is as small as I could make it while still pulling a real third-party dependency (so there’s something to install and, later, something to cache):
# pyproject.toml
[project]
name = "workshop-ci-demo"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["pandas>=2.2"]
[dependency-groups]
dev = ["pytest>=8"]
[tool.uv]
package = false
# demo.py
import pandas as pd
def total(values):
return int(pd.Series(values).sum())
# test_demo.py
from demo import total
def test_total():
assert total([1, 2, 3]) == 6
def test_total_negatives():
assert total([10, -4, -6]) == 0
The unit-tests action is defined once, in the workshop, and called the same way everywhere. Notice what is not in the workflow: no setup-python, no version pin in YAML, no pip install step, no cache: pip. The workshop owns all of that. The workflow only says launch this workshop, run that action.
One project, every release
This is the win for Workshop that actually matters, so it goes first. The interesting input to the action is workshop, which names the definition to launch. Make it a matrix axis; you test the same project across every release you have a Workshop definition for, in parallel:
# .github/workflows/matrix.yaml
name: matrix
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
workshop: [dev-jammy, dev-noble]
steps:
- uses: actions/checkout@v6
- uses: canonical/launch-workshop@v1
with:
workshop: ${{ matrix.workshop }}
cache: |
uv:cache
- run: workshop run "$WS" unit-tests
env:
WS: ${{ matrix.workshop }}
The two definitions are near-duplicates: dev-jammy.yaml and dev-noble.yaml differ only in their name and base lines, and the unit-tests action is byte-for-byte identical in both. With two releases I keep both files by hand; with five or ten you wouldn’t, you’d template them or generate the whole set from a list of bases, and the matrix axis falls out of that same list. The per-release cost is a base string, not a bespoke environment.
So it’s two jobs, one thin definition each, and here’s the part I didn’t stage: those near-identical definitions resolve different dependency trees, and the matrix surfaces it without me lifting a finger.
dev-jammy(Ubuntu 22.04, Python 3.10) resolvespandas 2.3.3.dev-noble(Ubuntu 24.04, Python 3.12) resolvespandas 3.0.3.
Both went green, and the split isn’t noise: the base sets the system Python that uv resolves against (22.04 ships 3.10, 24.04 ships 3.12), so each release gets the dependency tree its own Python allows, nothing pinned by hand. The matrix tests what your users on each release really get, not the one version your laptop happens to have, so an incompatibility shows up in the job for the release it hits. With Dockerfiles that’s an image per release; here adding dev-resolute tomorrow (Workshop bases are LTS-only so far) is a base string and a matrix entry, not a third Dockerfile.
The caching tradeoffs
The action has a cache input that persists a workshop’s mount plugs between runs. The uv SDK exposes its download cache as a plug called uv:cache, so cache: uv:cache above keeps uv’s wheel cache around from one run to the next.
It works, and I can prove the mechanism. On the first (cold) run, the install downloads everything:
Downloading pandas (10.4MiB)
Downloading numpy (15.9MiB)
Downloading pygments (1.2MiB)
Installed 9 packages in 188ms
2 passed in 0.84s
The warm run is the same matrix.yaml fired again with no code change (gh workflow run matrix.yaml, or the Actions tab, via its workflow_dispatch trigger). This time the action restores the cache before launching, and the same install step pulls nothing over the network:
Cache hit for restore-key: workshop-dbcb20a6...-28241217405-1
Cache restored successfully
Resolved 19 packages in 21ms
Installed 9 packages in 303ms
2 passed in 1.28s
No “Downloading” lines on the warm run. The wheels came out of the restored cache. So far so good.
Here’s the part the pitch decks skip: on a GitHub-hosted runner, this saved me almost nothing in wall-clock terms. The per-step timings from the matrix run, cold versus warm:
| step | cold | warm |
|---|---|---|
launch-workshop |
70s | 72s |
unit-tests |
3s | 2s |
The cacheable thing (about 28 MiB of wheels) downloads in roughly a second on GitHub’s network, and restoring the cache costs about the same second, so it’s close to a wash. Meanwhile the 70-odd seconds of installing the Workshop snap, pulling the base image, and setting up the uv SDK is not something uv:cache can touch; that cost is paid every run. One of my warm jobs actually came in slower than its cold twin, purely from runner-to-runner variance, which tells you how small the cache delta is next to the noise.
So I’m not going to tell you the cache made CI fast in this particular scenario. Rather, what it did was make the dependency fetch disappear, and that matters in the situations where fetching is the expensive or fragile part: heavy dependencies, packages that compile from source (where the cache stores the built artifact, not just the download), a slow or distant package index, or a registry that rate-limits you on a busy afternoon. For a handful of pure-Python wheels on a fast runner, leave the line in (it’s free and it’s one line), but don’t expect a stopwatch difference. Caching is insurance, not a turbo button.
Pin it before you trust it
@v1 is a floating tag: it tracks the latest v1.* release and can move under you, and @main is worse. For anything you actually depend on, pin to a commit SHA so a re-tagged release can’t push unreviewed code into your pipeline, e.g.:
- uses: canonical/launch-workshop@89e38f9a6342ffa6252eb3e2babd16b14639ed76 # v0.2.0
I kept @v1 in the demo because it’s a demo and I want it to keep working as the action evolves. In your repo, pin it.
What this gives us
The headline reason to run the launch-workshop action for an arbitrary project is not speed and not caching; rather, it’s that you don’t need to maintain two descriptions of your environment, one for Workshop and one for GitHub. With this action, the workshop you debug in at 2pm is the workshop CI runs at 2am, across as many Ubuntu releases as you care to define a workshop for, and the command is identical in both places, so reproducibility. Still, the benefits of caching become more visible on projects heavier than this two-function Pandas demo; in more realistic scenarios, they may earn their keep almost immediately.
If you already write workshop.yaml files, adding Workshop-flavored CI is about six lines of YAML. Try it on something real and watch the matrix find the thing your single-Python CI was hiding.
Next steps and see also
- The demo repo, runnable as-is: https://github.com/akcano/workshop-ci-demo
- The action: https://github.com/canonical/launch-workshop
- The how-to this post stress-tested: How to run workshops in GitHub Actions
- Prefer your own hardware? Run Actions on a workshop as a self-hosted runner instead: How to run GitHub Actions locally
- Agents in a box; same idea, different payload: Workshop and OpenCode