Skip to content

ci(workflows): consolidate ci into a single job on github-hosted runners - #47

Merged
upsetbit merged 17 commits into
masterfrom
ci/consolidate-and-move-to-github-hosted
Aug 4, 2026
Merged

ci(workflows): consolidate ci into a single job on github-hosted runners#47
upsetbit merged 17 commits into
masterfrom
ci/consolidate-and-move-to-github-hosted

Conversation

@upsetbit

@upsetbit upsetbit commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

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 escopo
refs/pull/N/merge e PRs irmãos não conseguem lê-lo; master nunca tinha cache
porque o CI nunca rodava em push. A evidência aparece nos 9 jobs do run
30718800367:

Cache not found for input keys: Linux-X64-devbox-nix-store-2.35.1-98f8ac80…

Também não havia path filters: um bump de tokio-util (o mesmo run
30718800367) acionou as pipelines de dotnet, python, node e Go sem
necessidade.

Por fim, o repo é público, então runners GitHub-hosted são gratuitos e
ilimitados — e ubuntu-latest em repo público entrega 4 vCPU / 16 GB
contra os 2 vCPU do blacksmith-2vcpu. Manter o Blacksmith aqui só queimava o
free tier de 3.000 min/mês sem ganho.

Mudanças

.github/workflows/ci.yml (reescrito)

  • Passa a rodar também em push: [master], o que aquece o cache do nix store
    no escopo base que os PRs conseguem ler.
  • concurrency com cancel-in-progress: true.
  • permissions: contents: read + pull-requests: read (obrigatório para o
    dorny/paths-filter em eventos pull_request).
  • Novo job changes com dorny/paths-filter@v4 e um output por lane
    (global, go, rust, node, python, dotnet, tooling). Em push
    todos os outputs voltam true.
  • Os 9 jobs que instalavam devbox viraram um único job ci sequencial com um
    único devbox install
    . Ordem: checkout (fetch-depth: 0) → cache de Go
    (antes do devbox, porque o init_hook roda durante a instalação) → devbox →
    Swatinem/rust-cache@v2 e cache de NuGet → pnpm install --frozen-lockfile
    → blocos de verificação.
  • Todo step de verificação tem id + continue-on-error: true, e um agregador
    final relê toJSON(steps) e falha o job listando os checks quebrados, com
    anotação ::error:: por check. Sem isso, um lane quebrado esconderia todos os
    checks seguintes. O filtro considera failure e cancelled em outcome e
    conclusion, e o upload de coverage entra no agregador (step sem id não
    aparece em toJSON(steps), e uma falha do serviço de artefatos deixaria o CI
    verde e sem coverage).
  • Condições por lane: go-* em go || global; rust-ci em rust || global;
    node-ci em node || rust || global e py-ci em python || rust || global
    (o addon e a extensão compilam a partir do crate do workspace);
    dotnet-ci + dotnet-verify-pack em dotnet || global. quality e
    commitlint rodam sempre.
  • dotnet-compat continua num job separado, com as duas pernas
    (ubuntu-latest e windows-latest) instalando 8.0.x e 10.0.x via
    actions/setup-dotnet e rodando a suíte em cada TFM. Agora é gated pelo
    filtro dotnet, então só roda quando dotnet/** muda.

Workflows de release

release-crate.yml, release-go.yml, release-npm.yml, release-pypi.yml
saem do Blacksmith. As matrizes já carregavam o label correto na chave os:,
então runs-on passa a ler ${{ matrix.os }} e a chave runs-on some das
entradas. Jobs sem matriz vão para ubuntu-latest.
Só os labels de runner mudaram — nomes de arquivo, permissions, condições
de publish e tudo ligado a OIDC/trusted publishing ficaram intactos.
release-nuget.yml já estava em ubuntu-latest e não foi tocado.

devbox.json / devbox.lock

govulncheck@1.6.0 e gosec@2.28.0 passam a vir do nixpkgs, nas mesmas
versões que o init_hook compilava do zero a cada shell. As duas linhas de
go install … @latest saíram do init_hook: o CI deixa de depender de rede e
de compilação para ter as ferramentas no PATH. O devbox.lock está commitado.

.github/dependabot.yml

Grupo minor-and-patch nos 7 ecossistemas. Majors continuam em PRs
individuais: um major incompatível travaria o grupo inteiro e dificultaria a
bisecção. Intervalo monthly e os commit-message.prefix existentes
preservados.

Risco e validação

  • Risco principal: com os lanes sequenciais num job só, o wall-clock de um
    run que toca tudo (push em master) fica maior que o do fan-out anterior. Em
    PRs típicos, que tocam um ecossistema só, o efeito é o inverso — roda um
    devbox install em vez de nove, e só o lane afetado.
  • Path filters: os filtros de rust incluem as partes em Rust de
    bindings/node e bindings/python, porque os bindings são membros do
    workspace Cargo e sem isso mudanças neles pulariam rustfmt/clippy/audit/deny.
    O package.json da raiz fica fora do lane node: ele é só ferramental
    (commitlint/husky/lint-staged) e antes um bump de lint-staged rebuildava o
    addon nativo à toa.
  • Go: o teste roda uma vez só. Em vez de just go-ci (que já inclui
    go-test-race) seguido de um segundo go test -coverprofile, o lane usa as
    receitas individuais e uma única invocação
    go test -race -count=1 -coverprofile=coverage.out ./..., que cobre a perna
    de race e produz o coverage.out que continua sendo publicado como artefato.
  • Cobertura de .NET preservada: just dotnet-ci roda no SDK 10 do devbox,
    então o net8.0 depende inteiramente do dotnet-compat. As duas pernas
    foram mantidas: sem a perna ubuntu, os 44 testes Unix-only de net8.0 não
    rodariam em lugar nenhum (a perna Windows os pula).
  • Caches sobrevivem a job vermelho: o post step do actions/cache roda com
    post-if: success(), então um job vermelho descartava os caches de Go e
    NuGet mesmo com esses lanes verdes. Restore e save agora são steps separados
    (actions/cache/restore@v6 + actions/cache/save@v6 sob always()), e o
    Swatinem/rust-cache recebeu cache-on-failure: true. O cache do devbox já
    sobrevivia porque a devbox-install-action chama actions/cache/save inline.
  • Flake do Rust mitigado: RUST_TEST_THREADS: 2 no step de Rust. É
    mitigação temporária de migração — o ubuntu-latest tem 4 vCPU contra os
    2 do blacksmith-2vcpu, e o dobro de paralelismo do cargo test tornou a
    corrida de ETXTBSY em reports_claude_usage_process_failures frequente o
    bastante 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ão
    toca em código de teste.
  • Validado localmente: devbox run -- just quality passa (markdownlint,
    lychee, gitleaks); devbox install reproduz o lock; gosec e govulncheck
    do nix profile rodam limpos sobre a árvore; sintaxe YAML de todos os
    workflows conferida.

O primeiro run em master depois do merge é o que aquece o cache do nix
store
no escopo base. Só a partir daí os PRs passam a ver um
devbox install rápido — o run deste PR ainda paga a instalação fria.

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.
@upsetbit

upsetbit commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Nota sobre a primeira tentativa deste run, que ficou vermelha e passou no rerun
sem nenhuma mudança:

thread 'reports_claude_usage_process_failures' panicked at
crates/codexcw/tests/claude_account_usage_it.rs:76:18:
unexpected error: Process("start claude account usage: Text file busy (os error 26)")

ETXTBSY é a corrida clássica entre escrever/chmod um script e exec-á-lo
enquanto outra thread do runner de testes ainda segura o descritor de escrita
herdado por um fork. É um flake pré-existente no fixture de teste, não
uma consequência da consolidação — mas ubuntu-latest em repo público tem
4 vCPU contra os 2 do blacksmith-2vcpu, o que dobra o paralelismo do
cargo test e torna a corrida bem mais provável de aparecer.

Não mexi nisso porque este PR é só de infraestrutura de CI. Vale um issue
separado para tornar o fixture determinístico.

Vale registrar também que o agregador de checks fez exatamente o trabalho
dele: os outros lanes rodaram até o fim e o job falhou apontando rust-ci
por nome.

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.
@upsetbit

upsetbit commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Revisão endereçada — os 7 achados, um commit por contexto. Run verde:
https://github.com/c3-oss/codexcw/actions/runs/30938493545

1. net8.0 no Linux (blocker) — 22c3f12. A matriz do dotnet-compat
voltou a ter as duas pernas. Confirmado no run: a perna ubuntu-latest roda
os dois TFMs com 137 passed / 4 skipped cada, contra os 48 skips da perna
Windows. Os 44 testes Unix-only de net8.0 voltaram a executar. Corpo do PR
atualizado.

2. Cache com job vermelho — 5c07f77. Restore e save separados para Go e
NuGet (actions/cache/restore@v6 + actions/cache/save@v6 sob
always() && cache-hit != 'true'), cache-on-failure: true no
Swatinem/rust-cache, e o mesmo tratamento no NuGet do dotnet-compat. Sem
save-always.

Vale explicitar o mecanismo, porque ele é mais forte do que só o always():
como todo check tem continue-on-error, o job só fica vermelho no
agregador
, que é o último step. Os saves agora são steps inline que rodam
antes disso; o post step do actions/cache rodava depois e por isso era
pulado. O always() cobre o resto (falha de step de infra e cancelamento).

Evidência no run: a chave ubuntu-latest-nuget-… (36.7 MB) foi criada agora
pelo save explícito da perna ubuntu — não existia antes. Os saves do job ci
aparecem pulados porque o restore teve hit exato, que é o comportamento
correto.

3. Upload de coverage — 03018b1. Ganhou id: upload-coverage, então
entra no toJSON(steps) e no agregador, mais if-no-files-found: error.

4. Flake do Rust — e6aa2bd. RUST_TEST_THREADS: 2 no step de Rust, com
comentário marcando que é salvaguarda de migração. Não toquei no fixture. A
correção real (fechar o descritor de escrita antes do exec) fica para
issue/PR separado, e está registrada no corpo do PR.

5. Agregador — aaa8d91. printf '%s\n' e o filtro jq ampliado para
failure/cancelled em outcome e conclusion. Verificado rodando o
script extraído do YAML commitado contra um steps sintético: emite uma
anotação ::error:: por check e sai 1 nos quatro casos
(continue-on-error que falhou, cancelado, outcome: success /
conclusion: failure), e sai 0 ignorando skipped.

6. Filtro toolingd1990f1. Removido, filtro e output. A razão de o
package.json da raiz ficar fora do lane node virou comentário no filtro
node, que é onde alguém iria procurar.

7. Comentário do cache de Go — 5c07f77. A justificativa antiga (o
init_hook resolvendo ferramentas Go pelo module cache) morreu quando este PR
tirou os dois go install do hook. Agora o comentário explica o que o step de
fato faz: restore separado do save.

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.
@upsetbit

upsetbit commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Re-revisão endereçada. Run verde:
https://github.com/c3-oss/codexcw/actions/runs/30939317773

A1 — envenenamento permanente do cache — 0815501.

Confirmo o diagnóstico, e neste repo o cenário é mais comum do que "falha de
infra ou cancelamento": como os path filters existem justamente para pular
lanes, um PR que só toca dotnet/** restaurava o cache de Go por prefixo (ou
não restaurava nada), não rodava nenhum step de Go, e mesmo assim publicava
esse conteúdo sob a chave exata nova. A chave exata é imutável, então todo run
seguinte pulava o próprio save por cache-hit == 'true' — cache de Go
permanentemente incompleto, herdado pelos PRs a partir de master.

Cada save agora exige restore bem-sucedido, job não cancelado, e pelo menos um
step que de fato popula aquele cache tendo passado:

  • Go: go-tidy-check (module cache) ou go-test ou go-build (build cache)
  • NuGet do job ci: dotnet-ci ou dotnet-verify-pack
  • NuGet do dotnet-compat: o step de teste, que ganhou id: dotnet-test

key continua sendo cache-primary-key, e não usei save-always.

Verificação de que a condição nova não quebrou o caminho saudável. No run
novo todos os saves aparecem pulados, mas por cache-hit == 'true' — as chaves
de go.sum e dos .csproj não mudaram e o restore acertou a chave exata
(Cache restored from key: ubuntu-go-6f2c4e…). Para não ficar no "deve estar
ok", avaliei a expressão extraída do YAML commitado contra os dados reais
observados:

cenário save roda?
A — run anterior, dotnet-compat ubuntu: restore MISS + teste ok True observado: rodou (criou a chave de 36.7 MB)
B — run novo, ci Save Go cache: HIT exato False observado: pulado
C — PR não-Go: lane pulado + restore MISS False condição antiga salvaria e envenenaria
D — cancelado antes dos lanes False condição antiga salvaria e envenenaria
E — lane de Go verde + restore MISS True tem de ser True
F — teste vermelho, build ok + restore MISS True ainda salva
G — lane de Go inteiro vermelho + restore MISS False não há conteúdo a salvar

A e B reproduzem exatamente o observado, o que fecha a verificação: em B o
único termo falso é o cache-hit != 'true', que é o guard pré-existente e
correto — nenhum dos guards novos é o que bloqueia.

A2 — bin/ sombreando os binários pinados — 274691c.

Investiguei a precedência antes de inverter, como você pediu, e não há
conflito
: export PATH=$PATH:$CARGO_HOME/bin:$GOBIN.

O cargo do perfil nix é um shim do rustup 1.28.2 e resolve o mesmo
toolchain 1.90.0 a partir do $RUSTUP_HOME, igual ao shim do ~/.cargo/bin.
Verificado no shell real: cargo 1.90.0 / rustc 1.90.0, cargo fmt --check
e just go-lint-sec passando.

A inversão completa também corrige o mesmo bug para o Rust, que estava fora do
seu escopo: just rust-tools faz cargo install cargo-deny cargo-audit em
$CARGO_HOME/bin, que antes vencia os pinados do devbox. Agora tudo resolve
pelo perfil:

gosec       -> .devbox/nix/profile/default/bin/gosec        (2.28.0, era "dev")
govulncheck -> .devbox/nix/profile/default/bin/govulncheck  (1.6.0, era 1.4.0)
cargo-deny  -> .devbox/nix/profile/default/bin/cargo-deny
cargo-audit -> .devbox/nix/profile/default/bin/cargo-audit

Ou seja: antes deste commit, just go-lint-vuln local rodava scanner 1.4.0
mesmo com o pin em 1.6.0 — exatamente o que você descreveu. As receitas
go-tools/rust-tools seguem funcionando: usam GOBIN= explícito e
cargo install, não precedência de PATH. devbox.lock não mudou (o
init_hook não afeta o lock).

A3 — sem ação, checado. Nenhum dos 6 filtros usa negação; são 24 padrões,
todos positivos. Sob predicate-quantifier: 'some' (o default) um lane casa
se qualquer padrão casar, que é exatamente a semântica de união desejada.
Trocar para every quebraria tudo, porque exigiria que um mesmo arquivo
casasse com todos os padrões do lane simultaneamente. Mantido o default.

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.
@upsetbit

upsetbit commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Re-revisão endereçada. Run verde:
https://github.com/c3-oss/codexcw/actions/runs/30940846375

Verificação que faltava nas duas rodadas anteriores, agora direta. A troca
de chave do R2 produziu um miss de propósito, então os saves finalmente
puderam ser observados executando no caminho saudável — não mais inferidos:

Restore Go cache    | Cache not found for input keys: Linux-X64-go-dd5ab2b4…
Save Go cache       | Cache saved with key:           Linux-X64-go-dd5ab2b4…
Restore NuGet cache | Cache not found for input keys: Linux-X64-nuget-97a29b47…
Save NuGet cache    | Cache saved with key:           Linux-X64-nuget-97a29b47…

Os saves do dotnet-compat seguem pulados por hit exato, o que é correto: as
chaves daquele job não mudaram.

R1 — cache do Rust — 712c004.

Escolhi a primeira opção, gatear a action, porque é a que fica coerente com o
resto: mesma forma dos guards de Go e NuGet, e mantém o Swatinem/rust-cache
que a spec manda preservar. Trocar por actions/cache cru jogaria fora a
limpeza de artefatos obsoletos do target e a chave derivada de
Cargo.lock/rustc/toolchain.

Uma correção ao seu diagnóstico, que muda a solução: o save-if não
resolve
aqui. Ele é avaliado quando a action inicia, não no post step, então
não consegue consultar steps.rust-ci.outcome — a expressão veria valor vazio
e desligaria o save sempre. Por isso gateei a action inteira nos lanes
consumidores, que é informação conhecida no momento do step.

Mantive o cache-on-failure: true: com o agregador por último, o job só fica
vermelho depois de todos os checks, então sem ele o post-save seria pulado
justamente quando um lane de Cargo falhou — que é quando o cache mais vale.

Verifiquei que o gate é exatamente equivalente a "algum lane de Cargo
roda", nas 16 combinações de rust/node/python/global: zero divergência
nos dois sentidos (nenhum lane sem cache, e nenhum cache sem lane). Os lanes
node e python entram porque compilam a partir do crate do workspace, como
você apontou.

Fechei também a janela residual: a action passou para depois do
pnpm install
, que é o último step capaz de abortar o job. Tudo abaixo dela
é continue-on-error e não consegue fail-fast, então não há caminho que chegue
ao post-save com target vazio.

R2 — chave do cache Go — 249b6b1. Premissa confirmada, e a divergência já
existe hoje:

versão
go.mod (diretiva go) 1.26.2
toolchain do devbox 1.26.4

GOTOOLCHAIN=auto e GOMODCACHE=~/go/pkg/mod, que está dentro dos paths
cacheados. Hoje 1.26.4 ≥ 1.26.2 e nada é baixado. Mas basta um PR de gomod
subir a diretiva acima da versão do devbox para o toolchain ser baixado para
dentro do cache sem constar no go.sum — e a chave antiga, que hasheava só
**/go.sum, continuaria dando hit exato para sempre. Não há diretiva
toolchain explícita no go.mod para atenuar isso.

Chave nova:
${{ runner.os }}-${{ runner.arch }}-go-${{ hashFiles('go.mod', 'go.sum', 'devbox.lock') }},
com restore-keys no mesmo prefixo. Só existe um módulo Go no repo, então o
**/go.sum não cobria nada além do go.sum da raiz.

NuGet: o mesmo raciocínio vale, mas só em um dos dois jobs. O job ci
restaura com o SDK do devbox, então ganhou devbox.lock na chave. O
dotnet-compat não: ele tira o SDK do actions/setup-dotnet (8.0.x e
10.0.x), e o devbox.lock seria ruído que invalidaria o cache sem motivo.
Deixei comentário nos dois lugares para a assimetria não parecer descuido.

R3 — mantive a disjunção, e aqui vai o porquê.

Meu julgamento é que neste repo a conjunção custa mais do que protege:

  1. Não é o caso dos irmãos. Os sete steps do lane de Go têm o mesmo
    if, então rodam ou pulam juntos — não existe cenário de path filter em que
    go-build roda e go-tidy-check não. Ou seja, o argumento de "lane pode
    legitimamente não rodar" não é o que me impede de aplicar conjunção; ela
    é tecnicamente viável.
  2. O custo é alto e frequente. Com conjunção, todo PR com teste vermelho
    descarta o cache de Go inteiro. Teste falhando em PR é rotina, e é
    exatamente o run em que a pessoa vai fazer push de novo em seguida — pagaria
    download e recompilação em cada iteração.
  3. O dano que a conjunção evitaria não se materializa em Go. O
    GOMODCACHE é verificado contra o go.sum e o build cache é endereçado por
    conteúdo, ambos escritos por rename atômico. Um run interrompido deixa o
    cache incompleto, não corrompido — e incompleto se auto-cura, porque o
    Go rebaixa/recompila só o que falta. É diferente de um artefato meio
    extraído.
  4. O caso realmente perigoso já está fechado. O que envenena de verdade é
    cache vazio sob a chave exata imutável, e isso o guard de "pelo menos um
    populador passou" já bloqueia.

O cenário que você descreve — test estourando timeout com build passando —
também não se aplica na ordem atual dos steps: go / test roda antes de
go / build, e não há timeout por step configurado, só o default de job.

Se preferir a conjunção mesmo assim por uniformidade entre os cinco repos, é
uma linha e eu mudo — mas registro que aqui ela troca um risco que considero
não-materializável por um custo recorrente e real.

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.
@upsetbit

upsetbit commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

R4 corrigido — cf0fcbc. Run verde:
https://github.com/c3-oss/codexcw/actions/runs/30942377974

Você tem razão sobre o save-if, e eu errei. Fui na fonte antes de
corrigir. src/save.ts:

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 getInput está dentro do run() do dist/save/index.js, que o action.yml
declara como post:. O comentário que eu tinha escrito documentava uma
limitação inexistente e foi removido.

Verificação empírica, não argumento. Esse trecho dá um discriminador
exato, porque o save-if falso retorna em silêncio, sem log, enquanto o
Cache up-to-date. só é alcançável depois do check passar:

  • se o runner reavalia no post step → todos os lanes verdes → "true" → passa
    → loga Cache up-to-date.
  • se avaliasse no main step → steps.rust-ci.outcome seria nulo, a expressão
    renderizaria a string "false" (não vazia, então o || "true" não a
    resgata) → retorno silencioso, sem nenhuma linha de log

Log real do post step no run novo:

Post job cleanup.
Cache up-to-date.

Com rust-ci, node-ci e py-ci todos success. A linha está lá, então o
runner reavalia mesmo — confirmado no comportamento, não só na leitura do
código.

Escolhi a opção A (save-if nos outcomes dos lanes), não a separação dos
steps que populam o Cargo:

  1. Os três lanes se cobrem entre si. node e python rodam sempre que rust
    muda, então um rust-ci vermelho depois de compilar ainda salva via
    node-ci/py-ci. Não é hipótese: foi exatamente o que aconteceu na
    tentativa 1 do run 30936125408 deste PR — rust-ci falhou no flaky de
    ETXTBSY, node-ci e py-ci passaram.
  2. O ganho residual da opção B é só o caso em que todos os lanes de Cargo
    falham, que normalmente indica quebra sistêmica de toolchain — cache de
    valor duvidoso.
  3. A opção B custaria dividir três lanes em oito steps, cada um com id e
    continue-on-error, ampliando a superfície do agregador. Só dividi o lane
    de Go, e por um motivo concreto (não rodar o teste duas vezes).
  4. Direção da falha: a opção A erra para "não salva" — recuperável no próximo
    run verde —, nunca para envenenamento.

Mantive o cache-on-failure: true, e a fonte explica por quê melhor do que
eu tinha explicado: o action.yml declara
post-if: "success() || env.CACHE_ON_FAILURE == 'true'". Como o agregador
deixa o job vermelho antes do post step, sem essa flag o post-save nem roda —
inclusive quando os lanes de Cargo passaram e só um lint de Markdown falhou.
Agora as duas coisas são independentes e complementares: cache-on-failure
decide se o post step roda, save-if decide se ele grava.

O gate nos lanes continua, cobrindo o caso anterior (nenhum lane de Cargo
agendado) e evitando baixar o cache à toa.

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`.
@upsetbit

upsetbit commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

F1 corrigido — 6480cdd. Run verde:
https://github.com/c3-oss/codexcw/actions/runs/30947594037

Fui pela opção (b), que colapsa em só dotnet — e a decisão saiu de teste,
não de intuição. Modifiquei a .editorconfig da raiz de quatro formas e
rodei cada checker do repo, restaurando o arquivo entre os testes:

teste na .editorconfig da raiz checker resultado
adicionar [*.cs] csharp_new_line_before_open_brace = none dotnet format --verify-no-changes FALHOU → lê o arquivo
[*.{rs,py}] indent_size 4 → 2 cargo fmt --all -- --check passou → ignora
[*.md] indent_size 2 → 8 markdownlint-cli2 passou → ignora
[*.go] indent_style tab → space golangci-lint (gofumpt + goimports) passou → ignora

Todos os quatro passavam no baseline, então a falha do T1 é sinal, não ruído.

Conclusão: o dotnet format é o único checker deste repo que consome
.editorconfig.
Bate com a configuração: não existe rustfmt.toml nem
.rustfmt.toml e o rustfmt não lê .editorconfig; o gofumpt/goimports
(habilitados em formatters do .golangci.yml) não têm esse tipo de config e
sempre emitem tab; e o markdownlint-cli2 lê o .markdownlint.jsonc.

Escolhi o T1 justamente numa chave ausente do bloco [*.cs] do
dotnet/.editorconfig, para não cair na sobrescrita que você identificou — é
exatamente o caminho do buraco que você descreveu.

Por isso a opção (a) seria conservadora demais na prática: colocar o arquivo em
global faria uma mudança de [*.md] ou [*.go] na raiz rodar Go, Rust, Node,
Python e .NET, sendo que nenhum desses lanes sequer lê o arquivo — reintroduzindo
em menor escala o over-triggering que este PR existe para eliminar.

O filtro dotnet cobre os dois consumidores: o dotnet-ci/dotnet-verify-pack
do job ci e o job dotnet-compat, já que ambos saem do mesmo output. Também
cobre os analisadores, que leem dotnet_diagnostic.* do mesmo arquivo. O
dotnet/.editorconfig continua coberto por dotnet/**.

Deixei o porquê como comentário no filtro, para ninguém "consertar" movendo o
arquivo para global depois.

Regressão conferida no run novo: o post step do cargo segue logando
Cache up-to-date., então o save-if continua sendo reavaliado e resolvendo
true com os lanes verdes.

`* 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.
@upsetbit

upsetbit commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

F1 corrigido — a8be7bf. Run verde:
https://github.com/c3-oss/codexcw/actions/runs/30948809620

.gitattributes entrou no filtro global, junto de devbox.json,
devbox.lock, .justfile e .github/workflows/**.

Confirmei o conteúdo antes de aplicar: a linha 1 é * text=auto eol=lf, com
escopo *, mais regras binary e linguist-*. É o próprio git aplicando
transformação de checkout sobre a árvore inteira — de fato a natureza oposta à
da .editorconfig, que só o dotnet format lia. Existe um único
.gitattributes no repo, na raiz.

Deixei comentário no filtro registrando o critério, para o arquivo não migrar
para um lane de linguagem depois.

Com isso encerro este PR. Os três itens que continuam abertos são de escopo
alheio a ele e viram issue separada:

  1. Flake de ETXTBSY em reports_claude_usage_process_failures
    (crates/codexcw/tests/claude_account_usage_it.rs:76). Hoje mitigado por
    RUST_TEST_THREADS: 2. A correção real é no fixture — fechar o descritor de
    escrita antes do exec —, e este PR não toca código de teste.
  2. 4 vulnerabilidades do Dependabot no default branch (3 high, 1 moderate),
    reportadas pelo remote a cada push. Não investigadas.
  3. net8.0 no lane Linux do job ci: o just dotnet-ci roda só no SDK 10
    do devbox. A cobertura de net8.0 existe e está correta via dotnet-compat,
    nas duas pernas; só registro que os dois caminhos usam SDKs de origens
    diferentes (devbox vs actions/setup-dotnet).

@upsetbit
upsetbit merged commit 5f5fd23 into master Aug 4, 2026
5 checks passed
@upsetbit
upsetbit deleted the ci/consolidate-and-move-to-github-hosted branch August 4, 2026 20:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant