Continuous integration¶
Seal ships the build-and-test pipeline as a composite GitHub Action, so a project’s own CI file is boilerplate that uses it.
What a green run means¶
The action runs seal ci, and seal ci exits zero only when
both of these hold:
The environment came up. Every resource Tilt manages reported ready – which means something, because
seal checkrefused to start at all until every Deployment container declared areadinessProbe. Without that, Kubernetes reports a container Ready the instant its process starts.Every promise the project has translated still holds. The whole outcome suite ran in one pass, and the exit code is its verdict.
Each service’s own tests run inside its running container along the way, once that service is ready, and fail pointing at that service.
One promise can be exempt from that second claim, and only by saying so in
the repository. An outcome carrying a
quarantine.md doesn’t
fail the run while somebody fixes its test’s determinism – it is still run,
still reported, and never counted as passed, so the report says which
promises were held to the gate and which weren’t. Because that file lives in
the outcome’s own directory, adding one needs a code owner exactly as
editing the test would.
Both claims are the default, and either can be switched off for a run –
see run_service_tests and run_outcomes below. A run with one
of them off is a faster check to have beside the gate rather than the gate
itself; with both off it gates on readiness alone.
It never touches a real cluster: the job creates a throwaway Kind cluster and discards it.
Your project’s CI file¶
The only project-specific piece. Copy it and point project_dir at your
project:
name: My project
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
tests:
runs-on: ubuntu-latest
# Your job binds the Environment, so the secret below resolves against
# it and the action receives the value. See "Why an action" below.
environment: dev
permissions:
contents: read
# Read by the reporting job below. An action's outputs belong to the
# step that ran it; a job's belong to whatever `needs:` it.
outputs:
results: ${{ steps.seal.outputs.results }}
recordings: ${{ steps.seal.outputs.recordings }}
results_artifact_url: ${{ steps.seal.outputs.results_artifact_url }}
project_url: ${{ steps.seal.outputs.project_url }}
steps:
- uses: actions/checkout@v6
- id: seal
uses: vdel/seal/actions/ci@<ref>
with:
project_dir: my-project
k8s_overlay: dev # the shape the gate deploys
publish_images: false
# Only if your services' `.env` files reference a store. Put the
# CLI your seal-credentials-config.json names on PATH, and log it
# in -- `provider_token` reaches this shell as
# $SEAL_PROVIDER_TOKEN.
provider_setup: |
curl -fsSL https://example.invalid/install.sh | bash
echo "$SEAL_PROVIDER_TOKEN" | its-cli login
provider_token: ${{ secrets.WHATEVER_YOUR_STORE_CALLS_ITS_TOKEN }}
# Optional, and a job of its own because the comment needs a write scope
# the gate has no business holding. Drop it and you still get the results,
# the reports and the rendered page in the artifact.
report:
needs: tests
if: (!cancelled()) && needs.tests.outputs.results != ''
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: vdel/seal/actions/report@<ref>
with:
results: ${{ needs.tests.outputs.results }}
# So a broken promise's line links straight to a video of it.
recordings: ${{ needs.tests.outputs.recordings }}
artifact_url: ${{ needs.tests.outputs.results_artifact_url }}
# So each promise's name links to what it promises.
project_url: ${{ needs.tests.outputs.project_url }}
Note what isn’t there: your application’s variable and secret names, and
which password manager you use. seal ci resolves each service’s
own .env through the providers your project declares, the same way
seal up does locally, so this file never enumerates them and never
goes stale against them. A project whose .env files hold only literals and
k8s:// markers drops both provider_setup and provider_token entirely.
Nor is there a pin for the seal CLI. uses: checks out this action’s own
repository, so the CLI is already beside it, at the revision <ref> names.
Inputs¶
Input |
Required |
Meaning |
|---|---|---|
|
yes |
Path, relative to the repository root, to the project’s root Tiltfile and services. |
|
yes |
Which of your Kubernetes overlays the gate deploys. The run against this one also executes each service’s own tests, so name the shape you bring up locally – a gate on a shape nobody can reproduce is one nobody can act on. |
|
no |
Further overlays to verify your promises against, as a JSON list – |
|
no |
Whether the run on |
|
no |
Whether these runs read your promises – |
|
yes |
Whether to push the images built, once every gate above has passed. |
|
when publishing |
Which overlay the publishing run deploys. Publishing means deploying and letting Tilt push what it built, so it still names a shape – the one the deployment will use. |
|
no |
Branch or SHA to check out. Defaults to the triggering ref. |
|
no |
Shell run before |
|
no |
Which of your credentials environments this run reads – which store each |
|
no |
Name of the artifact the run uploads its results to. Defaults to |
|
no |
Whether a promise the run doesn’t see kept keeps a video of its browser – |
|
no |
Whether a promise the run does see kept keeps one too – |
|
no |
Whether each broken promise’s recording also gets an artifact of its own, so a report can link straight to it – |
|
no |
How long that artifact is kept. Defaults to 7: it exists for your own reporting job to read in the same run. |
|
no |
One credential for |
|
no |
Read access to whatever the checkout needs beyond your own repository – a private git submodule your project vendors. |
Outputs¶
The action doesn’t publish anything. It reads what its runs left behind and hands that back, so which check, comment or dashboard your results become is your project’s decision rather than one you inherit – along with whichever third-party action implements it, the write scopes that action needs on your pull requests, and the kind of runner it happens to require.
What it does do is render: the artifact it uploads carries a readable page
of the same results, and actions/report
is a separate, opt-in action that puts that rendering on the pull request.
/rfcs/0015-reporting-what-a-run-found.md is where that line is drawn, and
why.
Output |
What it carries |
|---|---|
|
The overlays this run verified, as a JSON list. What to matrix a per-shape report over. Empty if the run died before it could say which shapes it was going to run – guard on that, because |
|
What every run found, as one line of JSON keyed by overlay. |
|
Name of the artifact holding the reports themselves, the reading, and the rendered page. Nothing is uploaded when a run failed before producing any results, so guard your download step. |
|
Where that artifact can be downloaded. Empty when nothing was uploaded. |
|
Where each broken promise’s recording was published, as JSON keyed by |
|
Where this project’s files are browsed at the revision under test. What lets a report link a promise’s name to the promise itself – its prompt, beside the test translated from it – instead of naming a slug. |
results is shaped like this:
{
"dev": {
"passed": false,
"cases": 208,
"skipped": 4,
"failed": 0,
"problems": [],
"sources": [
{"name": "api", "passed": true, "cases": 208, "skipped": 4,
"failed": [], "failed_omitted": 0, "problems": []}
],
"outcomes": {
"read": true,
"passed": false,
"counts": {"PASS": 11, "FAIL": 1},
"quarantined": 0,
"problems": [],
"promises": [
{"slug": "ui/todo-list/an-added-item-shows-up",
"headline": "an added item shows up", "state": "PASS",
"quarantined": false, "failed": []},
{"slug": "ui/todo-list/a-deleted-item-stays-deleted",
"headline": "a deleted item stays deleted", "state": "FAIL",
"quarantined": false,
"failed": ["deleted.stays deleted (junit.xml)"],
"evidence": [
{"kind": "video",
"path": "outcomes/ui/todo-list/a-deleted-item-stays-deleted/artifacts/…/video.webm"},
{"kind": "log",
"path": "outcomes/ui/todo-list/a-deleted-item-stays-deleted/log.txt"}
],
"evidence_omitted": 0}
]
}
}
}
Two halves, read by two different things – the same two the run’s own verdicts came from, so neither can disagree with what the gate concluded.
sources is one entry per service whose own tests came back, under its
own name. A problem is a reason the results can’t be believed rather than a
test that failed – no report written, a report that didn’t parse, a run that
produced nothing at all – because a shape whose run died looks exactly like
one whose tests all passed, and that is the claim a gate must never let a
report make. The failed names are capped per source (a job output is a
string with a size limit) and failed_omitted says how many were left out;
the failed count is never capped.
outcomes is every promise your tree declares, in one of four states:
|
Means |
|---|---|
|
The promise held. |
|
It didn’t. |
|
It has a test, and nothing here knows whether it ran – which is a failure, not an absence. |
|
Nothing has been translated from it. An ordinary state to sit in, and not a reason the run is red. |
A promise is named by its prompt’s headline as well as by its slug, because
the headline is what your project actually promises. quarantined marks one
the suite has been told not to gate on: it is still reported, and still not
reported as passed. failed carries which case gave way, where that
promise’s runner also wrote a JUnit report – detail beside the verdict, not
the verdict itself, which is the file the runner wrote.
evidence is what a promise left behind to look at, each path relative to
that run’s own results and so a path inside the artifact once the run’s name
is prefixed. kind is video, image, trace, page or log, classified
by what opening the file does – a runner writes whatever it writes, so this
is not a list of names any runner was told to produce.
A promise that did not hold carries everything it left; one that
held carries its recording and nothing else, since a log and a report
per kept promise would bury the one that broke. The key is absent where
there is nothing – which is every kept promise unless
record_outcome_video_on_success asked for a recording.
read is false when the run was not asked to read the promises at all
(run_outcomes: false). promises is then empty and passed is true –
because nothing here is a claim about them. That distinction matters: a run
that never looked and a run whose every test failed to write a verdict leave
exactly the same absence behind, and only the workflow knows which it was, so
it says rather than letting the second one read as the first.
passed at the top is both halves together: every service’s own tests, and
every promise the suite read.
What’s in the artifact¶
Path |
What it is |
|---|---|
|
Each service’s own report, as its test stage wrote it. |
|
One directory per promise: the |
|
The |
|
A self-contained page: every promise and its state, every service, every failure – and it plays the recording of each failed promise, since the video sits beside it in the same archive. Open it from the extracted artifact; it fetches nothing of its own. |
|
The same thing as Markdown, which is what |
Watching a failure¶
A promise that failed under the playwright runner seal supplies leaves a
video of the browser and a screenshot of the moment it gave way. They land
in that promise’s own results directory, which is what puts them in the
artifact.
Which runs keep a video is yours to say, with two inputs:
|
|
What happens |
|---|---|---|
|
|
The default. A broken promise keeps its recording; a kept one records and discards. |
|
|
Every promise keeps one — for checking the suite exercises what you think it does. |
|
|
Nothing is recorded at all, so no run pays the encoding. |
|
|
Refused. Nothing records a pass and discards a failure: keeping the passes means keeping the failures too. |
The screenshot follows a failure either way — a single frame rather than a stream, and the one artifact worth having when a video can’t be played.
index.html is where they are worth opening: download the artifact, extract
it, open the page, and each failed promise has its recording embedded under
it. Nothing is fetched from the network, and nothing is preloaded until you
press play.
A trace is deliberately not recorded. It is the better debugging tool and
it is megabytes per test, viewable only by loading it into Playwright’s own
viewer – so it is left to a project that wants it:
{"runner": [{"name": "ui", "runner_type": "playwright",
"runner_args": ["--trace", "retain-on-failure"]}]}
Traces land beside the videos and are reported the same way.
Watching one from GitHub, without a download¶
A file inside a CI artifact has no address of its own: an artifact is one archive, fetched whole, so a link to a video inside one is a download to go looking through.
So each broken promise’s recording is also published as an artifact of
its own, unarchived — which actions/upload-artifact allows for a single
file, and which makes the artifact’s name the file’s name. Each one is named
after the promise it belongs to, so the run’s artifact list reads as a list
of what broke:
dev--ui--todo-list--a-deleted-item-stays-deleted.webm
actions/ci hands back where each went in its recordings output, and
actions/report puts that in the comment as a link per failure — beside a
link to the promise itself, which is what project_url is for:
ui/todo-list/a-deleted-item-stays-deleted — FAIL: a deleted item stays deleted (▶see video)
The promise’s name reaches its own directory — the prompt it was written from, beside the test translated from it — at the commit under test, because a slug on its own says nothing to a reviewer who has never opened the tree.
The link reaches the recording rather than an archive containing it. Whether your browser plays it there or saves it is the browser’s call on the content type — either way there is no archive to open and nothing to go looking through.
How many |
Twenty per run, at most. A composite action can’t loop an upload step and a run doesn’t know how many promises broke until it has finished, so the number of addresses is fixed – high enough that reaching it is unusual, since an upload for a recording a run didn’t produce is skipped and costs it nothing. Past that, the recordings are still in the results artifact, and the report says how many were left without an address. |
Which files |
The videos, one per promise that left one – broken promises first, so the addresses go where somebody is looking. A kept promise has no recording unless |
What it costs |
One upload per broken promise, and the recording’s bytes travelling twice — it stays in the results artifact too, since that is where the page that plays it lives. |
On a green run |
Nothing, by default – no recording exists, so no upload happens and the comment has no link to make. With |
One comment, updated in place¶
actions/report renders the reading into the job’s summary and keeps a
single comment on the pull request current – replaced on every run rather
than added to, so twenty pushes leave one report instead of twenty stale
ones.
It is a separate action, used from a job of your own, because the comment
needs pull-requests: write and the gate has no business holding it:
report:
needs: tests
if: (!cancelled()) && needs.tests.outputs.results != ''
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: vdel/seal/actions/report@<ref>
with:
results: ${{ needs.tests.outputs.results }}
Input |
Required |
Meaning |
|---|---|---|
|
yes |
The |
|
no |
The heading both renderings carry. Defaults to |
|
no |
Whether to post and update the pull-request comment. |
|
no |
Which comment a run replaces. Defaults to |
|
no |
Whether to write the rendering to this job’s summary page. |
|
no |
The |
|
no |
The |
|
no |
The |
|
no |
What names the run – a commit, a branch, a URL. Rendered verbatim on the page. |
|
no |
What the comment authenticates as. Defaults to the job’s own |
It outputs comment_url, empty where it commented nothing.
The comment ends with the commit it is about:
Results for commit 1a2b3c4.
It is replaced in place on every run, so that line is the only thing dating
it – and it names the pull request’s head commit, not the merge commit
github.sha carries on a pull_request event, which appears nowhere in your
branch.
Two more behaviours worth knowing:
A pull request from a fork gets no comment. GitHub gives such a run a read-only token whatever your job’s
permissionssay, so the action warns and carries on rather than failing an outside contribution over a comment that was never possible. The summary and the artifact still have the report. Anywhere else, a refused comment fails the job and names the scope you’re missing.A run with no pull request comments nothing. A push to a branch, a schedule, a manual run: there is nowhere to comment, which isn’t a failure.
Doing something else with them¶
A summary of your own needs no permissions at all:
summary:
needs: tests
if: (!cancelled()) && needs.tests.outputs.results != ''
runs-on: ubuntu-latest
steps:
- env:
SEAL_RESULTS: ${{ needs.tests.outputs.results }}
run: |
python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY"
import json, os
for overlay, run in json.loads(os.environ["SEAL_RESULTS"]).items():
print("## {}: {}".format(overlay, "passed" if run["passed"] else "FAILED"))
PY
A check run per shape wants the XML rather than the reading of it – download
the artifact and point whichever action you like at it, granting that job
the checks: write it needs:
# in a job matrixing over fromJSON(needs.tests.outputs.overlays), so each
# shape gets a check of its own
- id: download
continue-on-error: true # a failed run may have uploaded nothing
uses: actions/download-artifact@v8
with:
name: ${{ needs.tests.outputs.results_artifact }}
path: results
- if: steps.download.outcome == 'success'
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: results/${{ matrix.overlay }}/**/junit.xml
check_name: "Test Results (${{ matrix.overlay }})"
Reading and reports are worth having together. A check carries the per-case
detail, and results carries what no report can, because a shape whose run
died before writing one has nothing to publish a check from: problems names
it, and the check would simply not appear.
.github/workflows/internal-example.yml in this repository is a worked
caller doing both.
Secrets¶
One, and it’s infrastructure-level rather than application config:
provider_token – one credential for your provider_setup script to
authenticate with, reaching it as $SEAL_PROVIDER_TOKEN. What it
opens, and which store, is your project’s business: the action never names
one. Scope it to only what this environment should read. Optional: only
needed if a service’s .env actually references a provider. See
Credentials.
Set it under Settings → Environments → <name> → Secrets, not as a
repository secret. Per-environment scoping is what makes a dev token
unable to read a prod store.
Why an action, and what follows from it¶
The pipeline is a composite action rather than a reusable workflow, and two things follow that shape your own CI file.
The seal CLI travels with the action. uses: on an action checks out
its whole repository and points $GITHUB_ACTION_PATH into it, so python/
is right there – at the revision the action was used at. Nothing names it,
nothing fetches it, and it cannot be a different version from the YAML
running it. A reusable workflow ships only its .yml, which is why one
would leave you to vendor the CLI as a submodule or pin it by git reference
and keep that pin in step by hand.
Your job binds the environment, and passes values. An action runs inside
the calling job, so that job sets environment: on itself and every
secrets.X/vars.X it reads resolves against that Environment. What the
action receives is the value:
jobs:
tests:
runs-on: ubuntu-latest
environment: dev
permissions:
contents: read
outputs:
overlays: ${{ steps.seal.outputs.overlays }}
results: ${{ steps.seal.outputs.results }}
results_artifact: ${{ steps.seal.outputs.results_artifact }}
steps:
- uses: actions/checkout@v6
- id: seal
uses: vdel/seal/actions/ci@<ref>
with:
project_dir: .
k8s_overlay: dev
publish_images: false
provider_token: ${{ secrets.WHATEVER_YOUR_STORE_CALLS_IT }}
Two details in that shape are easy to miss:
Re-expose the outputs. An action’s outputs belong to the step; a job’s belong to whatever
needs:it. If a later job fans out overoverlaysor reports fromresults, the job running the action has to declare them as its own.permissions:is yours to set. An action runs under whatever the calling job has. This one reads your repository and reports nothing back to it, socontents: readis enough – the write scopes belong on your own reporting job, if you write one.
One pin is left. Your root Tiltfile registers the Starlark half with
v1alpha1.extension_repo(ref=...). The Starlark and the Python are one
library, so that ref and the ref you use this action at have to name the
same revision.
What the job actually does¶
Checks out the repository.
Installs uv, Tilt, ctlptl, kubectl, kind and rsync.
Writes the script each run below starts with –
ctlptl delete cluster kindthenctlptl create cluster kind --registry=ctlptl-registry. The registry is a container of its own and outlives the cluster, so what an earlier run pushed is still there to pull.Sets up Docker Buildx with the GitHub Actions cache exporter, and sets
SEAL_BUILDX_CACHE=1soseal_service()builds throughcached_docker_build().Runs your
provider_setupscript, withprovider_tokenin its environment as$SEAL_PROVIDER_TOKEN– but only if you passed one, which a project whose.envfiles reference no provider does not need to.Runs the gate on
k8s_overlay:uvx --from <the action's own python/> seal ci -- --k8s_overlay <k8s_overlay> --build_type test. This is the run that also executes each service’s own tests, becausetestis the build kind whose images carry them. Both halves of what it verifies are yours to switch off:run_service_tests: falsebuilds--build_type runtimeinstead, leaving the promises and the readiness gate, andrun_outcomes: falseappends--run_outcomes false, leaving the service tests and the readiness gate. Off together they leave the readiness gate alone – the images build, the manifests apply, every resource comes up – which the run says in a warning, since no results come back to say it for them. Every one of the four is oneseal ciyou can type.Runs the gate again on each of
additional_k8s_overlays, at--build_type runtime– the images a real environment runs, which carry no test suite, so these runs verify the promises alone.If
publish_images, deployspublish_k8s_overlayat--build_type runtime --publish_images --run_outcomes falseand lets Tilt push what it built. Not a gate: the promises were read by the runs above, and reading them again would say the same thing more slowly.Reads what the runs left behind into the
resultsoutput – each service’s own reports throughseal _tests-results’junit.py, the same module that decided that service’s verdict during the run, and the promises through theoutcome_suite.pytheir verdicts came from. A run asked not to read the promises says so here, rather than reporting a tree that left no verdict.Renders that reading into
index.htmlandreport.md(seal _report), and uploadstests-results/as an artifact – filed under the overlay that produced it, so one run’s results never overwrite another’s, with the reading and the page beside them at the root. All of it happens whatever the gates said: a failed run’s results are the ones somebody needs.
Steps 6 to 8 are steps of one job rather than jobs of their own: each needs
the cluster, the images and the tooling the ones before it set up, and a
separate job would rebuild all of that to run one seal ci.
Each of them does start from a cluster with nothing on it, though, which is what step 3’s script is for. Reusing one leaves the previous overlay’s objects applied, and an object the next overlay doesn’t declare keeps running – so a promise can be kept by a container the shape under test removed. That is a false green, and a gate that can produce one is worth less than no gate at all.
Reporting per overlay is what makes the gate readable, and why the results
are keyed by one. Two result sets in one pull request are two different
claims – “the promises hold on the shape we work in” and “they hold on the
shape that ships” – and somebody deciding whether to merge has to see which
one went red. Both a service’s own test stage and the outcome runners write
JUnit XML into tests-results/, so the primary overlay’s results cover unit
tests and promises together; an additional overlay’s covers its promises
alone, since the images it runs carry no suite.
Making it a merge gate¶
Green CI is only half of a gate. The other half is that an outcome test
can’t be edited without a human – which needs CODEOWNERS over the outcome
tree and branch protection requiring code-owner review and this check
passing. See The merge gate.
Deployment is out of scope¶
Publishing an image to a real registry, applying an overlay that names a real environment, and sealing credentials for one are deliberately not here. This workflow builds and tests what’s in the repository; what happens to a build afterwards belongs to whoever deploys it.
The one place Seal’s own code meets that concern is
SEAL_IMAGE_TAG/SEAL_BUILD_ID,
which a publishing pipeline sets to get a stable, externally-discoverable
registry tag.