Set up the GitLab pipelines¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
This guide wires the scheduled jobs onto the lore repository
(repozytorium-fabularne): the monthly settlement, the daily map checkup, and the
close reminder. All are ephemeral-daemon drivers the module ships as includable job
templates; settlement and map checkup publish through a merge request the Rada reviews,
while the reminder only sends Discord. What a settlement is — pending derivation, the
report shape, the driver workflow — lives in the
settlement model; the map sweep contract lives
in the locations model; the close reminder and the
/zamknij-sesję close it complements live in the
sessions model.
A fourth scheduled job may sit on the same repository and is set up elsewhere: the annotation run, which is Python rather than PowerShell, needs a runner of its own, and follows the variable and merge-request conventions on this page — The language pipeline owns it.
Prerequisites¶
- An adopted lore repo. Everything the daemon needs —
nerthus.entities.md, the.nerthus/config, the data tables — is committed state, so a fresh CI clone boots with no extra setup (adoption). Rada edits to the committed tables govern the very next scheduled run with no CI change. - A Docker-executor runner that can pull the Ubuntu-based
mcr.microsoft.com/powershellimage (settlement and map checkup use:latest; the close reminder pins:7.5-ubuntu-24.04), runapt-get install gitwith network egress to the Ubuntu apt mirrors, and reach the module's clone URL. There is no shell-executor or air-gapped variant. GitLab's shared runners satisfy this; to run the jobs on your own hardware instead, stand one up with Set up a self-hosted runner. - No daemon-auth secret. The ephemeral daemon mints its own
admin.allmachine token at boot (permissions).
1. Provision the credentials and schedules¶
Two things only a human can do first; then one script provisions the rest.
Register the bot and mint its first PAT. GitLab.com Free blocks project access
tokens (a Premium/Ultimate feature), so the push credential is a dedicated bot user
account — a real user added to the lore repo as Developer or higher — with a
personal access token (scopes api + write_repository). The API that mints a
PAT itself needs a PAT, so this first token is hand-made. GitLab forces an expiry (365
days by default; a self-managed administrator can change the limit); note the date and
re-mint before it lapses, because an expired token fails every write-back with exit 2
(§6). Both scheduled jobs share this one token to push write-back branches and open
merge requests, so no protected-branch rule may cover settle/* or map-checkup/*.
It is the identity the fleet primary already uses (Sync),
and the merge requests it authors are the bot's, not any Gracz's — the Rada review
happens on the merge.
The module clone URL is public. The runner clones Nerthus.Core from
NERTHUS_MODULE_URL, which the script defaults to the module's plain public HTTPS URL
https://gitlab.com/margonem/nerthus/it/nerthus.core.git. Nerthus.Core is a public
project, so the clone needs no credential — you do not pass -ModuleUrl at all, and
override it only to point CI at a fork or mirror. The same publicness makes
include: project: resolve for whoever triggers the pipeline, with no per-user role on
the module.
Run the provisioner. It is a script, not a cmdlet: run the file against the lore
repo with a GitLab api-scope token (the operator's, or the bot's once minted). Do not
dot-source it — dot-sourcing runs it, and with the arguments below defaulted from .env
that first line would provision for real before the -WhatIf preview on the next one:
/srv/nerthus/module/vm/Set-NerthusGitLabPipelines.ps1 -Project margonem/nerthus/narratorzy/repozytorium-fabularne `
-Token <gitlab-api-token> -SettlePushToken <bot-pat> -TriggerToken -WhatIf # preview
/srv/nerthus/module/vm/Set-NerthusGitLabPipelines.ps1 -Project margonem/nerthus/narratorzy/repozytorium-fabularne `
-Token <gitlab-api-token> -SettlePushToken <bot-pat> -TriggerToken
-Project, -Token, and -SettlePushToken default from a gitignored .env
(GITLAB_PROJECT_PATH, GITLAB_ACCESS_TOKEN) loaded by vm/Import-NerthusEnv.ps1, so on a
disposable test bed you can drop those arguments — see
Set up credentials. Add
-PassThruTriggerToken to have the minted trigger token returned on the output stream (the step
log stays on the host channel) so a wrapper can capture it instead of a human copying the printed
line; vm/Initialize-NerthusCiTestBed.ps1 uses that to feed the Worker's secret.
One idempotent pass sets the CI/CD variables (NERTHUS_MODULE_URL,
NERTHUS_SETTLE_PUSH_TOKEN), mints the pipeline trigger token for the Cloudflare Worker
(-TriggerToken — it prints the token to paste as the Worker secret), and creates the
settlement schedule 17 3 1 * * (Etc/UTC) carrying its NERTHUS_SETTLE=true guard.
The map-checkup and close-reminder schedules are opt-in switches only: on a fleet the
primary's in-daemon ticks own both (below), so the provisioner creates neither by default.
-RunnerTag sets NERTHUS_RUNNER_TAG, the tag every job template asks for. Pass it only once a
runner carries that tag, and skip it only where a group variable already supplies one: undefined
everywhere, the templates ask for the literal tag $NERTHUS_RUNNER_TAG and every scheduled job
fails with stuck_pending_no_matching_runners (Set up a runner). A
re-run reconciles rather than duplicates, so re-run it after a token rotation. It sets two flags
correctly that trip a hand setup:
- Masked.
NERTHUS_SETTLE_PUSH_TOKENis set masked, so a leaked job log never exposes it. Why it matters: a hand-set variable is unmasked by default. - Protected off. The settle/map path leaves every variable unprotected — do
not pass
-Protectedhere. Why it matters: a protected variable is invisible to a pipeline on an unprotected write-back ref, and the driver checks the token only after settling, so the job exits 2 with the settlement applied but never pushed (§6). The report/close path is the mirror — theremainis protected, so it passes-Protected; that path's setup is Set up session reports.
- UTC cron. All three schedules fire in
Etc/UTC. Why it matters: the daemon computes the default settlement month in UTC, so a17 0 1 * *schedule in Warsaw summer time (UTC+2) would fire at 22:17 UTC on the last day of the old month and settle the wrong month.
By hand, if you cannot run the script
Every action the script performs is one dashboard step. Set the two variables under
Settings → CI/CD → Variables (NERTHUS_MODULE_URL = the public clone URL, unmasked;
NERTHUS_SETTLE_PUSH_TOKEN masked); add the trigger token under Settings → CI/CD →
Pipeline trigger tokens; and create the settlement schedule under CI/CD → Schedules on
the default branch, carrying NERTHUS_SETTLE=true and set to the UTC cron above.
Why only settlement is a CI schedule
On a fleet, map checkup and close reminder belong to the primary's in-daemon ticks, which is
what schedules.map_checkup.owner and schedules.close_reminder.owner say in committed
config (Configuration). On GitLab's shared runners
the daily CI form would cost several times the 400-minute monthly budget; as primary ticks
they cost nothing, and the fleet budget doctrine owns that arithmetic. A
self-hosted runner takes that cost off the quota and makes owner: ci a real choice.
Settlement stays a CI schedule because it runs once a month and is cheap. The templates below
still exist for a fleet-less setup, where you opt their schedules in.
Creating a schedule is only half of a handover. A job whose workload the fleet owns still
starts, logs skipped: owner=…, and exits 0.
Include the job templates. The script cannot edit the committed .gitlab-ci.yml —
that is the one manual GitLab step left (and it never touches Cloudflare, so the Worker
steps in Set up session reports stay manual too). Add the
includes to the lore repo's .gitlab-ci.yml; each schedule the script created fires
the template whose guard variable it carries:
# repozytorium-fabularne/.gitlab-ci.yml
include:
- project: 'margonem/nerthus/it/nerthus.core' # the module's real project path
file: 'ci/settle.gitlab-ci.yml'
- project: 'margonem/nerthus/it/nerthus.core'
file: 'ci/map-checkup.gitlab-ci.yml'
- project: 'margonem/nerthus/it/nerthus.core'
file: 'ci/reminder.gitlab-ci.yml'
2. The settlement run¶
The settle template drives the monthly settlement, fired by the
Nerthus settlement (monthly) schedule and its NERTHUS_SETTLE=true guard. It pins
resource_group: nerthus-settle, so two settle jobs of the same repo never run
concurrently. Two committed states break the job with the same "dry-run gate failed"
exit: server.read_only: true in config.json boots the ephemeral daemon ReadOnly
(configuration), and a repo whose
.nerthus/schema.json pointer lags the module's expected index-format version 403s
every write as SchemaTooOld — until an operator re-runs Initialize-NerthusRepo
locally and commits the result. In both cases the dry-run report logged above the
failure line carries the real error id.
3. The map checkup¶
On a fleet this sweep runs as the primary's in-daemon tick; the CI template here is the opt-in
form for a fleet-less setup. The sibling template ci/map-checkup.gitlab-ci.yml drives
ci/Invoke-NerthusMapCheckupCi.ps1 — the CDN version sweep over the Mapa catalogue,
fired by the Nerthus map checkup (daily) schedule and its NERTHUS_MAP_CHECKUP=true
guard. It reuses NERTHUS_SETTLE_PUSH_TOKEN, the one write-back token both drivers
share.
The job runs one full sweep, applied directly — each version bump is an
independent single-tag write and the merge request is the review, so a
settlement-style dry-run gate would only double the CDN traffic. No newer renders
(gone-map flags are report-only job-log output): exit 0. Otherwise it opens a
map-checkup/<timestamp> merge request with the report embedded; an exhausted
failure budget fails the job after the write-back so the CDN trouble gets triaged.
It pins the same resource_group: nerthus-settle, so a sweep and a settlement never
mutate the index concurrently. Daily is the honest frequency ceiling: a full sweep
is thousands of HEAD requests against the Margonem CDN — raise request_delay_ms
before raising frequency (configuration).
4. The close reminder¶
On a fleet this reminder runs as the primary's in-daemon tick with a 24-hour cooldown; the CI
template here is the opt-in form for a fleet-less setup, and schedules.close_reminder.owner
decides which of the two is live. Set it to ci before relying on this schedule — left at
fleet (or off), the job runs and stops without sending. The reminder finds the sessions that
were committed but never closed and nudges each to its Narrator's Discord channel and the shared
Rada channel, with a Polish preview of what closing will do. It only sends — it never pushes and opens no merge request — so it
carries no push token. The channel names are committed lore the CI clone already
holds: a Narrator's own channel is the @discord purposed narratorzy on their
## Osoby block in nerthus.contributors.md, falling back to their unpurposed
default slot, then to the shared narratorzy role's @discord; the Rada
channel is the rada role's @discord
(the contributor store).
Delivery itself, though, is no longer a committed webhook URL — it is a bot that resolves
each channel by name and posts to it, and the bot needs a token. So this job carries
the masked DISCORD_BOT_TOKEN CI variable plus DISCORD_GUILD_ID; the
token is a secret and lives only in that masked variable (or host-local
.nerthus/local/discord.json), never in the committed config.json. It is a deliberate
scope tradeoff — one bot token to hold rather than a per-channel webhook, but the bot can
post to any channel it can see where a webhook reached exactly one. The upside is a
read-privacy win: the bot is send-only (View Channel + Send Messages, no Read Message
History, no Message Content intent), so players' channels are written but never read.
The Nerthus close reminder (daily) schedule fires it through
NERTHUS_CLOSE_REMINDER=true. The job pins resource_group: nerthus-close-reminder. It
boots the daemon with Discord enabled (unlike settle/map-checkup, which stay silent)
and fires the full open set every run — the schedule is the cadence, so pick a frequency
you want to be pinged at.
To give narrators a grace period before the first nudge, set
integrations.discord.close_reminder.first_reminder_delay_hours; it counts from the session's
own date and defaults to 0, which nudges on the next pass.
The close itself is trigger-fired, not scheduled
Unlike this reminder, /zamknij-sesję closes a session through ci/close.gitlab-ci.yml,
fired by the Cloudflare Worker, and pushes the result straight to the default branch — a
close is an enactment, not a review. Its push token and branch rule live with the Worker in
Set up session reports.
4a. The forum capture¶
This one runs in Repozytorium Dzieł, not in the lore repository, and it is the only scheduled job that writes into that repository. It captures the forum topics the Rada has consent for, daily, and it exists because one of them deletes: the recruitment topic's opening post dates from 2023-08-07 and the oldest surviving post after it is 2026-07-27, so a week nobody captured is a week nobody can recover.
# repozytorium-dziel/.gitlab-ci.yml
include:
- project: 'margonem/nerthus/it/nerthus.core'
file: 'ci/forum-capture.gitlab-ci.yml'
| Variable | Value | Why |
|---|---|---|
NERTHUS_FORUM_CAPTURE |
true, on the schedule |
the job's rule keys on it |
NERTHUS_IMAGE |
the pinned fleet image | it ships lib/Nerthus.Core.Substrate.dll, and a module cloned from git does not — without it the transcription cannot run |
NERTHUS_MODULE_URL |
the nerthus.core clone URL |
same as every other driver |
NERTHUS_CAPTURE_PUSH_TOKEN |
a PAT with write_repository |
falls back to NERTHUS_SETTLE_PUSH_TOKEN; the job pushes the default branch directly |
NERTHUS_RUNNER_TAG |
the self-hosted runner's tag | must be set — see below |
Which topics are captured is not configured here. The job captures every source directory whose INDEX.md carries - @przechwytywanie: dopisywane, so adding or removing one is a merge request against the works repository, where a consent decision belongs — Fetch a source has the declaration.
Daily, at a time of your choosing. Set the schedule to run once a day; the cost is two page fetches, five seconds apart, and a commit of about 60 kB. The first pass over a topic with history behind it is the separate manual nerthus-forum-backfill job, which sweeps every page — around 3.3 MB for the changelog topic, run once.
A pending schedule is not a running schedule. If NERTHUS_RUNNER_TAG is unset, GitLab keeps the literal $NERTHUS_RUNNER_TAG as the tag, no runner matches, and the job sits pending until it times out rather than failing. Nothing alerts on that. What makes it visible is the committed record: przechwyty/index.jsonl gains a row per attempt, so a day with no row is a day nobody looked.
The push is read back. After pushing, the job asks the remote for the ref and for the paths this run created, and fails if they are not there. A job that fetched perfectly and committed nothing exits 0 otherwise, and the loss shows up months later when somebody goes looking for the data. Exit 2 means the commit or the push did not land; exit 1 means a capture or its transcription failed and the rest was published.
5. Test a run¶
To exercise these templates end to end against a real GitLab project without touching the lore repo, seed a disposable test bed first — Set up a CI test repository.
- In GitLab. The job's rules require pipeline source
schedule, so the New pipeline button (sourceweb) silently produces no job — this is the guard working, not a broken setup. Use the ▶ play button on the schedule itself (CI/CD → Schedules), which runs with sourceschedule. A test run is safe by construction: the worst it can do is push asettle/<timestamp>branch and open a merge request — close it unmerged and delete the branch. - Locally. The driver runs outside GitLab:
pwsh ci/Invoke-NerthusSettleCi.ps1 -Repo <clone> -NoPush. Know what-NoPushskips: only the branch/push/merge-request stage. The settle itself still writes into the clone's working tree — only a dry run (Invoke-NerthusSettle -WhatIf) is write-free. Use a disposable clone.
6. Triage a red run¶
The driver's exit-code contract:
| Exit | Meaning |
|---|---|
0 |
Settled, or nothing was pending |
1 |
The dry-run gate or the settle reported a failure |
2 |
Infrastructure: daemon boot, missing token or GitLab env, push or merge-request failure |
On exit 1, read the report in the job log and fix its findings — a partial month
or an unresolved name; semantics in the
settlement model. A gate failure writes
nothing; a settle failure leaves its partial result on the runner's throwaway clone
only. On exit 2, no durable state is at risk: pending work is derived from committed
state, so the next run redoes whatever the failed one applied.
Where the evidence lives. The runner's .nerthus/log/audit.jsonl evaporates
with the job; the durable record of a CI settlement is the merge request diff plus
the echo lines. The driver prints the full report into the job log and embeds it
in the merge-request description, so the reviewer holds both. One diagnostic gap: a
daemon boot failure points at .nerthus/log/daemon-ci.out.err, which the
template does not export as an artifact — it dies with the runner. Reproduce boot
failures locally with the driver.
7. Settle a month whose execution date has passed¶
The job template passes the driver no month override; every scheduled run settles
the previous calendar month and nothing else. Distribution repairs and transfers
are month-agnostic, so a missed run's leftovers are caught by the next one — but a
past month's PU is reachable only through the month override, which CI does not
expose. The rule, stated plainly: once a month's execution date has passed, only
an operator-driven commit can settle it. Re-triggering the schedule does not help —
it settles the same default month again, never the missed one. This lands on the
schedule most often through a partial PU month, which the dry-run gate refuses by
design; the 1st passes while a human untangles the split.
Worked example — settling July in September. The August 1st run exited 2 (the push token had expired in July) and nobody re-minted it before September, so the September run settled only August. The Koordynator settles July by hand:
git clone https://gitlab.com/margonem/nerthus/narratorzy/repozytorium-fabularne.git; cd repozytorium-fabularne
Invoke-NerthusSettle -Month 2026-07 -WhatIf # preview: PU pending, rest settled
Invoke-NerthusSettle -Month 2026-07 # applies to this working tree
The preview shows why only PU is left: the September run already caught July's
drifted copy. The manual run awards the stranded PU — Eraster's pu_suma moves
127,90 → 129,10 — and appends the July echo line to nerthus.ledger.md. Then the
operator-driven commit publishes it:
git checkout -b settle/2026-07-reczne
git add -A && git commit -m "Rozliczenie 2026-07 (ręczne)"
git push origin settle/2026-07-reczne # then open the merge request
The merge is the Rada's review, exactly as for a scheduled run.
Invoke-NerthusSettle parameters and envelope: API reference.
Automatic image-pin proposals¶
A green module build proposes its new image digest to a consumer fleet repo as a merge request,
never an auto-commit — a moved tag is a deploy nobody reviewed. The publish:image-pin job
rewrites only the image: digest line and its provenance comment in the consumer's
versions.lock, pushes a deploy/<shortsha> branch, and opens a merge request the fleet's
validate and plan jobs gate exactly as they gate a human one. Wire it with four CI variables on
the building project, minted by Set-NerthusGitLabPipelines:
| Variable | Holds |
|---|---|
NERTHUS_INFRA_PROJECT_PATH |
the consumer fleet repo to propose into (path, never a numeric id) |
NERTHUS_INFRA_PUSH_TOKEN |
machine-user PAT, api + write_repository, Maintainer on the consumer |
NERTHUS_INFRA_TARGET_BRANCH |
the consumer's default branch (default main) |
NERTHUS_IMAGE_PIN_AUTOMERGE |
1 to auto-merge on a green pipeline — dev only; prod leaves it unset |
All four are unprotected, because the job runs on the unprotected main. A rebuild yielding the
same digest opens no merge request, and a fork or unconfigured consumer is a silent no-op rather
than a failed pipeline. The fleet page owns the upgrade wave a merged
pin drives.
The module's own verification pipeline¶
The module repo carries its own .gitlab-ci.yml (repo root, distinct from the ci/
templates the lore repo includes). Eight jobs, and the order between them is a gate: the two
suites run first, and nothing reaches the registry or the fleet until both are green.
build:substratecompiles the C# substrate and passeslib/Nerthus.Core.Substrate.dllon as an artifact. The daemon no longer compiles sources at boot, so every job that boots it or dot-sources a service needs that file; it is gitignored and exists only as a build output.dotnet-testruns the substrate's own xunit suite on both target frameworks.pesterandanalyzerun the test and lint suites on thepwshimage - run the tests owns what each suite covers and how they are mocked. The adoption rehearsal is env-gated and skips itself here: it needs a disposable clone ofrepozytorium-fabularne.pesterclones four sibling repositories, because four of its suites assert across a boundary that no single repository can see:
| Clone | Variable | The suite that needs it |
|---|---|---|
nerthus.core.infra.dev |
NERTHUS_INFRA_DEV_PATH |
the smoke suite, for the commit the fleet pins |
nerthus.core.infra.common |
NERTHUS_INFRA_COMMON_PATH |
the same suite, for the edge routing list at that pin |
nerthus.docs |
NERTHUS_DOCS_PATH |
DocExamples, which replays this site's API reference |
nerthus.core.dashboard |
NERTHUS_DASHBOARD_PATH |
RouteReach, which asks which GET routes the browser client calls |
The docs and dashboard clones try a branch of the same name first and fall back to the default branch, because a route row and the call site that reaches it land in two repositories and a shared branch name is where the matching half is if there is one.
Every one of the four fails the job when its checkout is missing, deliberately. A suite that
quietly asserts nothing is worse than a red one: RouteReach generates one case per GET route
from the client, so with no dashboard checkout it generated zero cases and the static
assertions that remained ran over empty sets — the untriaged ratchet comparing 0 against 0.
It reported seven passing tests and was satisfied by the absence of the clone. If you fork this
repository without those siblings reachable, expect the job to stop and name the one it could
not read.
That is now checked twice, on purpose, because the two checks catch different things. The job
asserts the client file is on disk before it runs anything, which catches a clone that
succeeded over an empty repository — the case a directory test passes and the guard then
reports every route unreached, blaming the client for the clone. And the guard itself throws
rather than returning an empty census, which catches the case CI cannot see at all: a local run
in a worktree, where the sibling fallback misses because a worktree sits a level above the other
checkouts. Either half alone leaves the other case silent.
- package:amd64 and package:arm64 each build the fleet image from docker/Dockerfile
for one architecture and push it under <slug>-<arch>; package then merges the two into a
manifest list at the branch slug and writes the list digest into image-digest.txt as its
artifact. That digest is what a consumer fleet's versions.lock pins and what the health worker
compares each host against - one number, whatever architecture the host is. All three run on
main and multi-host-infra only. The two arch jobs carry their own nerthus-amd64 and
nerthus-arm64 tags, and since 2026-08-21 they point at two different machines: the
nerthus-amd64 tag is on nerthus-runner-ubuntu, which now lives on the estate's rented amd64
host, and nerthus-arm64 is on nerthus-arm64-runner, on the operator's laptop — the estate's
only arm64 machine. Both halves are native for the first time; before that both pointed at the
same arm64 runner and the amd64 half was emulated. The tag move needed no change in this file,
which is what the tags encoding intent rather than hardware bought.
The consequence is worth stating rather than smoothing over: package needs both halves, so
the image build — and every deploy that follows a versions.lock pin — is now gated on a
laptop being awake, and that does not fail loudly. The job sits pending and after the
timeout fails as stuck_pending_no_matching_runners, which reads as a hung queue rather than as
breakage. Both need pester and dotnet-test: package moves the
mutable :main tag, and the gate is what keeps a red commit's image out from under the name
every host resolves. They also carry one script_failure retry, for the registry push -
the arch assertion they end with fails the same way twice, so a retry cannot turn a wrong image
green.
- publish:image-pin consumes that artifact and opens the pin proposal above. It needs
package, pester and dotnet-test - the pin claims this commit's image is the one a host
should run, so every job that can refuse the commit is named in the chain.
What the emulation was costing¶
Until 2026-08-21 every self-hosted runner in the group reported architecture: arm64, and the
pipelines' default job image mcr.microsoft.com/powershell:7.5-ubuntu-24.04 is a single amd64
manifest with no index. So every job on that image ran under qemu, and nobody had a figure for it,
because there was nothing to compare against. Moving the runners to a native amd64 host made the
comparison available for about a day.
First, the claim needs narrowing, and the measurement is what narrowed it. Not every job was emulated:
| image | used by | manifest |
|---|---|---|
mcr.microsoft.com/powershell:7.5-ubuntu-24.04 |
every pwsh job | single amd64 manifest, no index → was emulated |
node:26-slim |
the Dashboard | OCI index, amd64 + arm64 → was native |
python:3.12-slim |
the language annotation | index → was native |
So the tax was paid by the estate's pwsh suite, which is its heaviest, and not by everything.
One identical warm pipeline, Nerthus.Core on main — laptop 2780318966, box 2780435022:
| laptop, emulated | box, native | ||
|---|---|---|---|
| whole pipeline, wall clock | 1 321 s | ~640 s | 2.1× |
pester |
885.6 s | 440.0 s | 2.0× |
package:amd64 |
325.3 s | 70.6 s | 4.6× |
package:arm64 — still the laptop, native both times |
359.1 s | 168.4 s | 2.1× |
| peak RSS of the heaviest job | 2 324.5 MiB | 1 719.3 MiB | qemu cost ~600 MiB |
package:arm64 is the control, and it says most of this is contention
That row did not change machine or architecture — it ran natively on the same laptop both times —
and it got 2.1× faster, because the laptop stopped sharing its cores with two runner
supervisors it no longer hosts. pester improved by the same factor. So the honest reading is
that much of pester's gain is contention, and the one job where emulation is unmistakably the
story is package:amd64 at 4.6× — a job that was emulated and is now not, on a box that is
otherwise slower per core than the laptop.
The Dashboard makes the same point from the other side: six Node jobs, native on both machines, 19 % slower on the box (108.3 s → 128.4 s of summed job time). Eight cores that are all yours beat fourteen shared ones for a heavy serial job and lose for a spread of short ones.
Both figures above are warm runs; a cold one pays for image pulls the box had never done.
One more thing that fell out of the RSS figures. .NET's in-container GC hard limit is 0.75 × the
cgroup, so the ordinary runner's 3072m puts the managed ceiling at 2 304 MiB — and the emulated
pester job peaked at 2 324.5 MiB, above it. It did not fail; a heap against a hard limit
collects instead, which reads as a slow job. Natively the same job peaks at 1,719.3 MiB. Part of
that 2.0× may be GC pressure relieved rather than instructions retired, and this run cannot
separate that from contention either.
The check that a job is native is not that it is fast. It is that
/proc/sys/fs/binfmt_misc/qemu-x86_64 does not exist on the host — an emulated run had a binfmt
handler doing the work, and a native run has nothing to have it with — and that uname -m inside the
job image reads x86_64.
Every job fetches its sources with GET_SOURCES_ATTEMPTS: "3". The self-hosted runner's link to
gitlab.com drops occasionally, and a job that catches it dies in get_sources with a TLS error
before a line of its own script runs - which reads as a test failure rather than as a network blip.
The retry sits on the fetch, not on the job, so a flake costs seconds instead of a re-run suite.
When package cannot produce an image — the month's minutes are gone, or the only runner free is
arm64 — the digest is built by hand instead:
Build the fleet image by hand.
This documentation site has its own pipeline in the
nerthus.docs repo: pages
builds the site and publishes it as GitLab Pages (cognition.nerthus.pl) on the default
branch; docs-build-check gives merge requests that touch the site a build check
without publishing.
See also¶
- Settlement model — what the job settles: derivation, the report, the driver's workflow
- Sync design — the long-lived fleet daemon this CI model complements
- Set up session reports — the event-driven companion:
the Discord report and
/zamknij-sesjęclose front-end fired by the Cloudflare Worker - Bootstrap a fleet host — the fleet primary whose in-daemon ticks run the map checkup and close reminder
- Move a runner — relocating a runner between machines, and the tag surgery that must land in the same window
- Set up a runner — the self-hosted runner
NERTHUS_RUNNER_TAGpoints these jobs at - Configuration — the committed
config.jsonthe ephemeral daemon boots from - API reference —
Invoke-NerthusSettleandPOST /workflows/settle