Continuous integration¶
Seal ships the build-and-test pipeline as a reusable GitHub Actions workflow, so a project’s own CI file is boilerplate that points at it.
What a green run means¶
The workflow 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.
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:
uses: ./.github/workflows/seal-ci.yml
permissions:
contents: read
with:
project_dir: my-project
gh_environment: dev
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 --
# `secrets.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
secrets:
provider_token: ${{ secrets.WHATEVER_YOUR_STORE_CALLS_ITS_TOKEN }}
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 the provider_setup and the secrets: block
entirely.
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 – |
|
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. |
|
yes |
The GitHub Environment this job binds to – protection rules, and scoping for what the workflow reads from |
|
no |
Branch or SHA to check out. Defaults to the triggering ref. |
|
no |
Runner label. Defaults to |
|
no |
Shell run before |
|
no |
Which of your credentials environments this run reads – which store each |
|
no |
Path, relative to |
|
no |
Name of the artifact the run uploads its results to. Defaults to |
|
no |
How long that artifact is kept. Defaults to 7: it exists for your own reporting job to read in the same run. |
Secrets¶
Secret |
Required |
Meaning |
|---|---|---|
|
no |
One credential for |
|
no |
Read access to whatever the checkout needs beyond your own repository — in practice a private git submodule vendoring Seal. Falls back to |
About python_dir¶
A reusable-workflow call checks out only the caller’s repository. The
../../python default therefore resolves only when your project lives
inside a checkout of Seal itself, two directories deep – which is
true of the bundled example and not of your project.
A project in its own repository that vendors Seal as a git submodule
overrides it to point at the submodule’s own python/:
with:
project_dir: my-project
python_dir: seal/python # submodule checked out at my-project/seal
If that submodule is private, pass a token that can read it as the
source_repo_token secret. GITHUB_TOKEN is scoped to the repository whose
workflow is running and can read no other, so the submodule’s clone
otherwise fails with a bare “Repository not found” before any step of the
job runs — and nothing later recovers, since both the Tilt extensions and
the CLI resolve out of that checkout.
secrets:
source_repo_token: ${{ secrets.A_TOKEN_THAT_CAN_READ_IT }}
Outputs¶
The workflow doesn’t publish a report. It reads the JUnit reports its runs left behind and hands them back, so how your results are presented 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.
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, laid out as |
results is shaped like this:
{
"dev": {
"passed": false,
"cases": 312,
"skipped": 4,
"failed": 1,
"problems": [],
"sources": [
{"name": "api", "passed": true, "cases": 208, "skipped": 4,
"failed": [], "failed_omitted": 0, "problems": []},
{"name": "outcomes", "passed": false, "cases": 12, "skipped": 0,
"failed": ["a-deleted-item-stays-deleted (todo-list/a-deleted-item-stays-deleted/junit.xml)"],
"failed_omitted": 0, "problems": []}
]
}
}
One source per directory a run’s results came back in: each service under
its own name, the outcome suite under outcomes. 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.
It’s read by the same junit.py the gate’s own per-service verdict comes
from, so it can’t say a test passed where the run said it failed. 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.
Doing something with them¶
A summary needs no permissions at all:
report:
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 per shape, or a comment on the pull request, 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 /
pull-requests: 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 }})"
The artifact is also where coverage lives: results covers JUnit reports,
and a service’s coverage.xml sits beside its junit.xml untouched.
The two are worth having together. A check carries the detail – which test
failed, and what it said – 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 workflow 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.
Three GitHub Actions quirks¶
None is obvious from GitHub’s own documentation, and each one fails quietly:
A job that calls a reusable workflow can’t also set environment: on
itself. GitHub rejects the whole file. That’s why gh_environment is
threaded through as a plain input – the reusable workflow’s own job binds
to it directly. The consequence: every vars.X expression in your caller
resolves at repository/organisation scope, not the environment’s.
secrets.X doesn’t follow that same rule. GitHub resolves an
environment-scoped secret for a reusable-workflow job only if that secret
was also named in the caller’s own secrets: block – even though the
value the caller passes is empty (the caller job isn’t bound to any
environment). Drop the passthrough and the value silently arrives empty,
even with the secret correctly set on the environment.
A reusable-workflow job only gets the permissions: its caller
declares. Where a repository’s default GITHUB_TOKEN permissions are
read-only, the permissions: block above must cover whatever the callee’s
jobs request, or the run fails at startup with zero jobs scheduled. This one
requests contents: read and nothing else – it reads your repository and
reports nothing back to it. The write scopes belong on your own reporting
job, if you write one.
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 <python_dir> 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.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 every JUnit report the runs left behind (
seal _tests-results, the samejunit.pythat decided each service’s verdict during the run) into theresultsoutput, and uploadstests-results/as an artifact – filed under the overlay that produced it, so one run’s results never overwrite another’s. Both happen 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.