Den ursprungliga frågan var om updater kunde hantera Docker Swarm och Docker Compose samtidigt istället för exklusivt via MODE.
Svaret blev nej — och det visade sig vara rätt svar. En instans kan bara nå den Docker-daemon den är konfigurerad mot. Compose-stackarna ligger på andra servrar, så en instans som klarade båda lägena skulle fortfarande bara nå en daemon. Begränsningen låg aldrig i applikationen utan i deployment-topologin: en instans för en hel flotta.
Lösningen är därför en instans per server, och fan-out från CI. Det gör MODE-exklusiviteten korrekt snarare än en brist.
Konsekvens: noll Go-ändringar i den här PR:en.
Innehåll
Dokumentation
Designspec med de tre förkastade alternativen och de fyra antaganden beslutet vilar på — så att "gör så den klarar båda" inte tas upp igen utan att antagandena ändrats.
README.md: ny sektion om deployment-topologi, omskriven MODE-rad och known-gaps-punkt, samt rättad ingress som tidigare motsade den nya texten.
CLAUDE.md: designintent-punkten uppdelad — rationalen bakom exklusiviteten och swarm-lägets säkerhetsgrind var två regler som delade en bullet.
gitea-action/ — fan-out
endpoint tar nu en newline-separerad lista istället för en enda URL. En ensam URL fungerar oförändrat som en lista med ett element.
Newline och inte komma: URL:er får innehålla komma men aldrig radbrytning, så avgränsaren kan inte kollidera med datat.
Det som faktiskt var svårt
Loopen måste fortsätta förbi fel. Actionen hade set -euo pipefail, vilket gör att första nedsläckta servern avbryter körningen — resten av flottan uppdateras aldrig, och loggen döljer vilka som hann med. set -e är borttaget med en kommentar som förklarar varför, så det inte "städas tillbaka".
head -n -1 var GNU-specifikt. Den befintliga koden använde det, vilket gjorde fan-out-logiken omöjlig att testa på en utvecklarmaskin (illegal line count på BSD/macOS). Ersatt med bash parameter expansion, som dessutom tar bort två subprocesser per endpoint.
tag var dokumenterad som kosmetisk men är load-bearing i swarm-läge. Kedjan handlers.go:59-62 → swarm.goJob.Image → swarm_executor.goContainerSpec.Image gör att en utelämnad tag deployar :latest istället för den byggda committen. Compose-läget är genuint tag-agnostiskt, swarm är det inte. Eftersom det nya exemplet sätter en swarm-endpoint först låg detta nu på den dokumenterade lyckliga vägen. Dokumentationen är rättad i både action.yml och gitea-action/README.md.
Timeouts. En host som accepterar TCP men aldrig svarar blockerade loopen i all evighet — samma delvis-deployade utfall som ändringen finns för, nådd en annan väg. --connect-timeout 10 --max-time 900; generöst eftersom /update är synkron och väntar ut jobbet.
Verifiering
go build ./... och go test ./... gröna med tömd testcache.
Fan-out-logiken kördes mot en lokal HTTP-server i tre scenarier:
Scenario
Utfall
Tre endpoints, mittersta död
Alla tre försöktes, exit 1
En endpoint
Exit 0
Indentering och blankrader
Två rena URL:er, exit 0
Att den tredje endpointen försöktes är beviset att loopen fortsatte förbi felet.
Känd begränsning
Actionen är shell inuti composite-YAML och det finns ingen testharness för den i repot. Verifieringen ovan kördes för hand och fångas inte av CI. Regression i fan-out-logiken upptäcks alltså inte automatiskt.
En orelaterad, sedan tidigare befintlig inkonsekvens rättades på vägen: README.md beskrev compose-lägets skydd som två faktorer när koden gör tre (token, opt-in-label och STACKS_ROOT-prefix).
## Bakgrund
Den ursprungliga frågan var om updater kunde hantera Docker Swarm och Docker Compose samtidigt istället för exklusivt via `MODE`.
Svaret blev nej — och det visade sig vara rätt svar. En instans kan bara nå den Docker-daemon den är konfigurerad mot. Compose-stackarna ligger på andra servrar, så en instans som klarade båda lägena skulle fortfarande bara nå en daemon. Begränsningen låg aldrig i applikationen utan i deployment-topologin: en instans för en hel flotta.
**Lösningen är därför en instans per server, och fan-out från CI.** Det gör `MODE`-exklusiviteten korrekt snarare än en brist.
Konsekvens: **noll Go-ändringar** i den här PR:en.
## Innehåll
**Dokumentation**
- Designspec med de tre förkastade alternativen och de fyra antaganden beslutet vilar på — så att "gör så den klarar båda" inte tas upp igen utan att antagandena ändrats.
- `README.md`: ny sektion om deployment-topologi, omskriven `MODE`-rad och known-gaps-punkt, samt rättad ingress som tidigare motsade den nya texten.
- `CLAUDE.md`: designintent-punkten uppdelad — rationalen bakom exklusiviteten och swarm-lägets säkerhetsgrind var två regler som delade en bullet.
**`gitea-action/` — fan-out**
`endpoint` tar nu en newline-separerad lista istället för en enda URL. En ensam URL fungerar oförändrat som en lista med ett element.
```yaml
endpoint: |
https://updater-swarm.example.com/update
https://updater-web01.example.com/update
https://updater-web02.example.com/update
```
Newline och inte komma: URL:er får innehålla komma men aldrig radbrytning, så avgränsaren kan inte kollidera med datat.
## Det som faktiskt var svårt
**Loopen måste fortsätta förbi fel.** Actionen hade `set -euo pipefail`, vilket gör att första nedsläckta servern avbryter körningen — resten av flottan uppdateras aldrig, och loggen döljer vilka som hann med. `set -e` är borttaget med en kommentar som förklarar varför, så det inte "städas tillbaka".
**`head -n -1` var GNU-specifikt.** Den befintliga koden använde det, vilket gjorde fan-out-logiken omöjlig att testa på en utvecklarmaskin (`illegal line count` på BSD/macOS). Ersatt med bash parameter expansion, som dessutom tar bort två subprocesser per endpoint.
**`tag` var dokumenterad som kosmetisk men är load-bearing i swarm-läge.** Kedjan `handlers.go:59-62` → `swarm.go` `Job.Image` → `swarm_executor.go` `ContainerSpec.Image` gör att en utelämnad tag deployar `:latest` istället för den byggda committen. Compose-läget är genuint tag-agnostiskt, swarm är det inte. Eftersom det nya exemplet sätter en swarm-endpoint först låg detta nu på den dokumenterade lyckliga vägen. Dokumentationen är rättad i både `action.yml` och `gitea-action/README.md`.
**Timeouts.** En host som accepterar TCP men aldrig svarar blockerade loopen i all evighet — samma delvis-deployade utfall som ändringen finns för, nådd en annan väg. `--connect-timeout 10 --max-time 900`; generöst eftersom `/update` är synkron och väntar ut jobbet.
## Verifiering
`go build ./...` och `go test ./...` gröna med tömd testcache.
Fan-out-logiken kördes mot en lokal HTTP-server i tre scenarier:
| Scenario | Utfall |
|---|---|
| Tre endpoints, mittersta död | Alla tre försöktes, exit 1 |
| En endpoint | Exit 0 |
| Indentering och blankrader | Två rena URL:er, exit 0 |
Att den **tredje** endpointen försöktes är beviset att loopen fortsatte förbi felet.
## Känd begränsning
Actionen är shell inuti composite-YAML och det finns ingen testharness för den i repot. Verifieringen ovan kördes för hand och fångas inte av CI. Regression i fan-out-logiken upptäcks alltså inte automatiskt.
En orelaterad, sedan tidigare befintlig inkonsekvens rättades på vägen: `README.md` beskrev compose-lägets skydd som två faktorer när koden gör tre (token, opt-in-label och `STACKS_ROOT`-prefix).
One instance per server, each owning its local Docker daemon; Gitea CI
posts /update to every instance. Records why MODE exclusivity is correct
rather than a limitation, and the alternatives rejected.
Claude-Session: https://claude.ai/code/session_01S3aqJ4tvaPezQhsGNCybut
head -n -1 fails on BSD/macOS, making the plan's local verification steps
unrunnable. Bash parameter expansion is portable and drops two subprocesses
per endpoint.
Claude-Session: https://claude.ai/code/session_01S3aqJ4tvaPezQhsGNCybut
Documentation fixes from the final branch review, plus small curl/jq
hardening in the gitea-action script:
- tag was documented as cosmetic ("for logging") but is load-bearing in
swarm mode: handlers.go folds it into the requested image, compose
discovery strips it via NormaliseImage, but SwarmExecutor assigns it
directly to ContainerSpec.Image. Omitting it deploys :latest, silently
diverging from what CI just built. Fixed in action.yml, gitea-action's
README, and added to CLAUDE.md's Gotchas since it's invisible from
either mode's code alone.
- gitea-action/README.md's opening line and root README.md's intro/trigger
flow described compose-only behavior even though both docs' bodies now
cover swarm mode too.
- README.md's defense-in-depth section described a two-factor gate; compose
mode is actually three factors (token, label, STACKS_ROOT prefix), and
swarm mode is genuinely two (no local compose file to path-check against).
- action.yml: curl now has --connect-timeout 10 --max-time 900 so a host
that accepts TCP but never answers can't block the fan-out loop forever;
the jq payload build now fails loudly instead of silently sending an
empty payload to every endpoint.
- CLAUDE.md References section now lists this branch's spec and plan.
Claude-Session: https://claude.ai/code/session_01S3aqJ4tvaPezQhsGNCybut
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Bakgrund
Den ursprungliga frågan var om updater kunde hantera Docker Swarm och Docker Compose samtidigt istället för exklusivt via
MODE.Svaret blev nej — och det visade sig vara rätt svar. En instans kan bara nå den Docker-daemon den är konfigurerad mot. Compose-stackarna ligger på andra servrar, så en instans som klarade båda lägena skulle fortfarande bara nå en daemon. Begränsningen låg aldrig i applikationen utan i deployment-topologin: en instans för en hel flotta.
Lösningen är därför en instans per server, och fan-out från CI. Det gör
MODE-exklusiviteten korrekt snarare än en brist.Konsekvens: noll Go-ändringar i den här PR:en.
Innehåll
Dokumentation
README.md: ny sektion om deployment-topologi, omskrivenMODE-rad och known-gaps-punkt, samt rättad ingress som tidigare motsade den nya texten.CLAUDE.md: designintent-punkten uppdelad — rationalen bakom exklusiviteten och swarm-lägets säkerhetsgrind var två regler som delade en bullet.gitea-action/— fan-outendpointtar nu en newline-separerad lista istället för en enda URL. En ensam URL fungerar oförändrat som en lista med ett element.Newline och inte komma: URL:er får innehålla komma men aldrig radbrytning, så avgränsaren kan inte kollidera med datat.
Det som faktiskt var svårt
Loopen måste fortsätta förbi fel. Actionen hade
set -euo pipefail, vilket gör att första nedsläckta servern avbryter körningen — resten av flottan uppdateras aldrig, och loggen döljer vilka som hann med.set -eär borttaget med en kommentar som förklarar varför, så det inte "städas tillbaka".head -n -1var GNU-specifikt. Den befintliga koden använde det, vilket gjorde fan-out-logiken omöjlig att testa på en utvecklarmaskin (illegal line countpå BSD/macOS). Ersatt med bash parameter expansion, som dessutom tar bort två subprocesser per endpoint.tagvar dokumenterad som kosmetisk men är load-bearing i swarm-läge. Kedjanhandlers.go:59-62→swarm.goJob.Image→swarm_executor.goContainerSpec.Imagegör att en utelämnad tag deployar:latestistället för den byggda committen. Compose-läget är genuint tag-agnostiskt, swarm är det inte. Eftersom det nya exemplet sätter en swarm-endpoint först låg detta nu på den dokumenterade lyckliga vägen. Dokumentationen är rättad i bådeaction.ymlochgitea-action/README.md.Timeouts. En host som accepterar TCP men aldrig svarar blockerade loopen i all evighet — samma delvis-deployade utfall som ändringen finns för, nådd en annan väg.
--connect-timeout 10 --max-time 900; generöst eftersom/updateär synkron och väntar ut jobbet.Verifiering
go build ./...ochgo test ./...gröna med tömd testcache.Fan-out-logiken kördes mot en lokal HTTP-server i tre scenarier:
Att den tredje endpointen försöktes är beviset att loopen fortsatte förbi felet.
Känd begränsning
Actionen är shell inuti composite-YAML och det finns ingen testharness för den i repot. Verifieringen ovan kördes för hand och fångas inte av CI. Regression i fan-out-logiken upptäcks alltså inte automatiskt.
En orelaterad, sedan tidigare befintlig inkonsekvens rättades på vägen:
README.mdbeskrev compose-lägets skydd som två faktorer när koden gör tre (token, opt-in-label ochSTACKS_ROOT-prefix).Documentation fixes from the final branch review, plus small curl/jq hardening in the gitea-action script: - tag was documented as cosmetic ("for logging") but is load-bearing in swarm mode: handlers.go folds it into the requested image, compose discovery strips it via NormaliseImage, but SwarmExecutor assigns it directly to ContainerSpec.Image. Omitting it deploys :latest, silently diverging from what CI just built. Fixed in action.yml, gitea-action's README, and added to CLAUDE.md's Gotchas since it's invisible from either mode's code alone. - gitea-action/README.md's opening line and root README.md's intro/trigger flow described compose-only behavior even though both docs' bodies now cover swarm mode too. - README.md's defense-in-depth section described a two-factor gate; compose mode is actually three factors (token, label, STACKS_ROOT prefix), and swarm mode is genuinely two (no local compose file to path-check against). - action.yml: curl now has --connect-timeout 10 --max-time 900 so a host that accepts TCP but never answers can't block the fan-out loop forever; the jq payload build now fails loudly instead of silently sending an empty payload to every endpoint. - CLAUDE.md References section now lists this branch's spec and plan. Claude-Session: https://claude.ai/code/session_01S3aqJ4tvaPezQhsGNCybutPull request closed