Task · CXO-0018

Migrate the repo task surface to just and retire Makefiles and ad-hoc scripts

Description

Migrate codexlb2otel’s task surface to just

1. Outcome

codexlb2otel has a single top-level justfile (no submodules — the repo is small enough that mod/import add nothing). It defines the seven mandatory recipes plus repo-specific ones for the corpus-drift tooling (probe, probe-sampled, baseline, probe-ci), the multi-binary build (build), the live-run helper (run) and the archive sync helper (sync). just check is exactly what ci.yml’s test job enforces — gofmt, vet, all seven CLI binaries build, the no-corpus test suite, and the CI-flavoured drift probe. Makefile is gone. AGENTS.md, README.md and backlog/config.yml reference just recipes, never make. ci.yml’s test job collapses to one run: just check step behind a setup-just step. No shell scripts exist in this repo today (verified via git ls-files), so there is nothing to absorb or keep beyond the Makefile itself.

2. The complete justfile

Drop this in at codexlb2otel/justfile, adjust nothing unless a later step in this doc says to.

set shell := ["bash", "-euo", "pipefail", "-c"]

# Go 1.27's json/v2-backed implementation regresses the multi-gigabyte corpus
# scans (see Makefile history / go.mod comment). Keep the established v1
# implementation until the archive decoder is migrated and measured against
# json/v2 explicitly. Exported so every recipe below inherits it.
export GOEXPERIMENT := env('GOEXPERIMENT', 'nojsonv2')

# Directory clbprobe/clbsync operate against. Override: `just probe corpus=other/dir`.
corpus := env('CORPUS', 'corpus/processed')

# show the task surface
default:
    @just --list

# install go module dependencies into the local module cache
setup:
    go mod download

# format all go source in place
[group('check')]
fmt:
    gofmt -w .

# verify formatting (go source + this justfile); never mutates
[group('check')]
fmt-check:
    test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
    just --fmt --check

# static analysis (go vet; no golangci-lint config exists in this repo)
[group('check')]
[no-exit-message]
lint:
    go vet ./...

# full go test suite (uses the local corpus if present); set filter="Name" for a subset
[group('check')]
[no-exit-message]
test filter="":
    go test {{ if filter != "" { "-run " + filter } else { "" } }} ./...

# fast inner loop: same suite, corpus-backed tests forced to skip cleanly (CI's exact invocation)
[group('check')]
[no-exit-message]
test-short:
    CLB_CORPUS=/nonexistent CLB_NO_CORPUS=1 go test ./...

# build all seven CLI tools into bin/ (gitignored)
[group('build')]
build:
    mkdir -p bin
    go build -o bin/codexlb2otel ./cmd/codexlb2otel
    go build -o bin/clbsync ./cmd/clbsync
    go build -o bin/clbfind ./cmd/clbfind
    go build -o bin/clbsum ./cmd/clbsum
    go build -o bin/clbprobe ./cmd/clbprobe
    go build -o bin/clbprofile ./cmd/clbprofile
    go build -o bin/clbstat ./cmd/clbstat

# remove bin/ (everything `setup` + `build` can reproduce)
[group('build')]
clean:
    rm -rf bin

# THE GATE. Exactly what ci.yml's `test` job enforces.
[group('check')]
check: fmt-check lint build test-short probe-ci

# run codexlb2otel against a config file (long-running; set config=path to override)
[group('dev')]
run config="config.yaml":
    mkdir -p bin
    go build -o bin/codexlb2otel ./cmd/codexlb2otel
    ./bin/codexlb2otel -config {{ config }}

# pull new archives off the codex-lb host
[group('dev')]
sync:
    mkdir -p bin
    go build -o bin/clbsync ./cmd/clbsync
    ./bin/clbsync

# full drift check against corpus.sig.json (set corpus=path to override; exits 1 on breaking drift)
[group('check')]
probe:
    mkdir -p bin
    go build -o bin/clbprobe ./cmd/clbprobe
    ./bin/clbprobe {{ corpus }}

# sampled drift check (faster; cannot prove a shape is absent)
[group('check')]
probe-sampled:
    mkdir -p bin
    go build -o bin/clbprobe ./cmd/clbprobe
    ./bin/clbprobe -sampled {{ corpus }}

# accept the current corpus shape as the baseline (always run from a FULL scan)
[group('gen')]
[confirm('This overwrites corpus.sig.json from a full scan of the local corpus. Continue?')]
baseline:
    mkdir -p bin
    go build -o bin/clbprobe ./cmd/clbprobe
    ./bin/clbprobe -update corpus.sig.json {{ corpus }}

# CI's exact drift-probe invocation: builds clbprobe, scans corpus/, and treats
# "nothing to scan" (exit 3) as a documented pass rather than a failure. There
# is never a corpus in CI (it's gitignored, personal data) so this always
# exercises the exit-3 branch there; it exercises the real scan on a machine
# that does have corpus/ populated.
[group('check')]
[script('bash')]
probe-ci:
    set -euo pipefail
    go build -o /tmp/clbprobe ./cmd/clbprobe
    set +e
    /tmp/clbprobe -fail-on breaking corpus
    status=$?
    set -e
    case "$status" in
      0) echo "clbprobe: clean, no drift against corpus.sig.json" ;;
      3) echo "clbprobe: nothing to scan - skipped" ;;
      1) echo "clbprobe: drift at or above 'breaking' against corpus.sig.json"; exit 1 ;;
      *) echo "clbprobe: exit $status (see output above)"; exit 1 ;;
    esac

Notes on choices baked into that file (do not re-litigate without a new fact):

3. Makefile disposition

Makefile — absorb in full, then git rm Makefile.

Make target Replacement Notes
build (+ bin/% pattern rule) just build Pattern rule → explicit line per binary (7 fixed tools, enumerated in §11 of the fleet standard’s translation table: “no equivalent; write explicit rules”). Drops the GO_FILES-based smart-rebuild dependency tracking — go build’s own cache makes that redundant; the Makefile comment says as much (“go build caches, so the over-broad dependency costs nothing”).
run (CONFIG ?= config.yaml) just run / just run config=other.yaml CONFIG ?= → a config="" recipe parameter with a default, per §12 of the standard.
clean just clean Unchanged behavior.
sync just sync Unchanged behavior.
probe (CORPUS ?= corpus/processed) just probe / just probe corpus=other/dir CORPUS ?=corpus := env('CORPUS', 'corpus/processed') plus a recipe parameter default of the same name, per §12.
probe-sampled just probe-sampled Unchanged behavior.
baseline just baseline Now [confirm]-gated (§5.4 of the standard: this is a deliberate, hard-to-undo overwrite of a committed baseline file — the Makefile’s own comment already calls it “a deliberate act”).
test just test Unchanged behavior (full suite, optional filter= param added per the mandatory test contract).
test-short just test-short Unchanged behavior; also now the thing just check actually runs (see justfile notes above).
check (gofmt -l . ; go vet ./... ; go test ./...) just check Behavior changed deliberately: now runs fmt-check (which actually fails on unformatted files — the old gofmt -l . alone did not, see traps), lint, build, test-short (not test — see above), and probe-ci, to match what CI enforces exactly.
GOEXPERIMENT ?= nojsonv2 (top-level export) export GOEXPERIMENT := env('GOEXPERIMENT', 'nojsonv2') in the justfile Applies to every recipe automatically, same as the Makefile’s global export.

Instruction: git rm Makefile once the justfile is proven locally (§8, step order) and CI is green on it.

4. Script disposition

git ls-files | grep -E '\.(sh|bash|zsh|ps1)$' returns nothing. There are no shell scripts tracked in this repo. Nothing to ABSORB, nothing to KEEP. (There is also no scripts/ directory and no non-trivial helper program in another language used as a dev/CI task — cmd/* are the CLI tools themselves, not task scripts, and stay exactly as they are, invoked via the justfile recipes above.)

5. CI changes

.github/workflows/ci.yml

Current test job runs five separate run: steps (gofmt, go vet, go build, go test, clbprobe) after checkout + setup-go. Replace all five with a setup-just step and one run: just check.

Remove the job-level env block:

env:
  # Go 1.27's json/v2-backed implementation regresses multi-gigabyte archive
  # scans; retain the established v1 behavior until that migration is measured.
  GOEXPERIMENT: nojsonv2

— it’s redundant now: the justfile owns and exports GOEXPERIMENT itself (env('GOEXPERIMENT', 'nojsonv2')), so the workflow no longer needs to set it. (If you’d rather keep it as an explicit belt-and-braces override, that’s harmless too — the justfile’s env() call still respects it. Default recommendation: remove it, one source of truth.)

Steps section becomes:

    steps:
      - name: checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: setup go
        uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
        with:
          go-version-file: go.mod
          cache: true
          cache-dependency-path: go.sum

      - name: setup just
        uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0
        with:
          just-version: '1.58.0'

      - name: just check
        run: just check

Delete the five old run: steps (gofmt, go vet, go build, go test (non-corpus), clbprobe drift check) entirely — just check now runs all of it (fmt-check, lint, build, test-short, probe-ci, in that dependency order).

Do NOT touch:

.github/workflows/publish.yml

Out of scope entirely. No build/test/lint/format/generate/validate run: logic lives here — it delegates to rknightion/.github/.github/workflows/container-publish.yml via uses: (a reusable workflow call) and has one small inline step (read the go directive from go.mod, a two-line awk that isn’t a build/test/lint task). Per the fleet standard: never convert a uses: into run: just, and this repo’s own comment block already documents why this file is architecturally frozen (the brewmdm/ARC blocker). Do not touch it.

.github/workflows/release-please.yml

Out of scope. GitHub-native release-please orchestration plus a uses: call into publish.yml. No shell logic to migrate.

.github/workflows/trigger-docs-sync.yml

Out of scope. A broker-token mint (uses:) plus a repository_dispatch action (uses:). No shell logic to migrate.

.github/workflows/scheduled-archive-probe.yml

Out of scope for CI wiring, but note it for later: this workflow is explicitly documented in its own header comment as NOT YET LIVE (no self-hosted runner registered, placeholder archive path). It contains the same clbprobe build+run+case-statement pattern as ci.yml’s old drift-check step, just against a live archive with -sampled instead of a full committed corpus. When this workflow is eventually activated, point it at the justfile too — the recipe body would be clbprobe -sampled -fail-on breaking "$ARCHIVE_DIR" instead of probe-ci’s hardcoded corpus. Do not build that recipe now — it targets an unresolved runner label and an unresolved archive path; inventing a recipe for infrastructure that doesn’t exist yet is scope creep. Leave this workflow exactly as-is; this is a note for whoever activates it later, not an action item for this task.

6. Docs and agent-contract changes

AGENTS.md (imported by CLAUDE.md via @AGENTS.md — edit only AGENTS.md)

Replace the “## The gate” section:

## The gate

```bash
make check        # gofmt -l . ; go vet ./... ; go test ./...
go build ./...

make test-short is the fast inner loop and skips the corpus tests. A green test-short is not a green gate.


with:

```markdown
## Task interface

This repo's task surface is a `justfile`. Discover it, don't guess it:

    just --list                        # human-readable
    just --dump --dump-format json     # machine-readable
    just --show <recipe>               # what a recipe actually runs

- `just check` is the full gate and is exactly what CI enforces. It must pass before you commit.
- Prefer `just <recipe>` over the underlying tool. If you are typing `go test`, you want `just test`.
- Run `just` with stdin from /dev/null. `just baseline` is `[confirm]`-gated — it overwrites the
  committed `corpus.sig.json`. Stop and ask before running it; never pass `--yes` or `JUST_YES=1`.
- If a task you need does not exist, add a recipe with a `#` doc comment and a `[group(...)]` rather
  than running a bare command.
- `just test` is the full local suite (uses the corpus if you've synced one). `just check` runs
  `just test-short` instead, matching CI exactly — CI never has a corpus (it's gitignored, personal
  data). A green `just test-short` (or `just check`) is not proof the corpus-backed tests pass; that
  only happens locally with `just test` against a synced corpus.

(This preserves the original “green test-short is not a green gate” warning, restated for just naming, and folds in the fleet-standard §9 boilerplate.)

Do not paste the recipe list itself into AGENTS.md — same rot risk the standard warns about.

CLAUDE.md

No change — it’s a two-line pointer (@AGENTS.md) with no make or script references.

README.md

Replace lines 67, 74–82 (the make-based usage block):

Current (verbatim, includes a real discrepancy against the actual Makefile — probe is the full scan and probe-sampled is the fast one, but this README text has the fast/full description backwards relative to target names; see trap below):

make build && ./bin/clbfind resp_052b6a... # faster to re-run; bin/ is gitignored
...
| `make build` | build every tool into `bin/` |
| `make sync` | pull new archives off the codex-lb host |
| `make probe` | fast drift check against the baseline (exit 1 on anything new) |
| `make probe-full` | exhaustive drift check |
| `make baseline` | accept the current shape as the baseline (always from a full scan) |
| `make check` | gofmt + vet + tests |
| `make test-short` | tests without the corpus, the fast inner loop |

`make probe CORPUS=some/other/dir` overrides the directory.

Replace with (corrected to match actual behavior — probe is the full scan, probe-sampled is the fast one):

just build && ./bin/clbfind resp_052b6a... # faster to re-run; bin/ is gitignored
...
| `just build` | build every tool into `bin/` |
| `just sync` | pull new archives off the codex-lb host |
| `just probe` | full drift check against the baseline (exit 1 on breaking drift) |
| `just probe-sampled` | faster sampled drift check; cannot prove a shape is absent |
| `just baseline` | accept the current shape as the baseline (always from a full scan; asks to confirm) |
| `just check` | the full gate: fmt-check, lint, build, test-short, probe-ci |
| `just test` | full test suite (uses the local corpus if synced) |
| `just test-short` | tests without the corpus, the fast inner loop |

`just probe corpus=some/other/dir` overrides the directory.

Search the rest of README.md for any other make occurrence before finalizing this edit — only the block above was found by grep -n "make ", but re-grep at implementation time in case the file changed since this analysis.

7. backlog/config.yml

Current:

definition_of_done: ["make check passes: gofmt -l . reports nothing, go vet ./... clean, go test ./... green", "go build ./... succeeds"]

New:

definition_of_done: ["just check passes: fmt-check, lint, build, test-short and probe-ci all clean"]

Edit this file by hand — backlog/config.yml’s list-valued keys cannot be set through backlog config set (per this repo’s own AGENTS.md, which explicitly carves this file out as the one deliberate hand-edit exception). Do not run this through the backlog CLI.

8. Order of work

  1. Add justfile at repo root (content in §2 above). Do not touch anything else yet.
  2. Run just --fmt --check — fix formatting if it fails, or run just --fmt once to auto-format, then re-check.
  3. Prove every recipe locally: just setup, just fmt-check, just lint, just build, just test-short, just probe-ci, then just check end to end. Fix anything that doesn’t match the old Makefile/CI behavior before moving on.
  4. Update .github/workflows/ci.yml per §5. Push and confirm the test job is green on the real runner (not just local just check — CI’s Go version, cache behavior, and the always-exit-3 probe-ci branch all need to be observed passing in the real environment).
  5. Update AGENTS.md, README.md, backlog/config.yml per §6–7.
  6. Only once CI is green on the justfile-based workflow and nothing else references make or Makefile (re-grep the whole repo: grep -rn "make " --include="*.md" --include="*.yml" --include="*.yaml" . and grep -rn "Makefile" .), delete Makefile: git rm Makefile.
  7. Final full-repo grep for stray make references (docs, comments, workflow files) before closing the task.

Justfile first and proven locally, CI switched second, deletion last — never delete Makefile before CI is confirmed green on just check.

9. Traps specific to this repo

10. Out of scope

Acceptance Criteria

Definition of Done

Implementation Plan

  1. Reconcile the frozen migration brief with the current codexlb2otel CI and the ratified 2otel alignment: retain the established shared-workflow pins and ci-success aggregator while mapping the required Go gates to documented just recipes.
  2. Add the top-level justfile and schema-v2 golangci-lint configuration, with gosec enabled within golangci-lint; add the Renovate manager/annotation only where the relocated govulncheck pin is actually managed.
  3. Convert ci.yml to the canonical GitHub-hosted 2otel job surface using setup-just and just recipes, preserving triggers, permissions, concurrency, out-of-scope workflows, and ci-success needs.
  4. Update task-interface documentation and Backlog definition of done; prove formatting, task-surface discovery, targeted recipes, and the complete local check.
  5. Stage named paths, run CodeRabbit before each code commit, push the justfile/CI transition, observe exact-head CI, then remove Makefile only after that evidence; run final checks, reference sweeps, task finalization, and push.

Implementation Notes

Implemented the justfile, schema-v2 golangci-lint gate with gosec, the pinned Renovate custom manager, canonical 2otel CI jobs, docs, and migration-related operational references. Observed passing: just –fmt –check; just –list; just –dump –dump-format json; just setup; just lint; just build; just test-short; just vuln; just snapshot; just image; and actionlint for ci.yml.

Full local probe-ci read 49 local archive files (4.8 GB) and correctly failed on pre-existing drift: one breaking safety-buffering bool-to-object shape change plus ten new paths/values. The baseline and decoder are out of this migration scope; CXO-0007 is still To Do but covers only older non-breaking baseline findings. No baseline was accepted or changed. Exact-head CI must still prove its intentionally archive-less exit-3 branch.

PARKED: CodeRabbit review is mandatory before commit, but two authenticated reviews were rate-limited because the organization has exhausted included reviews and has no assigned seat. Resume only once CodeRabbit can return a review; rerun it against the staged diff before any commit. Full local just check also remains blocked by genuine corpus drift (one breaking safety-buffering shape change and ten new findings). Do not accept a baseline or alter decoder behavior in this task; route that contract work separately.

Unparked and completed 2026-08-29. CodeRabbit found a misplaced gosec nosec directive and a missing goreleaser install-only, both fixed in a follow-up. The corpus drift against corpus.sig.json is deliberately NOT baselined and remains its own concern. Migration at 4b1cecd; exact-head CI green.

Comments

author: campaign-ordering created: 2026-08-29 09:18

Fleet ordering — WAVE 2. Starts after the Wave 0 pilot (sf2loki / SFL-0073) and the Wave 1 hubs land.

Within Wave 2 the order is free — these repos do not depend on each other. Batching by language is worthwhile so one lane reuses its Makefile-to-recipe mapping across similar repos.

Do not start before the pilot reports. The standard may be amended off the back of it, and picking this up early risks coding against a superseded seam.

Provisioning just in CI. Which mechanism depends on the runner, and the two must not be mixed:

Runner Mechanism
arc-arm64 (m7kni self-hosted) just is baked into the runner image by m7kni/ci-tools (runner-image/Dockerfile, ARG JUST_VERSION). Do not add extractions/setup-just, and delete the step if this repo already has one — it installs a second just earlier on PATH and turns the image pin into a lie.
GitHub-hosted (all rknightion repos) extractions/setup-just, SHA-pinned, with an explicit just-version:.

Both sides currently sit on 1.58.0 and are Renovate-managed. ci-toolsTool version drift workflow fails if the Dockerfile ARG and the published image ever disagree, and lists any repo still carrying a second pin.

While you are in the workflow files, check the hub pin. On 2026-08-29 Renovate was unfrozen for rknightion/.github in m7kni/renovate-config — it had been enabled: false on the mistaken belief that callers tracked @main, which froze the fleet across 19 different hub SHAs (v1.3.1 June → v1.9.7 August) so that no hub fix ever propagated. Bumps now arrive as one grouped, CI-gated, automerged PR per repo. A uses: whose comment is not a real # vX.Y.Z still cannot be bumped (it resolves to a digest-only update, which the fleet rules disable) — if you find one, repair the comment as part of this task.

author: campaign-ordering created: 2026-08-29 10:42

Standard amendment — ci is the sanctioned superset of check (RATIFIED)

This supersedes the frozen wording check is the complete local gate and reproduces every CI job that can run off a GitHub runner”, which several lanes could not honour without making the pre-commit gate depend on a Docker daemon.

The definitions now are:

Every leg you put in ci must carry a comment naming which of those three it needs. That comment is the guard: without it ci becomes the bin for anything slow or awkward, check quietly stops meaning much, and the fleet is back to a per-repo gate.

Eleven of the 42 lanes arrived at this shape independently before it was ratified, which is why it won.

If this repo has no such legs, it has no ci recipe at all and check is the whole gate. Do not add an empty one.

author: campaign-ordering created: 2026-08-29 10:57

Fleet alignment — the 2otel family converges on one CI shape

These seven Go repos are near-identical applications and had drifted into two naming dialects and materially different coverage. The migration rewrites every run: block anyway, so converge them in the same change rather than preserving the drift in new clothes.

Canonical job names — used by tailscale2otel, graph2otel, polylens2otel and rfc6035-2otel, so this is the majority convention, not an invention:

build-test · lint · govulncheck · goreleaser-snapshot · docker-build · coverage · ci-success

opnsense2otel and transceiver-exporter currently use a second dialect — tests, race, docker-build-verify. Rename to the canonical set as part of this task.

ci-success is the only check the branch ruleset gates, so jobs can be renamed or merged freely provided ci-success’s needs: list is updated in the same commit. Never rename ci-success itself.

Required gates, and where each lives after the migration:

Gate Recipe Note
build + test + -race just test -race belongs in the standard test run
golangci-lint just lint needs a .golangci.yml, schema v2
gosec just lint a golangci-lint linter, NOT a separate job — enable it in .golangci.yml. Four of the seven already do it this way; a standalone gosec job would be a third dialect
govulncheck just vuln pinned golang.org/x/vuln/cmd/govulncheck@v1.3.0, matching the family
goreleaser snapshot just snapshot cross-compile ⇒ belongs in ci, not check
container build just image needs a Docker daemon ⇒ belongs in ci, not check

Already done for you (2026-08-29): govulncheck was added to opnsense2otel, transceiver-exporter and codexlb2otel ahead of the migration, because those three had no dependency vulnerability scanning at all. Convert those jobs to just vuln like any other; do not re-add them.

Still missing, fix as part of this task:

One known trap: the govulncheck@v1.3.0 pins are invisible to Renovate — go install pkg@version inside a run: block matches no manager. All five are four minor versions behind (current is v1.7.0). Once the version moves into the justfile as a # renovate:-annotated := assignment, it becomes managed. That is a real benefit of this migration, not incidental.

author: campaign-ordering created: 2026-08-29 11:20

Correction — moving a pin into the justfile does NOT make it Renovate-managed

The 2otel alignment comment above ends with a claim that needs narrowing. It says the govulncheck@v1.3.0 pins become managed “once the version moves into the justfile as a # renovate:-annotated := assignment”. The conditional in that sentence is doing real work, and the first completed migration did not satisfy it.

Verified on tailscale2otel at origin/main after TSO-0025 closed:

The version relocated and nothing else changed. It is exactly as invisible to Renovate as it was in the run: block, and still four minors behind (v1.3.0 against v1.7.0).

Two things are required, and neither is implied by “move the pin into the justfile”:

  1. The := assignment carries a # renovate: datasource=… depName=… annotation directly above it.
  2. renovate.json points a custom manager at the justfile — the customManagers:dockerfileVersions preset does not cover justfiles; that one only matches Dockerfiles and Containerfiles.

Treat “the pin is now managed” as false unless you have done both and checked. Do not record it as a benefit of this migration in a final summary without verifying renovate.json yourself.

Credit: caught by the tailscale2otel lane on its closeout, against the claim as originally written here.

View the source file on GitHub