Skip to content

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/powershell image (settlement and map checkup use :latest; the close reminder pins :7.5-ubuntu-24.04), run apt-get install git with 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.all machine 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_TOKEN is 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 -Protected here. 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 — there main is 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 a 17 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 (source web) 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 source schedule. A test run is safe by construction: the worst it can do is push a settle/<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 -NoPush skips: 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:substrate compiles the C# substrate and passes lib/Nerthus.Core.Substrate.dll on 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-test runs the substrate's own xunit suite on both target frameworks.
  • pester and analyze run the test and lint suites on the pwsh image - 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 of repozytorium-fabularne.
  • pester clones 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:arm64still 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_TAG points these jobs at
  • Configuration — the committed config.json the ephemeral daemon boots from
  • API referenceInvoke-NerthusSettle and POST /workflows/settle