ci(workflows): consolidate ci into a single job on github-hosted runners - #47
Conversation
Eleven jobs each paid a cold 138s devbox install, so roughly 81% of the CI cost was redundant reinstallation. The lanes now share one devbox install in a single sequential job, gated by dorny/paths-filter so a bump in one ecosystem stops triggering the others. Runners move to GitHub-hosted, which is free and gives 4 vCPU on this public repo. Every check step carries continue-on-error and a final aggregator fails the job, so one broken lane no longer hides the rest.
The build matrices already carried the GitHub runner label in `os`, so `runs-on` now reads from it and the Blacksmith keys are gone. Jobs without a matrix take ubuntu-latest. Only runner labels change; the publish conditions, permissions and OIDC/trusted-publishing inputs are untouched.
Both tools come from nixpkgs at the versions the init_hook was compiling from source on every run, so the shell no longer needs network access or a `go install` to have them on PATH.
Minor and patch bumps land in one PR per ecosystem; majors stay individual so an incompatible one cannot block the group or complicate bisection.
|
Nota sobre a primeira tentativa deste run, que ficou vermelha e passou no rerun
Não mexi nisso porque este PR é só de infraestrutura de CI. Vale um issue Vale registrar também que o agregador de checks fez exatamente o trabalho |
The `ci` job runs `just dotnet-ci` on the devbox SDK 10 only, so folding the Linux leg into it left net8.0 tested nowhere on Unix: the Windows leg skips the 44 Unix-only process tests. The matrix runs both legs again, each installing 8.0.x and 10.0.x through actions/setup-dotnet and running the suite on every TFM. The `dotnet` path filter still gates the job.
actions/cache writes in a post step gated on `post-if: success()`, so a red job discarded the Go and NuGet caches even for lanes that had passed. Restore and save are now separate steps: the save runs under `always()` whenever the restore missed an exact key. Swatinem/rust-cache gets `cache-on-failure` for the same reason. The devbox cache was already surviving, since devbox-install-action calls actions/cache/save inline rather than as a post step.
`printf '%s'` left the list without a trailing newline, so the final `read` returned non-zero and no `::error::` annotation was ever emitted; the job failed with the names only in the raw log. The jq filter also looked at `outcome` alone, missing cancelled steps and the `outcome: success` / `conclusion: failure` case.
Steps without an `id` never reach `toJSON(steps)`, so a failure of the artifact service left the CI green and the coverage profile missing. The step now carries an `id` and reports a missing profile as an error.
`reports_claude_usage_process_failures` execs a fake script right after writing it, and fails with `Text file busy` when a sibling test thread still holds the inherited write descriptor. The 4 vCPU runner doubles the parallelism of `cargo test` and made the race frequent enough to cost reruns. RUST_TEST_THREADS is a migration guard; the fixture needs to close the descriptor before the exec.
Nothing consumed the output: commitlint and `just quality` run on every event regardless of which paths changed. The reason the repo-root package.json stays out of the `node` lane now lives as a comment on that filter.
|
Revisão endereçada — os 7 achados, um commit por contexto. Run verde: 1. net8.0 no Linux (blocker) — 2. Cache com job vermelho — Vale explicitar o mecanismo, porque ele é mais forte do que só o Evidência no run: a chave 3. Upload de coverage — 4. Flake do Rust — 5. Agregador — 6. Filtro 7. Comentário do cache de Go — |
The exact primary key is immutable, so publishing under it is a one-shot decision: every later run hits it and skips its own save. Saving under `always()` alone meant that an infra failure or a cancellation before the lanes ran could freeze a prefix-restored or empty cache under the new exact key, and path filters make a skipped lane the common case. Each save now requires a successful restore, a non-cancelled job, and at least one step that actually populates that cache having succeeded.
`$GOBIN` came first in PATH, so the untracked `bin/govulncheck` and `bin/gosec` left behind by the old init_hook kept shadowing the pinned packages on developer machines, and never got updated. Appending both `$CARGO_HOME/bin` and `$GOBIN` puts the devbox profile first, which also stops a `just rust-tools` install from shadowing the pinned cargo-deny and cargo-audit. The Rust toolchain is unaffected: the profile's rustup shims resolve the same 1.90.0 toolchain from $RUSTUP_HOME. CI never saw the problem, since `bin/` is gitignored.
|
Re-revisão endereçada. Run verde: A1 — envenenamento permanente do cache — Confirmo o diagnóstico, e neste repo o cenário é mais comum do que "falha de Cada save agora exige restore bem-sucedido, job não cancelado, e pelo menos um
Verificação de que a condição nova não quebrou o caminho saudável. No run
A e B reproduzem exatamente o observado, o que fecha a verificação: em B o A2 — Investiguei a precedência antes de inverter, como você pediu, e não há O A inversão completa também corrige o mesmo bug para o Rust, que estava fora do Ou seja: antes deste commit, A3 — sem ação, checado. Nenhum dos 6 filtros usa negação; são 24 padrões, |
GOTOOLCHAIN is `auto` and GOMODCACHE sits inside the cached paths, so a `go` directive newer than the devbox toolchain gets downloaded into the cache. That download is not recorded in go.sum, so hashing go.sum alone kept hitting the old exact key and re-downloading the toolchain on every run. go.mod and devbox.lock are now part of the key. The NuGet key of the `ci` job gains devbox.lock for the same reason: its SDK comes from devbox. The `dotnet-compat` key does not, since that job takes its SDK from setup-dotnet.
`cache-on-failure` made the post-save fire after a prefix restore or a miss without any Cargo lane having run, freezing an empty target under the exact key that later runs then hit and cannot replace. With path filters, a skipped lane is the common case, so this was the normal path rather than an edge case. `save-if` is evaluated when the action starts and cannot consult the lanes, so the action is gated on the lanes that consume the cache instead: rust, node and python all build from the workspace crate. It also moves below `pnpm install`, the last step that can abort the job, so no fail-fast path reaches the post-save with an empty target.
|
Re-revisão endereçada. Run verde: Verificação que faltava nas duas rodadas anteriores, agora direta. A troca Os saves do R1 — cache do Rust — Escolhi a primeira opção, gatear a action, porque é a que fica coerente com o Uma correção ao seu diagnóstico, que muda a solução: o Mantive o Verifiquei que o gate é exatamente equivalente a "algum lane de Cargo Fechei também a janela residual: a action passou para depois do R2 — chave do cache Go —
Chave nova: NuGet: o mesmo raciocínio vale, mas só em um dos dois jobs. O job R3 — mantive a disjunção, e aqui vai o porquê. Meu julgamento é que neste repo a conjunção custa mais do que protege:
O cenário que você descreve — Se preferir a conjunção mesmo assim por uniformidade entre os cinco repos, é |
The lane gate only proves a Cargo lane was scheduled. A node run that dies in `npm install`, or a python run that dies in `uv venv`, never reaches cargo, and `cache-on-failure` then published an empty target under the exact key that later runs hit and can no longer replace. `save-if` is read by the post step, so it can require one of the lanes that build from the workspace crate to have succeeded. The comment that claimed otherwise was wrong.
|
R4 corrigido — Você tem razão sobre o async function run() {
const cacheProvider = getCacheProvider();
const save = core.getInput("save-if").toLowerCase() || "true";
if (!(cacheProvider.cache.isFeatureAvailable() && save === "true")) {
return;
}
try {
if (isCacheUpToDate()) {
core.info(`Cache up-to-date.`);O Verificação empírica, não argumento. Esse trecho dá um discriminador
Log real do post step no run novo: Com Escolhi a opção A (
Mantive o O gate nos lanes continua, cobrindo o caso anterior (nenhum lane de Cargo R3 — obrigado pela arbitragem, deixo como está. |
dotnet/.editorconfig does not set `root = true`, so the repo-root file is inherited by `dotnet format` and by the build analyzers. Adding a C# rule at the root therefore changes what the dotnet lane verifies, while the filter matched only `dotnet/**` and skipped it. Verified that no other checker in this repo reads .editorconfig: rustfmt has no rustfmt.toml and does not consult it, gofumpt and goimports have no such config, and markdownlint-cli2 uses .markdownlint.jsonc. So the file belongs in this lane rather than in `global`.
|
F1 corrigido — Fui pela opção (b), que colapsa em só
Todos os quatro passavam no baseline, então a falha do T1 é sinal, não ruído. Conclusão: o Escolhi o T1 justamente numa chave ausente do bloco Por isso a opção (a) seria conservadora demais na prática: colocar o arquivo em O filtro Deixei o porquê como comentário no filtro, para ninguém "consertar" movendo o Regressão conferida no run novo: o post step do cargo segue logando |
`* text=auto eol=lf` decides how the whole working tree is materialised at checkout, so an added rule such as `*.cs text eol=crlf` can break a lane whose own paths were untouched. The file matched no filter at all, which let such a PR go green with every lane skipped and fail on the next push to master. It belongs in `global`, next to the other repo-wide policy files.
|
F1 corrigido —
Confirmei o conteúdo antes de aplicar: a linha 1 é Deixei comentário no filtro registrando o critério, para o arquivo não migrar Com isso encerro este PR. Os três itens que continuam abertos são de escopo
|
O quê / Por quê
O CI deste repo tinha 11 jobs, e 9 deles instalavam o devbox do zero a
138s cada — cerca de 81% do custo total era reinstalação redundante.
O cache do nix store nunca acertava: os workflows rodavam só em
on: pull_request, então o cache criado num PR fica no escoporefs/pull/N/mergee PRs irmãos não conseguem lê-lo;masternunca tinha cacheporque o CI nunca rodava em push. A evidência aparece nos 9 jobs do run
30718800367:Também não havia path filters: um bump de
tokio-util(o mesmo run30718800367) acionou as pipelines de dotnet, python, node e Go semnecessidade.
Por fim, o repo é público, então runners GitHub-hosted são gratuitos e
ilimitados — e
ubuntu-latestem repo público entrega 4 vCPU / 16 GBcontra os 2 vCPU do
blacksmith-2vcpu. Manter o Blacksmith aqui só queimava ofree tier de 3.000 min/mês sem ganho.
Mudanças
.github/workflows/ci.yml(reescrito)push: [master], o que aquece o cache do nix storeno escopo base que os PRs conseguem ler.
concurrencycomcancel-in-progress: true.permissions: contents: read+pull-requests: read(obrigatório para odorny/paths-filterem eventospull_request).changescomdorny/paths-filter@v4e um output por lane(
global,go,rust,node,python,dotnet,tooling). Empushtodos os outputs voltam
true.cisequencial com umúnico
devbox install. Ordem: checkout (fetch-depth: 0) → cache de Go(antes do devbox, porque o
init_hookroda durante a instalação) → devbox →Swatinem/rust-cache@v2e cache de NuGet →pnpm install --frozen-lockfile→ blocos de verificação.
id+continue-on-error: true, e um agregadorfinal relê
toJSON(steps)e falha o job listando os checks quebrados, comanotação
::error::por check. Sem isso, um lane quebrado esconderia todos oschecks seguintes. O filtro considera
failureecancelledemoutcomeeconclusion, e o upload de coverage entra no agregador (step semidnãoaparece em
toJSON(steps), e uma falha do serviço de artefatos deixaria o CIverde e sem coverage).
go-*emgo || global;rust-ciemrust || global;node-ciemnode || rust || globalepy-ciempython || rust || global(o addon e a extensão compilam a partir do crate do workspace);
dotnet-ci+dotnet-verify-packemdotnet || global.qualityecommitlint rodam sempre.
dotnet-compatcontinua num job separado, com as duas pernas(
ubuntu-latestewindows-latest) instalando 8.0.x e 10.0.x viaactions/setup-dotnete rodando a suíte em cada TFM. Agora é gated pelofiltro
dotnet, então só roda quandodotnet/**muda.Workflows de release
release-crate.yml,release-go.yml,release-npm.yml,release-pypi.ymlsaem do Blacksmith. As matrizes já carregavam o label correto na chave
os:,então
runs-onpassa a ler${{ matrix.os }}e a chaveruns-onsome dasentradas. Jobs sem matriz vão para
ubuntu-latest.Só os labels de runner mudaram — nomes de arquivo,
permissions, condiçõesde publish e tudo ligado a OIDC/trusted publishing ficaram intactos.
release-nuget.ymljá estava emubuntu-lateste não foi tocado.devbox.json/devbox.lockgovulncheck@1.6.0egosec@2.28.0passam a vir do nixpkgs, nas mesmasversões que o
init_hookcompilava do zero a cada shell. As duas linhas dego install … @latestsaíram doinit_hook: o CI deixa de depender de rede ede compilação para ter as ferramentas no PATH. O
devbox.lockestá commitado..github/dependabot.ymlGrupo
minor-and-patchnos 7 ecossistemas. Majors continuam em PRsindividuais: um major incompatível travaria o grupo inteiro e dificultaria a
bisecção. Intervalo
monthlye oscommit-message.prefixexistentespreservados.
Risco e validação
run que toca tudo (push em
master) fica maior que o do fan-out anterior. EmPRs típicos, que tocam um ecossistema só, o efeito é o inverso — roda um
devbox installem vez de nove, e só o lane afetado.rustincluem as partes em Rust debindings/nodeebindings/python, porque os bindings são membros doworkspace Cargo e sem isso mudanças neles pulariam rustfmt/clippy/audit/deny.
O
package.jsonda raiz fica fora do lanenode: ele é só ferramental(commitlint/husky/lint-staged) e antes um bump de lint-staged rebuildava o
addon nativo à toa.
just go-ci(que já incluigo-test-race) seguido de um segundogo test -coverprofile, o lane usa asreceitas individuais e uma única invocação
go test -race -count=1 -coverprofile=coverage.out ./..., que cobre a pernade race e produz o
coverage.outque continua sendo publicado como artefato.just dotnet-ciroda no SDK 10 do devbox,então o
net8.0depende inteiramente dodotnet-compat. As duas pernasforam mantidas: sem a perna ubuntu, os 44 testes Unix-only de
net8.0nãorodariam em lugar nenhum (a perna Windows os pula).
actions/cacheroda compost-if: success(), então um job vermelho descartava os caches de Go eNuGet mesmo com esses lanes verdes. Restore e save agora são steps separados
(
actions/cache/restore@v6+actions/cache/save@v6sobalways()), e oSwatinem/rust-cacherecebeucache-on-failure: true. O cache do devbox jásobrevivia porque a
devbox-install-actionchamaactions/cache/saveinline.RUST_TEST_THREADS: 2no step de Rust. Émitigação temporária de migração — o
ubuntu-latesttem 4 vCPU contra os2 do
blacksmith-2vcpu, e o dobro de paralelismo docargo testtornou acorrida de
ETXTBSYemreports_claude_usage_process_failuresfrequente obastante para exigir rerun. A correção real é no fixture (fechar o descritor
de escrita antes do
exec) e fica para um PR separado, já que este PR nãotoca em código de teste.
devbox run -- just qualitypassa (markdownlint,lychee, gitleaks);
devbox installreproduz o lock;gosecegovulncheckdo nix profile rodam limpos sobre a árvore; sintaxe YAML de todos os
workflows conferida.