Add the CI pipeline (Vikunja task #5) #10

Merged
rob merged 6 commits from feat/ci-pipeline into main 2026-08-02 21:19:30 +00:00
Owner

Implements Vikunja task #5. Replaces the temporary runner probe with a real pipeline.

This PR is itself the test. Everything below was verified locally; the runner-side behaviour could not be, because pushing is the only way to trigger it. The checks list on this PR is the first real evidence.

The pipeline

One job on ubuntu-24.04: restorebuild --no-restore -c Releasetest --no-buildformat --verify-no-changes.

Constraints encoded, each learned the expensive way earlier

  • ubuntu-24.04, not ubuntu-latest. No runner here advertises ubuntu-latest; a workflow requesting it waits indefinitely with no error.
  • SDK from global.json via setup-dotnet's global-json-file, single-sourcing the pin.
  • No --treat-no-tests-as-error. All four test projects are empty; dotnet test exits 0 while printing "No test is available".
  • dotnet format --verify-no-changes included — see the correction below.
  • verify-runner.yml deleted.

Two corrections to the brief I gave the agent

1. My brief omitted two of the ticket's four acceptance criteria. I wrote it from memory. The agent read the ticket itself and implemented both anyway: the README build badge and NuGet caching. My error; the ticket wins.

2. A UTF-8 BOM does not currently fail dotnet format. I asserted it did. The agent tested: a BOM on a .cs file gives BUILD EXIT=0, FORMAT EXIT=0. The CHARSET rule only exists once an .editorconfig declares a charset — so the protection arrived with #8, not before. Reproduced independently.

Worse for my earlier claim: no .cs file in the repo ever had a BOM. They were on the .csproj files, which dotnet format does not inspect at all — I verified this by injecting a BOM into a .csproj and getting exit 0. So the .csproj BOM normalisation from #8 is enforced by nothing. That needs a different mechanism and I will raise it separately.

Decisions

One job. With a single runner, parallel jobs execute serially anyway while restoring and rebuilding per job — strictly slower for more YAML.

Static analysis is the build, since #8 makes analyser violations build errors. A separate analysis step would run the same analysers twice. dotnet format covers what the build cannot see: layout and encoding.

Release only. Debug is continuously exercised locally and #8 makes warnings errors there too; CI should cover what local development does not.

Caching included, after measuring. The agent intended to argue against it, then found a cold restore pulls 240 MB (82 MB mono browser-wasm runtime, 36 MB codecoverage, 34 MB devserver). It is also an explicit acceptance criterion. Taken from the Forgejo mirror (code.forgejo.org/actions/cache) rather than GitHub, because the action talks to the runner's own cache server and the GitHub v4.2+ line moved to a protocol self-hosted implementations lag on. Marked continue-on-error so a missing cache server yields a slow green run, not a red one.

Fully qualified action URLs. setup-dotnet is not mirrored on code.forgejo.org (15 repos, checked), so a bare actions/setup-dotnet@v5 would 404 against the default action registry.

No concurrency block. Forgejo cancels superseded runs by default for push and pull_request; this instance is 16.0.1, well past the version that added it. An explicit block would restate the default.

Verified locally, from zero build artefacts

dotnet restore PlaceMark.slnx                                     exit=0
dotnet build PlaceMark.slnx --no-restore --configuration Release  exit=0  0 Warning(s), 0 Error(s)
dotnet test PlaceMark.slnx --no-build --configuration Release     exit=0
dotnet format PlaceMark.slnx --verify-no-changes --no-restore     exit=0

Run in exactly that order from a clean copy, so --no-restore/--no-build are not hiding a stale-artefact assumption.

Failure modes proven, not assumed: an invalid .cs gives error CS1519, BUILD EXIT=1; a deliberately failing test gives Failed: 1, TEST EXIT=1.

.slnx builds, tests and formats on SDK 10.0.110 — the criterion carried over from task #8, as far as it can be proven off-runner. Blazor WASM builds with no workloads installed, so the runner will not need dotnet workload restore.

Branch protection — needs your action after this merges

The pipeline is advisory until it is a required check, so "build failures block the merge" is not yet true of anything in the repository.

Expected context: ci.yml / build (pull_request). Copy it verbatim from this PR's checks list before saving the rule — a required pattern matching nothing blocks every merge permanently with no obvious cause. Forgejo accepts globs, so ci.yml / * is the safer form.

What to watch on this PR's first run

  1. setup-dotnet resolves — the largest unknown. If it 404s, the runner cannot reach github.com and there is no mirror; fallback is dotnet-install.sh.
  2. node24 support — both actions declare it; if act rejects it, pin to @v4.
  3. Nine projects restored. Fewer, or a solution parse error, means global.json is not being honoured.
  4. Cache step — expect a miss then a save. If the key renders as bare nuget-, hashFiles is unsupported.
  5. The badge reads "Not found" until the first run on main; that is expected, not broken.
Implements Vikunja task #5. Replaces the temporary runner probe with a real pipeline. **This PR is itself the test.** Everything below was verified locally; the runner-side behaviour could not be, because pushing is the only way to trigger it. The checks list on this PR is the first real evidence. ## The pipeline One job on `ubuntu-24.04`: `restore` → `build --no-restore -c Release` → `test --no-build` → `format --verify-no-changes`. ## Constraints encoded, each learned the expensive way earlier - **`ubuntu-24.04`, not `ubuntu-latest`.** No runner here advertises `ubuntu-latest`; a workflow requesting it waits indefinitely with no error. - **SDK from `global.json`** via `setup-dotnet`'s `global-json-file`, single-sourcing the pin. - **No `--treat-no-tests-as-error`.** All four test projects are empty; `dotnet test` exits 0 while printing "No test is available". - **`dotnet format --verify-no-changes` included** — see the correction below. - `verify-runner.yml` deleted. ## Two corrections to the brief I gave the agent **1. My brief omitted two of the ticket's four acceptance criteria.** I wrote it from memory. The agent read the ticket itself and implemented both anyway: the **README build badge** and **NuGet caching**. My error; the ticket wins. **2. A UTF-8 BOM does *not* currently fail `dotnet format`.** I asserted it did. The agent tested: a BOM on a `.cs` file gives `BUILD EXIT=0`, `FORMAT EXIT=0`. The `CHARSET` rule only exists once an `.editorconfig` declares a charset — so the protection arrived with #8, not before. Reproduced independently. Worse for my earlier claim: **no `.cs` file in the repo ever had a BOM.** They were on the `.csproj` files, which `dotnet format` does not inspect at all — I verified this by injecting a BOM into a `.csproj` and getting exit 0. So the `.csproj` BOM normalisation from #8 is **enforced by nothing**. That needs a different mechanism and I will raise it separately. ## Decisions **One job.** With a single runner, parallel jobs execute serially anyway while restoring and rebuilding per job — strictly slower for more YAML. **Static analysis is the build**, since #8 makes analyser violations build errors. A separate analysis step would run the same analysers twice. `dotnet format` covers what the build cannot see: layout and encoding. **Release only.** Debug is continuously exercised locally and #8 makes warnings errors there too; CI should cover what local development does not. **Caching included, after measuring.** The agent intended to argue against it, then found a cold restore pulls **240 MB** (82 MB mono browser-wasm runtime, 36 MB codecoverage, 34 MB devserver). It is also an explicit acceptance criterion. Taken from the **Forgejo mirror** (`code.forgejo.org/actions/cache`) rather than GitHub, because the action talks to the runner's own cache server and the GitHub v4.2+ line moved to a protocol self-hosted implementations lag on. Marked `continue-on-error` so a missing cache server yields a slow green run, not a red one. **Fully qualified action URLs.** `setup-dotnet` is **not** mirrored on `code.forgejo.org` (15 repos, checked), so a bare `actions/setup-dotnet@v5` would 404 against the default action registry. **No `concurrency` block.** Forgejo cancels superseded runs by default for `push` and `pull_request`; this instance is 16.0.1, well past the version that added it. An explicit block would restate the default. ## Verified locally, from zero build artefacts ``` dotnet restore PlaceMark.slnx exit=0 dotnet build PlaceMark.slnx --no-restore --configuration Release exit=0 0 Warning(s), 0 Error(s) dotnet test PlaceMark.slnx --no-build --configuration Release exit=0 dotnet format PlaceMark.slnx --verify-no-changes --no-restore exit=0 ``` Run in exactly that order from a clean copy, so `--no-restore`/`--no-build` are not hiding a stale-artefact assumption. **Failure modes proven, not assumed:** an invalid `.cs` gives `error CS1519`, `BUILD EXIT=1`; a deliberately failing test gives `Failed: 1`, `TEST EXIT=1`. **`.slnx` builds, tests and formats on SDK 10.0.110** — the criterion carried over from task #8, as far as it can be proven off-runner. Blazor WASM builds with **no workloads installed**, so the runner will not need `dotnet workload restore`. ## Branch protection — needs your action after this merges The pipeline is **advisory until it is a required check**, so "build failures block the merge" is not yet true of anything in the repository. Expected context: `ci.yml / build (pull_request)`. **Copy it verbatim from this PR's checks list before saving the rule** — a required pattern matching nothing blocks every merge permanently with no obvious cause. Forgejo accepts globs, so `ci.yml / *` is the safer form. ## What to watch on this PR's first run 1. **`setup-dotnet` resolves** — the largest unknown. If it 404s, the runner cannot reach `github.com` and there is no mirror; fallback is `dotnet-install.sh`. 2. **node24 support** — both actions declare it; if act rejects it, pin to `@v4`. 3. **Nine projects restored.** Fewer, or a solution parse error, means `global.json` is not being honoured. 4. **Cache step** — expect a miss then a save. If the key renders as bare `nuget-`, `hashFiles` is unsupported. 5. The badge reads "Not found" until the first run on `main`; that is expected, not broken.
Add the CI pipeline
Some checks failed
CI / build (pull_request) Failing after 7s
9ff3076b69
Builds, tests and format-checks the solution on pull requests and pushes
to main, replacing the temporary runner probe.

Single job on one runner: splitting build, test and analyse into parallel
jobs would restore and rebuild per job and, with a single runner, execute
serially anyway. The four commands share one restore and one build.

Static analysis is the build itself, since analyser violations are build
errors, plus dotnet format for the layout and encoding rules the compiler
never sees.

runs-on is ubuntu-24.04: no runner on this instance advertises
ubuntu-latest, and a workflow requesting it waits indefinitely with no
error. Actions are referenced by fully qualified URL because
setup-dotnet is not mirrored on code.forgejo.org and a bare reference
would 404 against the default action registry. The SDK comes from
global.json rather than a restated version.

Caching was measured rather than assumed: a cold restore pulls 240 MB.
The cache action is taken from the Forgejo mirror and marked
continue-on-error, so an instance without a cache server produces a slow
green run rather than a red one.

Release only. Debug is continuously exercised locally, and PR #8 makes
warnings errors there too.

Vikunja task #5.
rob left a comment

Verdict: changes needed

The pipeline is red. Run #115 on 9ff3076 (https://git.robware.uk/rob/PlaceMark/actions/runs/29) finished failure in 7 seconds — created 20:37:23, started :24, stopped :31. This PR correctly nominated its own checks list as the acceptance evidence, and that evidence came back negative. Nothing else in the review changes that.

What I could and could not establish about the failure

I could not read the step logs. No Forgejo MCP tool exposes job logs; get_workflow_run returns run-level metadata only. The repo is private, so the run page, the badge and the raw log endpoints all return 404 anonymously, and the Forgejo instance and runner are both remote (no containers on this host). Someone authenticated needs to open that run URL. I am stating this plainly rather than dressing up an inference as a diagnosis.

What I did establish narrows it usefully:

  • This is the first workflow in this repository ever to use an action. The verify-runner.yml being deleted here contains zero uses: steps — only echo and uname via run:. Every green run in the history (#96–#114, all 1–2s) exercised run: steps on ubuntu-24.04 and nothing else. Action resolution, download and execution have never once been exercised on this runner.
  • It is not a bad ref. I verified all three pins resolve publicly: github.com/actions/setup-dotnet@v5 exists (v5.4.0; v6 is current), github.com/actions/checkout@v5 exists, code.forgejo.org/actions/cache@v5 exists (v6.1.0 is current).
  • setup-dotnet genuinely is not mirrored. The actions org on the Forgejo mirror has 15 repos; checkout and cache are there, setup-dotnet is not. Also data.forgejo.org 302-redirects to code.forgejo.org, so the ci.yml comment and the commit message are describing the same host and are both correct.
  • node24 is supported by the forgejo runner v11 act model (ActionRunsUsingNode24), so that is only a problem on an older runner.
  • 7s is too long for a YAML parse error and far too short for an SDK install, so the job died at one of the first three steps.

One inference worth recording, because it constrains the fix: a total egress blackout cannot be the explanation. dotnet restore needs nuget.org for the 240 MB this PR measured, so if the runner had no outbound access the pipeline would be unworkable in principle, not merely red. The live question is whether github.com specifically is reachable.

The fix

  1. Move checkout to the mirrorhttps://code.forgejo.org/actions/checkout@v5. It is mirrored, this is the first step and therefore the most likely failure point, and it costs nothing. Sourcing it from github.com when a mirror exists is gratuitous risk.
  2. Then read the log. If setup-dotnet is what failed, there is no mirror, so replace it with dotnet-install.sh in a run: step — run: steps are the only mechanism ever proven to work on this runner.
  3. Do not merge until the run is green.

Status check context — could not confirm

The PR predicts ci.yml / build (pull_request). I could not verify it: the commits API does not surface statuses and the checks list is not readable anonymously. Copy it verbatim from this PR's checks list. Use the glob form ci.yml / *, and do not add it as a required check until a green run exists — a required pattern matching nothing makes the repo permanently unmergeable, which is the exact failure mode that is hardest to diagnose later.

Local verification — all four commands pass, as claimed

Run in order from the PR head with no prior artefacts:

dotnet restore PlaceMark.slnx                                     exit=0   9 projects restored
dotnet build PlaceMark.slnx --no-restore -c Release               exit=0   0 Warning(s), 0 Error(s)
dotnet test PlaceMark.slnx --no-build -c Release                  exit=0   "No test is available" x4
dotnet format PlaceMark.slnx --verify-no-changes --no-restore     exit=0

Nine projects restored and the .slnx parsed — the criterion carried over from task #8. The --no-restore/--no-build chaining is correct and will not break on a fresh runner; each step's prerequisite is produced by the one before it. I also confirmed bare dotnet format --verify-no-changes resolves the .slnx from the repo root, so the README instruction is valid.

Judgements on the design

  • Release only — sound, agreed. Debug is exercised locally and #8 makes warnings errors there too.
  • One job — sound for a single runner.
  • Badge URL shape — verified correct. code.forgejo.org honours /{owner}/{repo}/badges/workflows/{file}/badge.svg?branch=main and 303s to a shields.io "Not found" fallback for an unknown workflow, which is exactly the pre-first-run behaviour the PR predicts. Worth knowing that because the repo is private, the badge will render as a broken image for anyone not signed in.
  • No concurrency block — the claim checks out. Forgejo did historically not cancel superseded pull request runs (issue #2581), but that was fixed by PR #9434 in December 2025, well before 16.0.1. The justification is correct as written.
  • Action versions — reasonable. All are floating major tags that exist, though each is behind: checkout v5 against v7 current on the mirror, setup-dotnet v5 against v6, cache v5 against v6. Conservative rather than wrong. Non-blocking.
  • British English — clean throughout the added prose and comments.

Non-blocking

  • continue-on-error on the cache is not yet proven to do what it claims. It protects against a missing cache server; whether it also survives a failure to fetch the cache action itself is untested. Confirm once the run is green — and check the key does not render as a bare nuget-, which would mean hashFiles is unsupported.
  • The cache key hashes **/*.csproj and **/*.props but not global.json or the .slnx. The nuget- restore-key makes this cosmetic.

Acceptance criteria (from Vikunja task #5)

Criterion Status
Triggers on PR and push to main Configured correctly; PR trigger demonstrably fired, push-to-main path unproven
Fails on compile error, test failure or analyser violation Proven locally; not proven in CI — the run never reached those steps
Build status badge in README Met; route shape verified
Caches NuGet packages Configured; behaviour unproven

Two of four are unproven for the same reason: the run is red. Get it green and three of them verify themselves.

Verdict: changes needed The pipeline is red. Run **#115** on `9ff3076` (`https://git.robware.uk/rob/PlaceMark/actions/runs/29`) finished `failure` in **7 seconds** — created 20:37:23, started :24, stopped :31. This PR correctly nominated its own checks list as the acceptance evidence, and that evidence came back negative. Nothing else in the review changes that. ## What I could and could not establish about the failure **I could not read the step logs.** No Forgejo MCP tool exposes job logs; `get_workflow_run` returns run-level metadata only. The repo is private, so the run page, the badge and the raw log endpoints all return 404 anonymously, and the Forgejo instance and runner are both remote (no containers on this host). Someone authenticated needs to open that run URL. I am stating this plainly rather than dressing up an inference as a diagnosis. What I *did* establish narrows it usefully: - **This is the first workflow in this repository ever to use an action.** The `verify-runner.yml` being deleted here contains zero `uses:` steps — only `echo` and `uname` via `run:`. Every green run in the history (#96–#114, all 1–2s) exercised `run:` steps on `ubuntu-24.04` and nothing else. Action resolution, download and execution have never once been exercised on this runner. - **It is not a bad ref.** I verified all three pins resolve publicly: `github.com/actions/setup-dotnet@v5` exists (v5.4.0; v6 is current), `github.com/actions/checkout@v5` exists, `code.forgejo.org/actions/cache@v5` exists (v6.1.0 is current). - **`setup-dotnet` genuinely is not mirrored.** The `actions` org on the Forgejo mirror has 15 repos; `checkout` and `cache` are there, `setup-dotnet` is not. Also `data.forgejo.org` 302-redirects to `code.forgejo.org`, so the ci.yml comment and the commit message are describing the same host and are both correct. - **node24 is supported** by the forgejo runner v11 act model (`ActionRunsUsingNode24`), so that is only a problem on an older runner. - 7s is too long for a YAML parse error and far too short for an SDK install, so the job died at one of the first three steps. **One inference worth recording, because it constrains the fix:** a total egress blackout cannot be the explanation. `dotnet restore` needs nuget.org for the 240 MB this PR measured, so if the runner had no outbound access the pipeline would be unworkable in principle, not merely red. The live question is whether `github.com` specifically is reachable. ## The fix 1. **Move `checkout` to the mirror** — `https://code.forgejo.org/actions/checkout@v5`. It is mirrored, this is the *first* step and therefore the most likely failure point, and it costs nothing. Sourcing it from github.com when a mirror exists is gratuitous risk. 2. **Then read the log.** If `setup-dotnet` is what failed, there is no mirror, so replace it with `dotnet-install.sh` in a `run:` step — `run:` steps are the only mechanism ever proven to work on this runner. 3. Do not merge until the run is green. ## Status check context — could not confirm The PR predicts `ci.yml / build (pull_request)`. I could not verify it: the commits API does not surface statuses and the checks list is not readable anonymously. **Copy it verbatim from this PR's checks list.** Use the glob form `ci.yml / *`, and do not add it as a required check until a green run exists — a required pattern matching nothing makes the repo permanently unmergeable, which is the exact failure mode that is hardest to diagnose later. ## Local verification — all four commands pass, as claimed Run in order from the PR head with no prior artefacts: ``` dotnet restore PlaceMark.slnx exit=0 9 projects restored dotnet build PlaceMark.slnx --no-restore -c Release exit=0 0 Warning(s), 0 Error(s) dotnet test PlaceMark.slnx --no-build -c Release exit=0 "No test is available" x4 dotnet format PlaceMark.slnx --verify-no-changes --no-restore exit=0 ``` **Nine projects restored and the `.slnx` parsed** — the criterion carried over from task #8. The `--no-restore`/`--no-build` chaining is correct and will not break on a fresh runner; each step's prerequisite is produced by the one before it. I also confirmed bare `dotnet format --verify-no-changes` resolves the `.slnx` from the repo root, so the README instruction is valid. ## Judgements on the design - **Release only** — sound, agreed. Debug is exercised locally and #8 makes warnings errors there too. - **One job** — sound for a single runner. - **Badge URL shape — verified correct.** `code.forgejo.org` honours `/{owner}/{repo}/badges/workflows/{file}/badge.svg?branch=main` and 303s to a shields.io "Not found" fallback for an unknown workflow, which is exactly the pre-first-run behaviour the PR predicts. Worth knowing that because the repo is **private**, the badge will render as a broken image for anyone not signed in. - **No `concurrency` block — the claim checks out.** Forgejo did historically not cancel superseded *pull request* runs (issue #2581), but that was fixed by PR #9434 in December 2025, well before 16.0.1. The justification is correct as written. - **Action versions** — reasonable. All are floating major tags that exist, though each is behind: checkout v5 against v7 current on the mirror, setup-dotnet v5 against v6, cache v5 against v6. Conservative rather than wrong. Non-blocking. - **British English** — clean throughout the added prose and comments. ## Non-blocking - **`continue-on-error` on the cache is not yet proven to do what it claims.** It protects against a missing cache *server*; whether it also survives a failure to *fetch the cache action itself* is untested. Confirm once the run is green — and check the key does not render as a bare `nuget-`, which would mean `hashFiles` is unsupported. - The cache key hashes `**/*.csproj` and `**/*.props` but not `global.json` or the `.slnx`. The `nuget-` restore-key makes this cosmetic. ## Acceptance criteria (from Vikunja task #5) | Criterion | Status | |---|---| | Triggers on PR and push to `main` | Configured correctly; PR trigger demonstrably fired, push-to-`main` path unproven | | Fails on compile error, test failure or analyser violation | Proven locally; **not** proven in CI — the run never reached those steps | | Build status badge in README | Met; route shape verified | | Caches NuGet packages | Configured; behaviour unproven | Two of four are unproven for the same reason: the run is red. Get it green and three of them verify themselves.
Fetch checkout from the Forgejo mirror
Some checks failed
CI / build (pull_request) Failing after 7s
c6cf130ee8
The first run of this pipeline failed in 7 seconds, and it is the first
workflow in the repository's history to use an action at all: the probe
it replaces had no uses: steps, so nothing about action fetching had ever
been exercised on this runner.

Step logs are not readable through the API and the repository is private,
so the run's pass or fail is the only instrument available. This changes
one variable. checkout is the first action and has a Forgejo mirror;
setup-dotnet deliberately stays on its GitHub URL, because that is what
makes the next result informative.

Note the mirror's v5 also declares node24, so this tests host
reachability only and leaves the node24 hypothesis untouched.
Pin all three actions to their node20 majors
Some checks failed
CI / build (pull_request) Failing after 48s
ca727f9552
The runner is forgejo runner v6.4.0, whose act model accepts only
[composite docker node12 node16 node20 go sh]. Every v5 action declares
node24, so the job died while parsing the first action manifest:

  git clone 'https://code.forgejo.org/actions/checkout' # ref=v5
  The runs.using key in action.yml must be one of:
    [composite docker node12 node16 node20 go sh], got node24

The clone itself succeeded, so neither egress nor the choice of mirror
was ever implicated.

Pins checkout, setup-dotnet and cache to v4, each verified to declare
node20 by reading runs.using at the tag rather than assuming it.
setup-dotnet@v4 still accepts global-json-file, which arrived in v3, so
the SDK pin stays single-sourced.

The cache action is pinned too, although it is continue-on-error: if act
rejects an unsupported runtime while planning the job rather than while
running the step, that flag cannot absorb it, and leaving one node24
manifest in place would have failed the run for the very reason this
change exists to test.

Records the runner's constraint in a comment, since anyone bumping a
major must check runs.using first.
Fetch all three actions from github.com
Some checks failed
CI / build (pull_request) Failing after 40s
cb8f38e4e3
Run #117 got past the node20 fix and then failed in the checkout step
with no message at all: act logged the docker cp of its cached action
directory and then a bare failure, after which the post step could not
find dist/index.js in the destination. The missing module is a symptom of
the main step not completing, not its cause.

The mirror was not at fault. Its checkout@v4 and cache@v4 tags point at
the same commits as GitHub's, with byte-identical dist entry points, so
switching host does not change what act copies. What it does change is
the runner's action cache directory name, which is the one variable that
plausibly differs after run #116 aborted against the same repository
seconds earlier.

This is an experiment rather than a diagnosis, and the reasoning that
picked it is weaker than the change deserves. It also consolidates on one
host, retiring an earlier preference for the mirror that rested on
speculation about cache protocols rather than evidence.
Reference actions by bare name rather than URL
Some checks failed
CI / build (pull_request) Failing after 51s
bba30ccad9
Runs 117 and 119 both failed in the checkout step with no message, from
the Forgejo mirror and from github.com respectively, after act logged a
docker cp of the cached action directory. The post step then found no
dist/index.js at the destination, so nothing was materialised into the
container.

Host, tag content and cache state are all eliminated: both mirrors point
at the same commit with byte-identical dist entry points, and the two
runs used different cache directories.

What every attempt has shared is the fully qualified URL form of uses:,
which act mangles into the directory name it then fails to read. That is
the last cheap hypothesis, so this tests it. Forgejo recommends the URL
form, but recommending it is not evidence this runner implements it, and
no workflow on this instance has ever successfully used an action in any
form.

setup-dotnet is not mirrored, so it cannot resolve bare. That is
deliberate: reaching a failure at that step rather than at checkout is
the outcome that confirms the hypothesis.
Build the pipeline from run: steps only
All checks were successful
CI / build (pull_request) Successful in 52s
f8d148cbd2
act_runner v6.4.0 on this instance cannot materialise a node action into
the job container. It clones the action, logs a docker cp, then fails
with no message; the post step afterwards finds no dist/index.js at the
destination.

Five runs eliminated every variable independently: node24 and node20,
github.com and both Forgejo mirrors, fully qualified URL and bare name,
with a fresh action cache directory each time. All three actions cloned
successfully in the last attempt and checkout still failed identically,
so resolution, host, tag content and cache state are all ruled out.

run: steps are the only mechanism this runner has ever executed
successfully — the probe workflow this replaced contained nothing else,
which is also why the runner was believed to be working.

Checkout fetches refs/pull/<n>/head explicitly rather than trusting
GITHUB_REF, which is the merge preview on GitHub and has varied on
Gitea-lineage servers. This server publishes only /head refs, so the
distinction is not theoretical. The SDK comes from dotnet-install.sh
--jsonfile global.json, keeping the pin single-sourced.

GITHUB_PATH is deliberately not used: it is the same class of runtime
protocol as the action materialisation that failed five times, and it
fails silently — every dotnet step would report command not found, which
reads as a broken install rather than a broken protocol. The SDK installs
to a fixed DOTNET_ROOT and is invoked explicitly, so the five steps stay
separate and per-step attribution survives.

NuGet caching is lost and cannot be recovered: the runner's cache server
is reachable only through the actions/cache protocol. That is 240 MB of
restore plus a full SDK download per run. Stated in the README with the
single revert that recovers it.
rob left a comment

Verdict: mergeable

Supersedes my review at 9ff3076. Run #123 on f8d148c is success in 52s, verified independently via the API rather than taken from the summary. The run:-only rewrite is the right call and the pipeline now does in CI what it was always able to do locally.

Status check context — the prediction was wrong, and it would have bricked the repo

The verbatim context is:

CI / build (pull_request)

Read from /api/v1/repos/rob/PlaceMark/commits/f8d148c.../statuses — three entries, all CI / build (pull_request), transitioning pending → pending → success.

Not ci.yml / build (pull_request). Forgejo builds the context from the workflow's name: field, not the filename. This PR's description predicts the filename form and recommends the glob ci.yml / * — that pattern matches nothing, and saving it as a required check is precisely the permanent-merge-block failure mode the description warns about. Use CI / * (which also covers CI / build (push)), or the exact string above.

One consequence worth knowing: the required check is coupled to name: CI inside ci.yml. Renaming the workflow silently breaks branch protection, with the same signature. Worth a line in the header comment.

Is the run:-only rewrite an over-reaction?

No — and there is no sixth experiment worth running in the workflow file. Five runs eliminated every variable reachable from YAML: node runtime version, action host, cache directory and uses: syntax. What remains — the container runtime, the docker socket, the runner version — cannot be addressed from a workflow at all.

The run durations independently corroborate the elimination log: #115 and #116 both died at 7s (manifest parse, before any download — consistent with node24 rejection), while #117, #119 and #120 ran 48s / 40s / 51s (actions cloned, then failed during execution). Two distinct failure signatures, exactly matching item 1 versus items 2/3/5. The log is telling the truth.

act_runner v6.4.0 is roughly five majors behind — the current line is code.forgejo.org/forgejo/runner/v11. A defect this fundamental in a version that old is entirely plausible and not worth further archaeology. The genuine next experiment is on the runner host: upgrade it, or run the job without a container (host/LXC mode), which sidesteps docker cp entirely. Please raise that as its own ticket — it recovers actions, the NuGet cache and the SDK cache in one move.

The checkout script

Two things to harden, neither blocking:

  1. git remote add origin is not idempotent. If the workspace is ever reused between runs — act_runner does reuse /workspace under some configurations — this fails with "remote origin already exists" and set -e aborts the job. #123 is a single run in a fresh container, so this is untested. It is the most likely future breakage in the whole file. Cheap fix: git remote add origin "$URL" 2>/dev/null || git remote set-url origin "$URL".
  2. base64 defeats the runner's log masking. The runner masks the literal secret value; the base64 of it is a different string and will not be masked. Nothing prints it today (-q, no set -x), but the first person who adds set -x to debug this step drops a trivially reversible credential into the log. Worth a warning comment beside the printf.

Things you got right that I want to record so they are not "tidied up" later:

  • git -c http.extraheader= rather than git config --local is correct here. actions/checkout writes the header into .git/config and removes it in a post step; this workflow has no post step, so --local would persist the credential in the workspace. Argv exposure inside a single-tenant ephemeral container is the lesser risk. Deliberate and right.
  • --depth 1 is safe against force-push. Each run does a fresh git init and fetches the ref by name, resolving to wherever it currently points, so there is no stale local state to conflict — subject only to point 1 above. Nothing in build, test or format reads history.
  • PULL_REQUEST_NUMBER under set -u is safe, because the env: key is always defined (empty on push) and only read in the pull_request branch.
  • The push path has never executed. SOURCE_REF="$GITHUB_REF" is logically sound and will fail loudly rather than silently if it is wrong, but merging this PR is its first run. Watch the post-merge run on main.

$GITHUB_PATH — right decision, wrong reason

The stated justification is inaccurate and should be reworded, because it teaches the next person something false. $GITHUB_PATH is a file the runner appends to and reads between steps; the failure here is materialising a node action into the container via docker cp. Different mechanisms. The comment's own logic refutes itself: if runner-managed state propagation were broken, the job-level env: DOTNET_ROOT this workflow does depend on would be broken too.

The decision is still correct, for a better reason: an absolute DOTNET_ROOT is explicit and greppable, and DOTNET_ROOT is the documented variable .NET itself uses to locate the runtime — it is doing real work, not standing in for PATH. Say that instead.

NuGet caching and the README

The README states the cost honestly. It quantifies 240 MB of packages per run plus a full SDK install per run, says runs take "minutes where it should take seconds", and explicitly frames it as unreachable rather than "a shortcut taken for simplicity". That is the right standard and it meets it.

But ticket criterion four is not met — unmeetable is not the same as met, and it should not be recorded only in a README paragraph. Please update Vikunja task #5 to record the deferral and link it to the runner-upgrade ticket. One option not considered, for completeness rather than as a request: a pull-through NuGet proxy configured via NuGet.config would restore package caching without fixing the runner. Almost certainly not worth it at hobby scale, but better as a rejected option than an unknown one.

Header comment accuracy

Accurate on the substance; three amendments:

  • The $GITHUB_PATH rationale, above.
  • Item 4 ("a stale cache entry — each host produced a fresh /data/.cache/act directory") is the one claim I cannot corroborate from outside. I am neither endorsing nor disputing it.
  • Worth one line: the original premise for fully qualified URLs — that a bare actions/setup-dotnet@v5 would 404 against the default registry — was disproven by #120, where the bare form resolved setup-dotnet from data.forgejo.org despite its web listing not showing that repository. I could not confirm this independently (the host 403s me), but a successful clone is stronger evidence than my probing. The revert instruction already uses the bare form, so the file is self-consistent; recording the disproof stops someone reinstating URL pinning on a false premise.

Local verification

Only .forgejo/workflows/ci.yml and README.md changed between 9ff3076 and f8d148c (diff --stat: 2 files). No source, project or .slnx file was touched, so my verification at the previous head carries over unchanged: restore of 9 projects, Release build with 0 warnings and 0 errors, tests exit 0, format --verify-no-changes exit 0.

Acceptance criteria (Vikunja task #5)

Criterion Status
Triggers on PR and push to main PR path proven green; push path exercised for the first time by this merge
Fails on compile error, test failure or analyser violation Now genuinely proven — the run reaches all four commands
Build status badge in README Met; route shape verified against a live Forgejo instance
Caches NuGet packages Not met, and not achievable on this runner. Needs recording on the ticket

Merge it, then set branch protection using CI / * — not ci.yml / *.

Verdict: mergeable Supersedes my review at `9ff3076`. Run **#123** on `f8d148c` is `success` in 52s, verified independently via the API rather than taken from the summary. The `run:`-only rewrite is the right call and the pipeline now does in CI what it was always able to do locally. ## Status check context — the prediction was wrong, and it would have bricked the repo **The verbatim context is:** ``` CI / build (pull_request) ``` Read from `/api/v1/repos/rob/PlaceMark/commits/f8d148c.../statuses` — three entries, all `CI / build (pull_request)`, transitioning pending → pending → success. **Not `ci.yml / build (pull_request)`.** Forgejo builds the context from the workflow's `name:` field, not the filename. This PR's description predicts the filename form and recommends the glob `ci.yml / *` — that pattern matches nothing, and saving it as a required check is precisely the permanent-merge-block failure mode the description warns about. Use `CI / *` (which also covers `CI / build (push)`), or the exact string above. One consequence worth knowing: the required check is coupled to `name: CI` inside `ci.yml`. Renaming the workflow silently breaks branch protection, with the same signature. Worth a line in the header comment. ## Is the `run:`-only rewrite an over-reaction? **No — and there is no sixth experiment worth running in the workflow file.** Five runs eliminated every variable reachable from YAML: node runtime version, action host, cache directory and `uses:` syntax. What remains — the container runtime, the docker socket, the runner version — cannot be addressed from a workflow at all. The run durations independently corroborate the elimination log: #115 and #116 both died at **7s** (manifest parse, before any download — consistent with node24 rejection), while #117, #119 and #120 ran **48s / 40s / 51s** (actions cloned, then failed during execution). Two distinct failure signatures, exactly matching item 1 versus items 2/3/5. The log is telling the truth. **act_runner v6.4.0 is roughly five majors behind** — the current line is `code.forgejo.org/forgejo/runner/v11`. A defect this fundamental in a version that old is entirely plausible and not worth further archaeology. The genuine next experiment is on the runner host: upgrade it, or run the job without a container (host/LXC mode), which sidesteps `docker cp` entirely. Please raise that as its own ticket — it recovers actions, the NuGet cache and the SDK cache in one move. ## The checkout script Two things to harden, neither blocking: 1. **`git remote add origin` is not idempotent.** If the workspace is ever reused between runs — act_runner does reuse `/workspace` under some configurations — this fails with "remote origin already exists" and `set -e` aborts the job. #123 is a single run in a fresh container, so this is untested. It is the most likely future breakage in the whole file. Cheap fix: `git remote add origin "$URL" 2>/dev/null || git remote set-url origin "$URL"`. 2. **`base64` defeats the runner's log masking.** The runner masks the literal secret value; the base64 of it is a different string and will not be masked. Nothing prints it today (`-q`, no `set -x`), but the first person who adds `set -x` to debug this step drops a trivially reversible credential into the log. Worth a warning comment beside the `printf`. Things you got right that I want to record so they are not "tidied up" later: - **`git -c http.extraheader=` rather than `git config --local` is correct here.** `actions/checkout` writes the header into `.git/config` and removes it in a post step; this workflow has no post step, so `--local` would persist the credential in the workspace. Argv exposure inside a single-tenant ephemeral container is the lesser risk. Deliberate and right. - **`--depth 1` is safe against force-push.** Each run does a fresh `git init` and fetches the ref by name, resolving to wherever it currently points, so there is no stale local state to conflict — subject only to point 1 above. Nothing in build, test or format reads history. - **`PULL_REQUEST_NUMBER` under `set -u` is safe**, because the `env:` key is always defined (empty on push) and only read in the `pull_request` branch. - **The `push` path has never executed.** `SOURCE_REF="$GITHUB_REF"` is logically sound and will fail loudly rather than silently if it is wrong, but merging this PR is its first run. Watch the post-merge run on `main`. ## `$GITHUB_PATH` — right decision, wrong reason The stated justification is inaccurate and should be reworded, because it teaches the next person something false. `$GITHUB_PATH` is a file the runner appends to and reads between steps; the failure here is materialising a **node action** into the container via `docker cp`. Different mechanisms. The comment's own logic refutes itself: if runner-managed state propagation were broken, the job-level `env: DOTNET_ROOT` this workflow *does* depend on would be broken too. The decision is still correct, for a better reason: an absolute `DOTNET_ROOT` is explicit and greppable, and `DOTNET_ROOT` is the documented variable .NET itself uses to locate the runtime — it is doing real work, not standing in for `PATH`. Say that instead. ## NuGet caching and the README **The README states the cost honestly.** It quantifies 240 MB of packages per run plus a full SDK install per run, says runs take "minutes where it should take seconds", and explicitly frames it as unreachable rather than "a shortcut taken for simplicity". That is the right standard and it meets it. But **ticket criterion four is not met** — unmeetable is not the same as met, and it should not be recorded only in a README paragraph. Please update Vikunja task #5 to record the deferral and link it to the runner-upgrade ticket. One option not considered, for completeness rather than as a request: a pull-through NuGet proxy configured via `NuGet.config` would restore package caching without fixing the runner. Almost certainly not worth it at hobby scale, but better as a rejected option than an unknown one. ## Header comment accuracy Accurate on the substance; three amendments: - The `$GITHUB_PATH` rationale, above. - Item 4 ("a stale cache entry — each host produced a fresh `/data/.cache/act` directory") is the one claim I cannot corroborate from outside. I am neither endorsing nor disputing it. - Worth one line: the *original* premise for fully qualified URLs — that a bare `actions/setup-dotnet@v5` would 404 against the default registry — was **disproven** by #120, where the bare form resolved `setup-dotnet` from `data.forgejo.org` despite its web listing not showing that repository. I could not confirm this independently (the host 403s me), but a successful clone is stronger evidence than my probing. The revert instruction already uses the bare form, so the file is self-consistent; recording the disproof stops someone reinstating URL pinning on a false premise. ## Local verification Only `.forgejo/workflows/ci.yml` and `README.md` changed between `9ff3076` and `f8d148c` (`diff --stat`: 2 files). No source, project or `.slnx` file was touched, so my verification at the previous head carries over unchanged: restore of 9 projects, Release build with 0 warnings and 0 errors, tests exit 0, `format --verify-no-changes` exit 0. ## Acceptance criteria (Vikunja task #5) | Criterion | Status | |---|---| | Triggers on PR and push to `main` | PR path proven green; push path exercised for the first time by this merge | | Fails on compile error, test failure or analyser violation | Now genuinely proven — the run reaches all four commands | | Build status badge in README | Met; route shape verified against a live Forgejo instance | | Caches NuGet packages | **Not met, and not achievable on this runner.** Needs recording on the ticket | Merge it, then set branch protection using `CI / *` — not `ci.yml / *`.
rob force-pushed feat/ci-pipeline from f8d148cbd2
All checks were successful
CI / build (pull_request) Successful in 52s
to e4f1e84149
All checks were successful
CI / build (pull_request) Successful in 32s
2026-08-02 21:16:04 +00:00
Compare
rob left a comment

Verdict: mergeable

Re-issued for e4f1e84. You were right not to bend the rule; the check was cheap and it did confirm the load-bearing item against the new head.

The rebase is clean

  • ci.yml is byte-identical, not merely diff-clean: the blob hash is 1d246d04c0e6f892de2ade9cf806cf48fb60eec9 at both f8d148c and e4f1e84. Everything I established about the workflow carries over untouched.
  • No conflict debris anywhere on the branch (grep for markers across all paths: none).
  • main..e4f1e84 is exactly three filesci.yml added, verify-runner.yml deleted, README.md +41. No stray content picked up from the rebase.
  • CI run #124 on e4f1e84: success, 33s.

README resolution — both sections survived, coherently

The heading outline is main's outline with two headings inserted and nothing displaced:

## Getting started → ### Database → ### Configuration → #### Where a value comes from
  → #### Three ways to leak a secret
  → ## Continuous integration → ### Why the workflow looks the way it does
  → ## Contributing → ## Licence

### Configuration from #9 is intact under ## Getting started; ## Continuous integration sits at the top level after the secrets material and before ## Contributing. Heading levels are consistent, ordering reads correctly, no duplication. The badge is still on line 3.

Status check context, re-confirmed on e4f1e84

I read the statuses API myself rather than taking it from your summary. Three entries, all:

CI / build (pull_request)

Unchanged from f8d148c, as expected — it derives from name: CI, which the rebase did not touch. Use CI / *.

The three deferred items — none of them block, and here is the test I applied

The question for a merge gate is narrow: can this cause CI to report success when it should report failure, or lose a secret without anyone noticing? None of the three can.

  1. git remote add origin non-idempotence — fails loudly on step one with an explicit "remote origin already exists" and a red run. It cannot produce a false green. Deferring is safe.
  2. base64 versus log masking — requires someone to deliberately add set -x first. Worth putting in the follow-up ticket rather than leaving purely in review history, though, because the repository is now public: a job token in a public log is a worse outcome than it was when I first raised it. Still not blocking — the token is job-scoped and ephemeral — but that changed while this PR was open and is worth a line on the ticket. If the repository is meant to go back to private, that also closes the exposure.
  3. $GITHUB_PATH reasoning — prose in a comment. Zero runtime effect.

Agreed on all three as follow-up.

One line for the follow-up ticket, while you are there

The README states "a failing run blocks the merge" as fact. It becomes true the moment you save the branch protection rule and not before — which is minutes away, so I am flagging it only so it does not sit un-actioned if the rule gets deferred. If you do defer it, soften the sentence.

Merge it, then set the required check to CI / *.

Verdict: mergeable Re-issued for `e4f1e84`. You were right not to bend the rule; the check was cheap and it did confirm the load-bearing item against the new head. ## The rebase is clean - **`ci.yml` is byte-identical**, not merely diff-clean: the blob hash is `1d246d04c0e6f892de2ade9cf806cf48fb60eec9` at both `f8d148c` and `e4f1e84`. Everything I established about the workflow carries over untouched. - **No conflict debris** anywhere on the branch (`grep` for markers across all paths: none). - **`main..e4f1e84` is exactly three files** — `ci.yml` added, `verify-runner.yml` deleted, `README.md` +41. No stray content picked up from the rebase. - **CI run #124 on `e4f1e84`: success**, 33s. ## README resolution — both sections survived, coherently The heading outline is `main`'s outline with two headings inserted and nothing displaced: ``` ## Getting started → ### Database → ### Configuration → #### Where a value comes from → #### Three ways to leak a secret → ## Continuous integration → ### Why the workflow looks the way it does → ## Contributing → ## Licence ``` `### Configuration` from #9 is intact under `## Getting started`; `## Continuous integration` sits at the top level after the secrets material and before `## Contributing`. Heading levels are consistent, ordering reads correctly, no duplication. The badge is still on line 3. ## Status check context, re-confirmed on `e4f1e84` I read the statuses API myself rather than taking it from your summary. Three entries, all: ``` CI / build (pull_request) ``` Unchanged from `f8d148c`, as expected — it derives from `name: CI`, which the rebase did not touch. Use `CI / *`. ## The three deferred items — none of them block, and here is the test I applied The question for a merge gate is narrow: **can this cause CI to report success when it should report failure, or lose a secret without anyone noticing?** None of the three can. 1. **`git remote add origin` non-idempotence** — fails *loudly* on step one with an explicit "remote origin already exists" and a red run. It cannot produce a false green. Deferring is safe. 2. **`base64` versus log masking** — requires someone to deliberately add `set -x` first. Worth putting in the follow-up ticket rather than leaving purely in review history, though, because the repository is **now public**: a job token in a public log is a worse outcome than it was when I first raised it. Still not blocking — the token is job-scoped and ephemeral — but that changed while this PR was open and is worth a line on the ticket. If the repository is meant to go back to private, that also closes the exposure. 3. **`$GITHUB_PATH` reasoning** — prose in a comment. Zero runtime effect. Agreed on all three as follow-up. ## One line for the follow-up ticket, while you are there The README states "a failing run blocks the merge" as fact. It becomes true the moment you save the branch protection rule and not before — which is minutes away, so I am flagging it only so it does not sit un-actioned if the rule gets deferred. If you do defer it, soften the sentence. Merge it, then set the required check to `CI / *`.
rob merged commit 319a814469 into main 2026-08-02 21:19:30 +00:00
rob deleted branch feat/ci-pipeline 2026-08-02 21:19:30 +00:00
rob referenced this pull request from a commit 2026-08-02 21:19:31 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!10
No description provided.