ci(devops): downgrade actions/upload-artifact v4 → v3 (re-regression — needs ADR to prevent future re-upgrade) #557
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
actions/upload-artifact@v4was originally downgraded tov3on 2026-03-19 in commits9f3f022eand4142c7cd. The downgrade was deliberate, documented in the commit body, and motivated by a real CI failure:On 2026-05-05 the action was upgraded back to v4 in commit
410b91e2("chore: upgrade upload-artifact action from v3 to v4"). The commit body does not reference the prior downgrade or its rationale. This is a regression — Gitea Actions (our self-hosted runner) still does not implement the v4 artifact protocol.There are three call sites in the current
mainworkflow tree:All three need to come back to
@v3.Decision
actions/upload-artifact@v3.docs/adr/0XX-upload-artifact-v3-pin.md) that records:9f3f022e,4142c7cd) and the re-regression (410b91e2).gitea/workflows/ci.yml(or as a pre-test step) that fails the build ifupload-artifact@v[4-9]appears in any workflow file. This is the same defence-in-depth pattern used by ADR-012 for the birpc grep guard. Without this guard, the next contributor or AI agent will silently re-introduce the regression.Acceptance
actions/upload-artifact@v3uses:line (# pinned per ADR-XXX — do not upgrade)coverage-flake-probe.ymlsucceeds end-to-end, with the artifact upload step actually producing an artifact (positive verification, not just "job didn't fail")Non-goals
References
9f3f022e— original v3 pin (2026-03-19)4142c7cd— rationale committed (2026-03-19, closes #14)410b91e2— regression upgrade back to v4 (2026-05-05)Markus Keller — Senior Application Architect
Observations
9f3f022e+4142c7cd) was reversed in410b91e2without any reference to the prior rationale. The downgrade was never captured as an ADR, so a contributor reading only the workflow file sees a "stale-looking v3" with no breadcrumb back to the reason. That is an institutional-memory failure, not a contributor failure.docs/infrastructure/ci-gitea.mdlines 203, 230, 332 carry the comment# ← upgraded from v3. That doc actively misled the May 5th upgrade — it reads as if v4 is the canonical target. Pinning the workflows back to v3 while leaving this doc unchanged guarantees re-regression #3. The doc fix is in scope.012indocs/adr/(012-browser-test-mocking-strategy.mdand012-nsenter-for-host-service-management-in-ci.md). The duplicate-ID hazard is real — the new ADR must be013-upload-artifact-v3-pin.md, not "0XX" as the issue body says.Recommendations
013-upload-artifact-v3-pin.md. Follow the existing ADR template (Context / Decision / Consequences / Alternatives). State the upstream tracking issue explicitly so the reader knows what condition would license a re-upgrade.uses:line should be load-bearing, not decorative. Use the exact phrasing:# Gitea Actions does not implement upload-artifact v4 protocol — pinned per ADR-013. Do NOT upgrade. See #557.The issue number in the comment closes the loop; the next agent searches Gitea for context and finds the full story.docs/infrastructure/ci-gitea.mdin the same PR. Replace# ← upgraded from v3with# pinned at v3 per ADR-013 — Gitea Actions limitation. The doc and the workflow must agree, or one of them will mislead the next change.ci.ymlstep that already enforces the ADR-012 birpc invariant. That section is becoming a "repo invariants" block — name it that way in a comment so the pattern is visible.Open Decisions
Felix Brandt — Senior Fullstack Developer
Observations
.gitea/workflows/ci.yml:82,:118, and.gitea/workflows/coverage-flake-probe.yml:61. I confirmed viagrep -rn "upload-artifact" .gitea/— no other usages exist. The issue's scope is accurate.ci.yml(lines 39–57). That block already enforces an ADR-012 invariant the same way — the pattern is established, and putting a second invariant check 200 lines away would split the convention.coverage-flake-probe.yml:60-64only uploads the coverage logif: failure(). The acceptance criterion "the artifact upload step actually producing an artifact (positive verification, not just 'job didn't fail')" cannot be satisfied by that step as written — a successful run uploads nothing. We need either a secondif: success()upload, or a manual workflow_dispatch on a run that we expect to fail (e.g. by injecting a known birpc-trigger). Cleaner: change the existing condition toif: always()so every run produces an artifact and the v3 path is exercised on every probe invocation.## Context / ## Decision / ## Consequences / ## Alternativesstructure (see012-browser-test-mocking-strategy.md). The new ADR should follow the same template, not invent a new shape.Recommendations
ci.yml, run the guard locally against the upgraded version, watch it FAIL with the expected message, then commit the guard. This is the red/green discipline for shell-script-as-test. Without the red step, we have no evidence the regex actually catches@v4,@v5,@v4.1,@v4.0.0, etc. Suggested pattern:The
[4-9]([.0-9]*)?form catches@v4,@v4.1.0,@v9, etc., without false-positiving on@v3.0.0.coverage-flake-probe.ymlupload condition fromif: failure()toif: always(). Otherwise the acceptance criterion "workflow_dispatch run succeeds end-to-end with the artifact upload step actually producing an artifact" cannot be verified — successful runs never upload. Withif: always(), the v3 upload step is exercised on every matrix cell on every run, which is the positive verification the issue asks for.infra/that runs the same regex against three positive samples (@v4,@v4.0.1,@v5) and three negative samples (@v3,@v3.0.1,@v3-rc1) and asserts the expected outcomes. This is the regression test for the guard itself — without it, someone "fixes" the regex and silently widens the gap.Open Decisions
Tobias Wendt — DevOps & Platform Engineer
Observations
9f3f022e+4142c7cd, "closes #14"), the upgrade on 2026-05-05 (410b91e2), and we are now reversing the upgrade on 2026-05-13. Eight weeks per round trip. Without a guard, the next round trip lands by mid-July.@v4, deprecated actions accumulate vulnerabilities") is wrong for this runner. Gitea Actions runs onact_runner, which does not implement the v4 artifact protocol — the v4 client uploads via a GitHub-specific API that act_runner has not shipped. Pinning to@v3here is the correct, documented decision, not technical debt. The ADR needs to say this loudly so the next contributor (and the next AI) does not "fix" it.act_runnerreturns a non-zero exit code from the v4 client when the upload API responds with an unsupported-route error, even though the test suite already completed. Hard to diagnose because the build log shows green test output right above a job-level failure. Worth calling this out in the ADR's "How to spot the symptom" section verbatim.https://gitea.com/gitea/act_runnerfor upstream v4 support. There is currently no merged PR for v4 artifact protocol in act_runner — pinning at v3 is a multi-quarter decision, not a multi-week one.Recommendations
actions/upload-artifact@v3exactly — not@v3.x.y, not@main. The v3 major track is in maintenance mode; minor pinning would just add Renovate noise. Major-only pin matches the rest of the repo's convention.renovate.json(or whatever config file exists):Without this, Renovate will open a PR to upgrade to v4 every quarter and one of them will eventually merge. The bypass + grep guard is belt-and-braces.
gitea/act_runnerships v4 protocol support, re-evaluate. Link to the upstream tracker (https://gitea.com/gitea/act_runner/issuesfiltered for "artifact v4" if such an issue exists; if not, this is a candidate to file). Without an explicit upgrade trigger, the pin stays forever even after it becomes obsolete.coverage-flake-probe.yml, not justci.yml. A guard that only scans.gitea/workflows/ci.ymlwill miss the third call site. The guard's grep target must be.gitea/workflows/(the directory), not a single file.coverage-reports,unit-test-screenshots,e2e-results, the probe log) are <100MB each and live in Gitea's local storage — no infrastructure cost impact from the downgrade.Open Decisions
@v3major pin, Renovate bypass, ADR-013, and upstream tracker are the standard playbook for this exact situation.Sara Holt — QA Engineer & Test Strategist
Observations
coverage-flake-probe.ymlsucceeds end-to-end, with the artifact upload step actually producing an artifact (positive verification, not just 'job didn't fail')". This is the right instinct — but the workflow as written (.gitea/workflows/coverage-flake-probe.yml:60) uploads only onif: failure(). A successful run produces zero artifacts. As written, the acceptance criterion is unsatisfiable.ci.yml:39-57does not have its own test either — that is a debt I would flag separately, but this issue is the right time to set the new pattern.Recommendations
coverage-flake-probe.yml:60fromif: failure()toif: always(). This is the only way to satisfy the acceptance criterion as written. Withif: always(), the v3 upload runs on every matrix cell on every probe invocation, producing 20 artifacts per run. The probe's purpose is to detect the birpc race — but it's also our positive-verification surface for the upload action itself. Both invariants now run together.Wire this into the same CI step (or as a
pre-commithook) so the guard's regex is itself under test. Closes the recursion.download-artifactclarification to the guard. The current scope isupload-artifact.actions/download-artifact@v4has the same protocol incompatibility. Confirm via grep whether any workflow uses it (I see none today), and either extend the guard to cover both, or note explicitly in the ADR that the guard is upload-only because download is not currently used. Silence on this point is a known-known we should not leave unaddressed.Open Decisions
download-artifact, or scope strictly toupload-artifact? Cost of extending: 5 chars in the regex ((upload|download)-artifact). Cost of not extending: anactions/download-artifact@v4lands in a future PR and silently fails for the same reason, and we file issue #689. (I'd extend it — the regex change is trivial and the failure mode is identical, but flagging because the issue scoped it strictly to upload.)Nora "NullX" Steiner — Application Security Engineer
Observations
actions/upload-artifact@v3is in maintenance mode as of GitHub's 2024 retirement notice, but it has not been removed and is still resolvable from the marketplace. It does not introduce a known CVE on its own — the 2024 npm-side advisories on v3 were resolved within the action's own deps. Downgrading is not a security regression. I would block this if it were, but the threat surface is unchanged..gitea/workflows/— that path is itself a privileged surface. A PR that modifiesci.ymlto disable the guard step would defeat the guard in the same PR that re-introduces v4. Defence-in-depth says: the guard alone is not enough; the guard plus aCODEOWNERSor branch-protection rule on.gitea/workflows/is.@v3(major-only) keeps us on the latest patches of v3 — anything else would freeze us on a literal commit SHA. SHA pinning would be more secure (resistant to action repo compromise), but it costs Renovate noise. For a self-hosted, low-attack-surface Gitea instance with one trusted contributor, major-pin is the right balance. Worth noting in the ADR so the next reviewer doesn't "harden" it into a SHA pin without thinking about the operational cost.Recommendations
@v3(major), not a SHA. Document the trade-off explicitly in the ADR's Alternatives section: "SHA-pinning rejected — adds Renovate update friction; threat model (self-hosted single-tenant Gitea) does not justify it." A reader who later wants to harden this knows the decision was deliberate.CODEOWNERSentry for.gitea/workflows/if one does not already exist. A required-review rule on workflow files is the second layer of defence — the grep guard catches the regression, CODEOWNERS catches the guard's own removal. This is the same pattern as protectingSecurityConfig.java. (I checked:marcelis the sole maintainer, so this is more of a forcing function for AI-assisted PRs than a multi-reviewer gate, but it still creates a checkpoint.)actions/upload-artifact@v3is unmaintained but not vulnerable. If GitHub publishes a v3-specific advisory in the future, the ADR's "How we'll know we can upgrade" section becomes "or until v3 acquires an unpatched CVE" — add that escape hatch now so future security incidents have a clear procedure..env,target/classes/application.properties, or a Maven settings file. I checked the three current uploads —frontend/coverage/,/tmp/coverage-test-*.log,frontend/test-results/screenshots/,backend/target/surefire-reports/— none look risky, but reconfirming during this PR is cheap.Open Decisions
CODEOWNERSon.gitea/workflows/now, or scope it to a separate issue? Cost of adding now: 1 line inCODEOWNERS, zero behaviour change for a solo maintainer, valuable forcing-function for AI agents. Cost of deferring: a future PR that disables the guard has no second checkpoint. (I'd add the line in this PR, but it is genuinely out of the issue's scope and would warrant its own commit.)Elicit — Senior Requirements Engineer
Observations
actions/upload-artifact@v3") — testable, binary, automatable. Solid.uses:line" phrasing is slightly loose; I'd tighten to "on the line immediately precedinguses: actions/upload-artifact@v3."@v4.1,@v4-rc1,@v5,@main? Without a test fixture, this is qualitative. Felix and Sara have already proposed concrete fixtures — fold them into the AC.coverage-flake-probe.ymlsucceeds end-to-end, with the artifact upload step actually producing an artifact") — currently unsatisfiable given the workflow uploads only onif: failure(). Either the workflow must change, or the AC must change. Both are valid, but right now the issue contradicts itself.Recommendations
Resolve AC-4's contradiction. Either:
coverage-flake-probe.yml:60fromif: failure()toif: always(), then AC-4 is satisfiable on any successful run.Option A is preferable because it makes the verification automatic and continuous.
Add a new AC-5 covering the misleading documentation:
Without this, the doc continues to drive re-regressions even after the workflow is fixed. Markus already raised this; promoting it to a formal AC closes the gap.
Replace "0XX" with the actual ADR number in the issue body. Markus identified that there are already two ADR-012 files in
docs/adr/. The new ADR is013-upload-artifact-v3-pin.md. Update the issue body so implementers don't have to re-derive this.Add a Definition of Done note: every workflow change MUST be followed by a manual workflow_dispatch run before the PR merges. This is the only way to catch CI-config bugs that the suite itself cannot detect. Should become a project-wide DoD rule, but flagging here as relevant to this issue.
Open Decisions
if:change into this issue while Option B keeps the workflow file unchanged. (I'd take Option A for the continuous-verification benefit.)Leonie Voss — UX Designer & Accessibility Strategist
Observations
4142c7cd) put the rationale only in the commit body — invisible at the point where the change matters (theuses:line). That is the same failure pattern as a form field with no label: state-at-a-distance, no inline cue.Recommendations
uses:line, not in a separate file. The "screen" a contributor sees when editing this workflow IS the workflow file — anything not visible there will be missed. Concretely:Three lines. The "what" (don't upgrade), the "why" (Gitea limitation), the "where to look" (ADR + issue). This is the equivalent of a
<label>on theuses:line.Use plain language in the failure message. When the grep guard fires, the contributor sees the failure message — that is the "error toast" of the CI screen. Write it like a human:
This is the equivalent of an actionable error message: it tells the user what is wrong, what the consequence is, and what to do. Avoid
FAIL: regex matchstyle messages — they're the CI version of "Error 0x80070005".Documentation hierarchy: ADR-013 is the canonical source. The inline comment is the breadcrumb. The grep guard's failure message is the safety net. Each layer must reinforce the others — don't write the inline comment as if the ADR doesn't exist, and don't write the ADR as if the comment doesn't exist.
Discoverability:
docs/infrastructure/ci-gitea.mdis the doc a new contributor will read before editing workflows. The lines# ← upgraded from v3(203, 230, 332) currently teach the wrong lesson. Replace them with the same inline-comment pattern as the actual workflow. A doc that contradicts the code is worse than no doc.Open Decisions
Decision Queue — Action Required
3 decisions need your input before implementation starts.
Scope of the grep guard
download-artifacttoo, or keep it strictly toupload-artifact?actions/download-artifact@v4has the same Gitea-incompatibility. No workflow currently uses it, but a future PR could re-introduce the same regression silently. Cost of extending: ~5 chars in the regex ((upload|download)-artifact). Cost of deferring: a re-regression on the sibling action goes uncaught until someone runs the failing workflow manually. (Raised by: Sara)Verification surface (AC-4 contradiction)
coverage-flake-probe.yml:60uploads only onif: failure()— AC-4 ("artifact upload step actually producing an artifact") is currently unsatisfiable. Two ways to resolve:if: always(). Every probe run produces 20 artifacts; v3 path is exercised continuously; AC-4 is automatically verifiable.Defence-in-depth scope
CODEOWNERSentry for.gitea/workflows/in this PR, or scope it to a separate issue? Cost of adding now: 1 line inCODEOWNERS. Effect: forcing-function for AI-assisted PRs (the human/AI editing workflows needs explicit approval), second checkpoint against the grep guard's own removal. Cost of deferring: the guard alone protects against re-regression, but a PR that disables the guard while re-introducing v4 has no second line of defence. Genuinely outside the issue's literal scope. (Raised by: Nora)Decisions on the review queue
Resolving the four cross-cutting themes from the persona review so the implementation can proceed.
1. ADR number → 014
Existing
012-browser-test-mocking-strategy.mdand012-nsenter-for-host-service-management-in-ci.mdare a pre-existing collision — I will file a separate cleanup issue to renumber012-nsenter→013-nsenter. For this issue, use 014 (slot 013 is reserved for #556's threshold ADR — already filed and being implemented in parallel).2. Also fix
docs/infrastructure/ci-gitea.mdConfirmed via grep — lines 203, 230, 332 of
docs/infrastructure/ci-gitea.mdcontain the comment# ← upgraded from v3that actively encouraged the regression. In scope of this PR. Replace with:Mirror the same comment on each of the three
.gitea/workflows/call sites.3. Grep guard scope → include
download-artifacttooExtend the guard regex to match both action families:
Both are part of the same v4 protocol; gating one without the other is incomplete.
4. AC-4 resolution → Option A (drop "positive artifact upload" verification)
coverage-flake-probe.yml:60correctly usesif: failure()— artifacts only upload on failure (that's the whole point of the workflow). A passing run produces no artifact by design. Replace AC-4 with:This is positive verification of the v3 protocol working, not just "no failure".
5. CODEOWNERS → out of scope
Tracked separately. Don't bundle.
6. Grep-guard regression test → in scope
Per Felix + Sara: the grep guard itself needs a regression test. Add a tiny check (a bash test under
.gitea/workflows/__tests__/or similar, or just a self-test step in the guard job that creates a tempfile containingactions/upload-artifact@v5, runs the guard against it, and asserts non-zero exit). Keep this minimal — one fixture, one assert.Acceptance update
.gitea/workflows/pinned to v3 (already in scope)docs/infrastructure/ci-gitea.md(lines 203, 230, 332) similarly pinned with the ADR-014 comment (NEW — §2)(upload|download)-artifact@v[4-9](revised — §3)Items closed by these decisions
docs/infrastructure/ci-gitea.md" (Markus, Leonie, Elicit) → §2 abovedownload-artifact" (decision queue Q1) → §3 abovePlan is pre-approved by the maintainer. The implementer should treat the items above as final and proceed.
Implementation complete
Branch:
worktree-feat+issue-557-upload-artifact-v3-pinCommits (4, in red/green order)
d2b63fbc596d03ceci(workflows): downgrade upload-artifact v4 → v3 — Gitea act_runner limitation (ADR-014)— makes guard GREEN7997de3fdocs(adr-014): record upload-artifact v3 pin and Gitea act_runner v4 limitation37b2ed6adocs(ci-gitea): replace '← upgraded from v3' with ADR-014 pin commentAcceptance criteria
actions/upload-artifact@v3(ci.yml ×2, coverage-flake-probe.yml ×1)docs/adr/014-upload-artifact-v3-pin.md; load-bearing inline comment on eachuses:line:# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.uses:lines (^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]), covers both upload and download per §3 decision, includes inline self-test that proves the regex catches v4+ before scanning the repodocs/infrastructure/ci-gitea.mdlines 203, 230, 332 updated;# ← upgraded from v3replaced with ADR-014 pin commentDecisions applied
docs/infrastructure/ci-gitea.mdfixed — in scope(upload|download)-artifact— extended regexif: failure()left as-is; positive verification via deliberate-failure runGuard technical note
The regex is anchored to
^\s+uses:\s+to prevent the self-testprintffixture string from false-positiving when the guard scans its own workflow file.