carlos / platform Public

Merge remote-tracking branch 'origin/main' into egress-and-minting-rulings

Paul Campbell pushed by paul@keymail.dev 5d59179cca902bf8dc26cc07140f171f0f4df761 parent 53d221eparent 1010be2
10 files changed, +498 −262
  • .amadan/README.md +73 −36
  • .amadan/ci.d/10-gate +43 −12
  • .amadan/ci.d/{20-cross@3 => 20-cross@5} +15 −6
  • .amadan/ci.d/30-minio-contract +323 −0
  • .github/workflows/ci.yml +0 −186
  • .gitignore +5 −0
  • AGENTS.md +24 −14
  • Makefile +8 −2
  • internal/host/statfs_unix.go +1 −1
  • internal/objstore/contract_test.go +6 −5
diff --git a/.amadan/README.md b/.amadan/README.md
index e35f10661..8d81510a1 100644
--- a/.amadan/README.md
+++ b/.amadan/README.md
@@ -1,41 +1,78 @@
# platform's CI on amadan
-`ci.d/` is a second home for this repo's gate, running on a self-hosted
-amadan runner. **`.github/workflows/ci.yml` remains the required check
-and remains the definition of green.** Where the two disagree, the
-workflow is the one that gates a merge, and the difference is a bug
-here.
+`ci.d/` is this repo's gate, running on a self-hosted amadan runner.
+**It is the only one.** `.github/workflows/ci.yml` was deleted on
+2026-08-24; nothing stands behind these three steps. A green run here
+is the whole of what "green" means for this repo, and a red one is not
+a discrepancy to reconcile against a more official run somewhere else.
-| workflow job | here | |
+The workflow went for two reasons that arrived together. The steps here
+had caught up with it and then gone past it — the last gap, `gofmt`,
+closed by *widening*, so this gate is now strictly the tighter of the
+two (see below). And GitHub Actions on the `carlosframework` account
+had stopped starting jobs at all over billing, so the "required check"
+had been gating nothing for some time before anyone removed it. The
+evidence for the first half is the run on `cd47f38`, green on the real
+arm64 worker with all four sections in the log: `=== gate ===`
+including `gofmt -l .: clean`, `=== cross ===` with all five targets,
+and `=== minio-contract ===` with `--- PASS: TestDeliveredContractMinio`
+and its five subtests.
+
+## Where each step came from
+
+This table is provenance, not a checklist against a live file. Every
+step here is shaped the way it is because of the job it descends from,
+and dropping that history makes the scripts read as arbitrary choices
+rather than as ported ones. The left column names a job in a file that
+no longer exists.
+
+| descended from | here | |
|---|---|---|
| `gate` | `10-gate` | build · vet · `go test ./... -count=1` |
-| `cross` | `20-cross@3` | one target per shard |
-| `minio-contract` | **not ported** | see below |
-| `gofmt` | **not ported** | see below |
-
-## What is deliberately missing, and why
-
-Two jobs are absent. Writing that down is the point of this file: a
-`ci.d/` directory that quietly covers less than the workflow next to it
-is worth less than no `ci.d/` at all, because it invites people to read
-a green run as meaning more than it does.
-
-**`minio-contract`** starts a MinIO server in Docker and runs
-`TestDeliveredContractMinio` against it. amadan CI jobs run under
-bubblewrap with the host filesystem read-only and no container runtime;
-`-isolate oci` is designed but unbuilt, precisely because "what
-container access does a job get, and does that leave any isolation at
-all" is unresolved. So this job stays on GitHub, where a service
-container is free. It is the only place that S3 contract runs live.
-
-**`gofmt`** checks only the files a PR changed, deliberately — three
-pre-existing unformatted files elsewhere are left alone, and a
-repo-wide check would fail on arrival and teach everyone to ignore the
-job. That needs the PR's **base SHA**, which is a GitHub concept. amadan
-CI hands a job its commit and its branch and has no notion of what it
-is a pull request against, so the job cannot be reproduced faithfully
-here. Reproducing it *unfaithfully* — repo-wide, or diffed against a
-guessed base — is worse than not running it.
-
-Both are gaps in this runner, not in the gate. The gate is still whole
-on GitHub.
+| `cross` | `20-cross@5` | one target per shard, all five |
+| `minio-contract` | `30-minio-contract` | MinIO on loopback, no container |
+| `gofmt` | `10-gate` | repo-wide, via `make test` — see below |
+
+## `gofmt`: ported by widening it, not by reproducing it
+
+This section is the reason it was safe to delete the workflow, so it is
+worth keeping even now that the comparison is with a dead file. The
+whole risk in a `ci.d/` that covers *less* than what it replaces is
+that it invites people to read a green run as meaning more than it
+does — and that risk did not end when the other gate did. It moved:
+there is nothing left to compare against, so a gap here is now a gap
+full stop. This file is the check on that, and it has to stay true.
+
+**`gofmt`** was the last gap, and it closed by widening rather than by
+imitation. The workflow checked only the files a PR changed, because it
+needed a **base SHA** — a GitHub concept. amadan CI hands a job its
+commit and its branch and has no notion of what it is a pull request
+against, so that job could not be reproduced faithfully here.
+
+It did not need to be. The scoping existed to protect three
+pre-existing unformatted files, and those are gone: `gofmt -l .` over
+the whole tree is clean today. So `10-gate` runs `make test`, whose
+recipe already contains a **repo-wide** gofmt check — strictly stronger
+than the job it replaced, and one definition shared with what a
+developer runs locally rather than a second list that can drift.
+
+The rule that survives from the old scoping is the one worth keeping:
+if a repo-wide check ever starts failing on arrival, fix the tree
+rather than narrowing the check, because a gate people learn to ignore
+is not a gate.
+
+## `minio-contract`, and why it turned out to be portable
+
+It was listed here as impossible: it started MinIO in Docker, and amadan
+jobs get no container runtime. That conflated the job with its
+packaging. MinIO and `mc` are single static Go binaries, and
+`TestDeliveredContractMinio` only wants an endpoint and credentials over
+HTTP — it has no opinion about what is serving. So `30-minio-contract`
+downloads both binaries at pinned versions, verifies them against pinned
+sha256 digests, and runs the server on loopback inside the job's
+existing bubblewrap sandbox. Nothing persists on the box.
+
+The test **skips itself** unless `CARLOS_MINIO_TEST=1`, and a skipped
+test exits 0. The step therefore requires the parent's `--- PASS:` line
+to appear in the output and fails without it, so a dropped env var
+cannot turn this into a green step that exercised nothing.
diff --git a/.amadan/ci.d/10-gate b/.amadan/ci.d/10-gate
index 9276c1635..434170661 100755
--- a/.amadan/ci.d/10-gate
+++ b/.amadan/ci.d/10-gate
@@ -3,18 +3,49 @@
# ./... -count=1 — all clean, every package, before any commit lands" —
# as amadan CI runs it.
#
-# A SECOND home for that gate, not a replacement.
-# .github/workflows/ci.yml stays the required check and stays the
-# definition of green; where the two disagree, the workflow is the one
-# that gates a merge and the difference is a bug in this file.
-#
-# -count=1 is the same rule for the same reason it is there: never
-# satisfy the gate from a cached pass. The 30m timeout is above Go's
-# 10m default because the root package's e2e tests compile the carlos
-# binary many times over — copied from the workflow rather than
-# re-derived, so a change there is a change in one place.
+# THIS IS THE GATE. Not a second home for one, not a copy kept beside a
+# more authoritative run somewhere else: the definition of green for this
+# repo is what these three steps say it is.
+#
+# It did not start that way, and the change is recent enough to be worth
+# writing down. Until 2026-08-24 the authority was
+# .github/workflows/ci.yml, and this file said so in as many words —
+# where the two disagreed, the workflow won and the difference was a bug
+# here. That workflow has been deleted. Two things made it safe to
+# delete, and they arrived together:
+#
+# 1. This directory caught up, and then went past it. `10-gate`,
+# `20-cross@5` and `30-minio-contract` cover every job the workflow
+# ran — build/vet/test, all five cross-compile targets, the live
+# MinIO contract — and the gofmt check here is STRICTER: `make
+# test` runs `gofmt -l .` over the whole tree, where the workflow
+# only ever checked the files a PR happened to touch. There is no
+# direction in which the deleted file was the tighter gate.
+#
+# 2. It had stopped running anyway. GitHub Actions on the
+# carlosframework account has been refusing to start jobs over
+# billing, so the "required check" was gating nothing well before
+# anyone removed it — which is the worse of the two failure modes,
+# because a gate that is merely absent is at least honest about it.
+#
+# So: a red step here is not a discrepancy to reconcile against a more
+# official run. It is the answer, and nothing behind it is going to catch
+# what it misses. Keep it true.
+#
+# One definition, not two. Everything past the build is `make test` — the
+# same target a maintainer runs on a laptop — so CI and the local gate
+# cannot drift apart, and a check added to one is a check added to both.
+# That is how the gofmt check gets here at all: `make test` runs vet, a
+# repo-wide `gofmt -l .`, then the tests, and this file no longer restates
+# any of it.
+#
+# `go build ./...` stays spelled out because `make test` does not do it —
+# the Makefile's test target starts at vet.
+#
+# -count=1 and -timeout 30m moved to the Makefile's go test line along with
+# the reasoning for them; they are still in force here, just no longer
+# written down twice.
set -eu
go build ./...
-go vet ./...
-go test ./... -count=1 -timeout 30m
+exec make test
diff --git a/.amadan/ci.d/20-cross@3 b/.amadan/ci.d/20-cross@5
similarity index 66%
rename from .amadan/ci.d/20-cross@3
rename to .amadan/ci.d/20-cross@5
index 6f79790c5..9dec4b177 100755
--- a/.amadan/ci.d/20-cross@3
+++ b/.amadan/ci.d/20-cross@5
@@ -9,9 +9,16 @@
# built it was on Linux. Cross-compiling catches it in seconds and needs
# no Mac.
#
-# The @3 is amadan's whole sharding interface: it runs this script three
+# Windows is a CLIENT target: `carlos deploy` from a Windows workstation
+# against a Linux fleet. The box-side packages still have to COMPILE
+# there, because the CLI is the same binary as the agent, so unix-only
+# syscalls live behind per-OS files (internal/edge/purge_listen_*.go,
+# internal/host/{statfs,flock}_*.go). These two shards are what stops
+# the next syscall.Foo from silently un-shipping the Windows build.
+#
+# The @5 is amadan's whole sharding interface: it runs this script five
# times concurrently and tells each copy which one it is. One target per
-# shard rather than three builds in a row, because they are independent
+# shard rather than five builds in a row, because they are independent
# and this box has the cores — and because a failing target then names
# itself in the shard that failed, instead of being the first line of a
# combined log.
@@ -22,12 +29,12 @@ shards=${AMADAN_SHARD_COUNT:?this step must be run by amadan CI, which sets AMAD
# The fence. The targets below are a fixed list, and the shard count in
# this file's name is what deals them out — so the two MUST agree. Rename
-# this to @2 and, without this check, one platform silently stops being
+# this to @4 and, without this check, one platform silently stops being
# compiled while CI stays green. That is the exact shape of the bug the
# job exists to catch, arriving through the job that catches it.
-if [ "$shards" -ne 3 ]; then
- echo "this step deals 3 fixed targets but was run with $shards shards —"
- echo "rename the file back to 20-cross@3, or add a target below"
+if [ "$shards" -ne 5 ]; then
+ echo "this step deals 5 fixed targets but was run with $shards shards —"
+ echo "rename the file back to 20-cross@5, or add a target below"
exit 1
fi
@@ -35,6 +42,8 @@ case "$shard" in
0) GOOS=darwin GOARCH=arm64 ;;
1) GOOS=darwin GOARCH=amd64 ;;
2) GOOS=linux GOARCH=arm64 ;;
+3) GOOS=windows GOARCH=amd64 ;;
+4) GOOS=windows GOARCH=arm64 ;;
*) echo "no target for shard $shard"; exit 1 ;;
esac
diff --git a/.amadan/ci.d/30-minio-contract b/.amadan/ci.d/30-minio-contract
new file mode 100755
index 000000000..37cac5ee0
--- /dev/null
+++ b/.amadan/ci.d/30-minio-contract
@@ -0,0 +1,323 @@
+#!/bin/sh
+# The S3 delivered-contract gate: internal/objstore's
+# TestDeliveredContractMinio, run against a real MinIO, with no container
+# runtime anywhere in sight.
+#
+# THIS IS THE ONLY PLACE THIS TEST RUNS LIVE. It began as a port of the
+# `minio-contract` job in .github/workflows/ci.yml, with that workflow
+# holding the final say; the workflow has since been deleted (10-gate's
+# header records why), so there is no longer a second run of this
+# anywhere. If this step does not exercise the contract against a real
+# server, nothing does — `TestDeliveredContractMinio` skips itself
+# without CARLOS_MINIO_TEST, and a skipped test is a green test.
+#
+# References to "the workflow" below are provenance: they say where a
+# choice came from and where this deliberately diverged from it. They
+# describe a file that no longer exists.
+#
+# WHY THIS CAN EXIST HERE AT ALL
+#
+# .amadan/README.md used to list this job as "not ported", on the grounds
+# that it starts MinIO in Docker and amadan jobs get no container
+# runtime. That reasoning conflated the job with its packaging. MinIO and
+# mc are single static Go binaries, published per-arch at pinned archive
+# URLs on dl.min.io, and contract_test.go only ever wanted MINIO_ENDPOINT
+# plus credentials over HTTP — it has no opinion about what is serving.
+# So the container was never load-bearing; it was just how GitHub happens
+# to hand you a MinIO.
+#
+# Running the server as a plain process inside the job's existing
+# bubblewrap sandbox was, if anything, tighter than the container the
+# workflow used, and still is the tighter of the two shapes: it binds
+# loopback only, its data directory is the job's private /tmp tmpfs, and
+# it dies with the job's PID namespace. Nothing persists on the box.
+set -eu
+
+# Fail on a missing tool by name rather than by a confusing error forty
+# lines down. Both are present on the runner; this is here so that a
+# stripped-down box says which one it is missing.
+for tool in curl sha256sum; do
+ command -v "$tool" >/dev/null 2>&1 || {
+ echo "$tool is required by this step but was not found on PATH" >&2
+ exit 1
+ }
+done
+
+# ---------------------------------------------------------------------
+# The pins.
+#
+# The workflow pinned its MinIO image and its mc download, and said why:
+# a floating minio tag has already broken this exact job class once, when
+# mc's admin-policy syntax churned across versions. This started as a
+# port of that pin, not an upgrade — RELEASE.2025-09-07T16-13-09Z is the
+# same server release that image digest resolved to, and the mc release
+# is the same one it downloaded.
+#
+# But a version string is a weaker pin than a container digest, which the
+# workflow got for free and a plain URL does not:
+# dl.min.io could serve different bytes at the same archive path and
+# nothing would notice. So each binary carries its sha256, verified after
+# download and again on every cache hit, and a mismatch refuses to run
+# rather than testing whatever arrived. The four digests below were taken
+# from upstream's own published .sha256sum files alongside each archive
+# (and independently confirmed against the downloaded bytes) on
+# 2026-08-23.
+#
+# Bump the two releases together, and re-derive all four digests when you
+# do.
+MINIO_RELEASE=RELEASE.2025-09-07T16-13-09Z
+MC_RELEASE=RELEASE.2025-08-13T08-35-41Z
+
+# Arch-aware on purpose. The amadan runner is arm64; the workflow's
+# ubuntu-latest was amd64, and it hardwired linux-amd64 for its mc
+# download — harmless there, wrong here, and a bug not to carry across.
+# The runner fleet is also the kind of thing that changes under you, so
+# this reads uname rather than trusting a note about what the box is.
+case "$(uname -m)" in
+x86_64 | amd64)
+ arch=amd64
+ minio_sha=7c5bd8512c6e966455b1d198209358b2d191c77a83ab377c4073281065fb855f
+ mc_sha=01f866e9c5f9b87c2b09116fa5d7c06695b106242d829a8bb32990c00312e891
+ ;;
+aarch64 | arm64)
+ arch=arm64
+ minio_sha=5c83cd2cf151717ba0243f73e1c7802ff36e272b67144bdd7f1f7d684fd6f03d
+ mc_sha=14c8c9616cfce4636add161304353244e8de383b2e2752c0e9dad01d4c27c12c
+ ;;
+*)
+ echo "no pinned MinIO build for $(uname -m) — add one above, do not guess" >&2
+ exit 1
+ ;;
+esac
+
+echo "minio contract: $arch, minio $MINIO_RELEASE, mc $MC_RELEASE"
+
+# ---------------------------------------------------------------------
+# The cache.
+#
+# amadan reuses one workspace per repo across jobs and does not clean
+# untracked files between them by default, so an untracked directory in
+# the checkout IS the cache — no separate cache action, no key, nothing
+# to expire. ~140MB of binaries then downloads once per worker rather
+# than once per run.
+#
+# The dot prefix is deliberate: the Go toolchain skips dot-directories
+# when walking ./..., so this cannot leak into `go build ./...` or
+# `go vet ./...` in 10-gate no matter what ends up inside it. The digest
+# check below is what makes reusing the cache safe — a truncated or
+# half-written binary from an interrupted job fails verification and is
+# re-fetched, so a cache hit is never taken on trust.
+cache="$PWD/.amadan-cache/minio-$arch"
+mkdir -p "$cache"
+
+fetch_pinned() {
+ fp_name=$1
+ fp_url=$2
+ fp_want=$3
+ fp_dest=$4
+
+ if [ -f "$fp_dest" ] && [ "$(sha256sum <"$fp_dest" | cut -d' ' -f1)" = "$fp_want" ]; then
+ echo " $fp_name: cached, digest verified"
+ return 0
+ fi
+
+ echo " $fp_name: downloading $fp_url"
+ curl -sfL --retry 3 -o "$fp_dest.part" "$fp_url"
+
+ fp_got=$(sha256sum <"$fp_dest.part" | cut -d' ' -f1)
+ if [ "$fp_got" != "$fp_want" ]; then
+ rm -f "$fp_dest.part"
+ echo "$fp_name digest mismatch — refusing to run" >&2
+ echo " want $fp_want" >&2
+ echo " got $fp_got" >&2
+ echo " from $fp_url" >&2
+ exit 1
+ fi
+
+ chmod +x "$fp_dest.part"
+ mv "$fp_dest.part" "$fp_dest"
+ echo " $fp_name: downloaded, digest verified"
+}
+
+fetch_pinned minio \
+ "https://dl.min.io/server/minio/release/linux-$arch/archive/minio.$MINIO_RELEASE" \
+ "$minio_sha" "$cache/minio"
+fetch_pinned mc \
+ "https://dl.min.io/client/mc/release/linux-$arch/archive/mc.$MC_RELEASE" \
+ "$mc_sha" "$cache/mc"
+
+minio=$cache/minio
+mc=$cache/mc
+
+# ---------------------------------------------------------------------
+# Scratch state, and getting rid of it.
+#
+# Everything mutable this step creates lives under one directory: MinIO's
+# data, mc's config (MC_CONFIG_DIR, so mc never touches $HOME), the
+# policy document, and the server log. /tmp inside an amadan job is a
+# private tmpfs that the sandbox discards when the job ends, so this is
+# scratch in the strongest sense available.
+rundir=$(mktemp -d "${TMPDIR:-/tmp}/carlos-minio-contract-XXXXXX")
+MC_CONFIG_DIR=$rundir/mc
+export MC_CONFIG_DIR
+
+minio_pid=
+
+# The trap runs on the success path and on every failure path, because
+# `set -eu` exits through it — which is the point. A step that leaves a
+# MinIO listening on a shared box after it fails would poison every later
+# run on that worker, and the failure that caused it is exactly when
+# nobody is watching.
+cleanup() {
+ if [ -n "$minio_pid" ]; then
+ kill "$minio_pid" 2>/dev/null || true
+ wait "$minio_pid" 2>/dev/null || true
+ fi
+ rm -rf "$rundir"
+}
+trap cleanup EXIT INT TERM
+
+# Root credentials are minted per run rather than hardcoded. They are not
+# a secret — the server is loopback-only and lives for seconds — but
+# random ones make the identity check below meaningful: creds that only
+# this process knows cannot authenticate against somebody else's MinIO.
+root_user=root$$
+root_pass=$(head -c 18 /dev/urandom | od -An -tx1 | tr -d ' \n')
+
+# ---------------------------------------------------------------------
+# Start the server.
+#
+# The workflow used a fixed port 9000 because its MinIO was in a
+# container with its own network. amadan jobs deliberately do NOT get
+# --unshare-net (they need the Go module proxy), so a job shares the
+# host's network
+# namespace with the worker and with anything else on it. 9000 is a
+# popular port; assuming it is free is assuming something this step does
+# not know.
+#
+# So: pick a high port, and if MinIO cannot take it, pick another. The
+# liveness probe alone would not be enough to tell the two cases apart —
+# a foreign MinIO already on the port answers /health/live perfectly
+# happily, and we would then test somebody else's server. Requiring OUR
+# process to still be alive is what distinguishes "it started" from "the
+# port was taken"; `mc alias set` with the random root credentials above
+# is the second, independent check that the thing answering is ours.
+port=
+attempt=1
+while [ "$attempt" -le 5 ]; do
+ candidate=$((20000 + ($$ + attempt * 997) % 20000))
+ rm -rf "$rundir/data"
+ mkdir -p "$rundir/data"
+
+ MINIO_ROOT_USER=$root_user MINIO_ROOT_PASSWORD=$root_pass \
+ "$minio" server "$rundir/data" \
+ --address "127.0.0.1:$candidate" \
+ --console-address "127.0.0.1:0" \
+ >"$rundir/minio.log" 2>&1 &
+ minio_pid=$!
+
+ waited=0
+ while [ "$waited" -lt 30 ]; do
+ if ! kill -0 "$minio_pid" 2>/dev/null; then
+ break # died — almost certainly could not bind
+ fi
+ if curl -sf "http://127.0.0.1:$candidate/minio/health/live" >/dev/null; then
+ port=$candidate
+ break
+ fi
+ sleep 1
+ waited=$((waited + 1))
+ done
+
+ [ -n "$port" ] && break
+
+ kill "$minio_pid" 2>/dev/null || true
+ wait "$minio_pid" 2>/dev/null || true
+ minio_pid=
+ echo " minio did not come up on 127.0.0.1:$candidate, trying another port"
+ attempt=$((attempt + 1))
+done
+
+if [ -z "$port" ]; then
+ echo "minio never became healthy after 5 attempts; last log:" >&2
+ cat "$rundir/minio.log" >&2
+ exit 1
+fi
+
+echo " minio healthy on 127.0.0.1:$port"
+
+# ---------------------------------------------------------------------
+# The fixture: two buckets and a scoped user granted exactly one of them.
+#
+# Carried over verbatim from the workflow's `mint scoped user` step —
+# same bucket names, same policy, same user — because the whole value of
+# the test is that cross-bucket denial is enforced by real IAM rather
+# than by the client politely not asking. Nothing checks this policy
+# against a second copy any more, so loosening it here is invisible:
+# grant `denied-bucket` by accident and the test still passes while
+# proving nothing. Change it only on purpose.
+"$mc" --quiet alias set local "http://127.0.0.1:$port" "$root_user" "$root_pass"
+"$mc" --quiet mb local/contract-bucket local/denied-bucket
+
+cat >"$rundir/policy.json" <<'EOF'
+{"Version": "2012-10-17", "Statement": [
+ {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::contract-bucket"]},
+ {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": ["arn:aws:s3:::contract-bucket/*"]}
+]}
+EOF
+
+"$mc" --quiet admin policy create local contract-only "$rundir/policy.json"
+"$mc" --quiet admin user add local scopeduser scopedsecret1
+"$mc" --quiet admin policy attach local contract-only --user scopeduser
+
+echo " buckets and scoped user ready"
+
+# ---------------------------------------------------------------------
+# Run it.
+#
+# Same env var names the test reads (contract_test.go's requireEnv calls),
+# and -count=1 for the same reason 10-gate uses it: never satisfy a gate
+# from a cached pass. Output goes to a file rather than through a pipe
+# because POSIX sh has no pipefail, and this step needs the test's own
+# exit status, not tee's.
+out=$rundir/test.log
+set +e
+CARLOS_MINIO_TEST=1 \
+ MINIO_ENDPOINT="http://127.0.0.1:$port" \
+ MINIO_SCOPED_KEY=scopeduser \
+ MINIO_SCOPED_SECRET=scopedsecret1 \
+ MINIO_BUCKET=contract-bucket \
+ MINIO_DENIED_BUCKET=denied-bucket \
+ go test ./internal/objstore/ -run TestDeliveredContractMinio -count=1 -v >"$out" 2>&1
+test_status=$?
+set -e
+
+cat "$out"
+
+if [ "$test_status" -ne 0 ]; then
+ exit "$test_status"
+fi
+
+# THE ASSERTION. Do not delete this because the test above already
+# "passed".
+#
+# TestDeliveredContractMinio skips itself unless CARLOS_MINIO_TEST=1, and
+# a skipped Go test exits 0. So every interesting way this step can break
+# — a typo'd env var, a MinIO that started but was never reachable, a
+# rename of the test, a future edit that drops the env block — produces a
+# green step that tested nothing at all. That is strictly worse than no
+# step, because it reports coverage this repo does not have.
+#
+# Requiring the parent test's PASS line to actually appear closes it. The
+# trailing " (" anchors on the parent rather than its subtests, whose
+# PASS lines are indented anyway.
+if ! grep -q '^--- PASS: TestDeliveredContractMinio (' "$out"; then
+ echo >&2
+ echo "the contract test did not PASS — it was skipped, filtered out, or renamed." >&2
+ echo "go test exited 0, which for this test means nothing was exercised: it" >&2
+ echo "skips unless CARLOS_MINIO_TEST=1. Check the env block above and that" >&2
+ echo "TestDeliveredContractMinio still exists in internal/objstore." >&2
+ exit 1
+fi
+
+echo "minio contract: PASS"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index b2d1ff026..000000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,186 +0,0 @@
-# The build gate from CLAUDE.md, enforced by a machine rather than by
-# whoever happens to be at a keyboard. The gate itself is not new; what is
-# new is that it now runs somewhere other than a maintainer's Linux laptop.
-#
-# The cross-compile job exists because of a specific bug: readDiskFreeBytes
-# in internal/host/probes.go multiplied an int64 by syscall.Statfs_t.Bsize,
-# which is int64 on Linux and uint32 on darwin. That is a compile error on
-# macOS for every GOARCH, and internal/host is imported by the carlos CLI —
-# the laptop tool the README's developer story is built around. It sat in
-# main undetected because every person who built it was on Linux. A
-# `GOOS=darwin go build ./...` costs seconds on a Linux runner and would
-# have caught it at the commit that introduced it.
-name: CI
-
-on:
- push:
- branches: [main]
- pull_request:
-
-permissions:
- contents: read
-
-# A second push to a PR makes the first run's answer irrelevant.
-concurrency:
- group: ci-${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- gate:
- name: build · vet · test (linux)
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
- with:
- go-version-file: go.mod
- cache: true
- - run: go build ./...
- - run: go vet ./...
- # -count=1 to match CLAUDE.md: never satisfy the gate from a cached
- # pass. The explicit timeout is above the 10m default because the
- # root package's e2e tests compile the carlos binary many times over.
- - run: go test ./... -count=1 -timeout 30m
-
- cross:
- name: compile ${{ matrix.goos }}/${{ matrix.goarch }}
- runs-on: ubuntu-latest
- strategy:
- # One platform breaking must not hide the state of the others.
- fail-fast: false
- matrix:
- include:
- - {goos: darwin, goarch: arm64}
- - {goos: darwin, goarch: amd64}
- - {goos: linux, goarch: arm64}
- # Windows is a CLIENT target: `carlos deploy` from a Windows
- # workstation against a Linux fleet. The box-side packages still
- # have to COMPILE there because the CLI is the same binary as the
- # agent, so unix-only syscalls live behind per-OS files
- # (internal/edge/purge_listen_*.go, internal/host/{statfs,flock}_*.go).
- # This job is what stops the next syscall.Foo from silently
- # un-shipping the Windows build.
- - {goos: windows, goarch: amd64}
- - {goos: windows, goarch: arm64}
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
- with:
- go-version-file: go.mod
- cache: true
- # Compile only — these binaries are never run, so no runner for the
- # target OS is needed. CGO_ENABLED=0 is already the default when
- # cross-compiling; it is explicit here so that adding a cgo dependency
- # fails loudly in this job rather than silently changing what is
- # covered. The tree is pure Go today (modernc.org/sqlite, no import "C").
- - run: go build ./...
- env:
- GOOS: ${{ matrix.goos }}
- GOARCH: ${{ matrix.goarch }}
- CGO_ENABLED: "0"
-
- minio-contract:
- # Exercises internal/objstore's TestDeliveredContractMinio: the S3
- # contract a member app actually gets from delivered grant env (static
- # creds, path-style, us-east-1, checksum WhenRequired), against a real
- # S3-compatible server plus a scoped user IAM never granted the second
- # bucket. This is the only place in the gate that runs that test live —
- # see internal/objstore/contract_test.go's doc comment.
- name: minio contract
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
- with:
- go-version-file: go.mod
- cache: true
- - name: start minio
- # Pinned rather than :latest — everything else in this file pins
- # (go-version-file, action majors), and a floating minio tag has
- # already broken this exact job class once (mc's own admin-policy
- # syntax churned across versions). Verified live against the quay.io
- # registry API on 2026-08-10: RELEASE.2025-09-07T16-13-09Z is the
- # image quay.io/minio/minio:latest currently resolves to (identical
- # manifest digest sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e).
- # Bump deliberately, not silently — re-verify against
- # https://quay.io/api/v1/repository/minio/minio/tag/ before moving it.
- run: |
- docker run -d --name minio -p 9000:9000 \
- -e MINIO_ROOT_USER=root -e MINIO_ROOT_PASSWORD=rootsecret \
- quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
- for i in $(seq 1 30); do
- if curl -sf http://127.0.0.1:9000/minio/health/live; then
- break
- fi
- if [ "$i" -eq 30 ]; then
- echo "minio never became healthy after 30s" >&2
- docker logs minio || true
- exit 1
- fi
- sleep 1
- done
- - name: mint scoped user
- run: |
- # Pinned to the mc release published alongside the minio server
- # image above (verified live against the minio/mc GitHub releases
- # API and dl.min.io's archive endpoint on 2026-08-10) rather than
- # the floating linux-amd64/mc path, for the same reproducibility
- # reason. Bump together with the minio image pin above.
- curl -sfL https://dl.min.io/client/mc/release/linux-amd64/archive/mc.RELEASE.2025-08-13T08-35-41Z -o mc && chmod +x mc
- ./mc --version
- ./mc alias set local http://127.0.0.1:9000 root rootsecret
- ./mc mb local/contract-bucket local/denied-bucket
- cat > /tmp/policy.json <<'EOF'
- {"Version": "2012-10-17", "Statement": [
- {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::contract-bucket"]},
- {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": ["arn:aws:s3:::contract-bucket/*"]}
- ]}
- EOF
- ./mc admin policy create local contract-only /tmp/policy.json
- ./mc admin user add local scopeduser scopedsecret1
- ./mc admin policy attach local contract-only --user scopeduser
- - name: contract test
- env:
- CARLOS_MINIO_TEST: "1"
- MINIO_ENDPOINT: http://127.0.0.1:9000
- MINIO_SCOPED_KEY: scopeduser
- MINIO_SCOPED_SECRET: scopedsecret1
- MINIO_BUCKET: contract-bucket
- MINIO_DENIED_BUCKET: denied-bucket
- run: go test ./internal/objstore/ -run TestDeliveredContractMinio -count=1 -v
-
- gofmt:
- name: gofmt (changed files)
- runs-on: ubuntu-latest
- # Only meaningful against a base to diff from.
- if: github.event_name == 'pull_request'
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - uses: actions/setup-go@v5
- with:
- go-version-file: go.mod
- # CLAUDE.md's rule is "gofmt -l clean for files you touch", not for the
- # tree: three pre-existing unformatted files are deliberately left
- # alone, so a repo-wide check would fail on arrival and teach everyone
- # to ignore this job. Scoped to the files the PR actually changes.
- - name: gofmt changed files
- env:
- BASE_SHA: ${{ github.event.pull_request.base.sha }}
- run: |
- set -euo pipefail
- files=$(git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD -- '*.go')
- if [ -z "$files" ]; then
- echo "no Go files changed"
- exit 0
- fi
- echo "checking:"
- echo "$files" | sed 's/^/ /'
- unformatted=$(gofmt -l $files)
- if [ -n "$unformatted" ]; then
- echo
- echo "needs gofmt -w:"
- echo "$unformatted" | sed 's/^/ /'
- exit 1
- fi
diff --git a/.gitignore b/.gitignore
index 0df49b9a3..6b209b585 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,8 @@ platform
*.tfstate*
.terraform/
.claude/worktrees/
+
+# Pinned MinIO/mc binaries cached by .amadan/ci.d/30-minio-contract.
+# amadan reuses one workspace per repo across jobs, so an untracked
+# directory in the checkout is that step's cache — see the step for why.
+.amadan-cache/
diff --git a/AGENTS.md b/AGENTS.md
index efe0aa0ee..78b2f8c32 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -74,26 +74,36 @@ Use the judgement the rest of this file asks for: a PR that is still
collecting review, that is gated on something (`#62`), or whose diff you
would want a second pair of eyes on, waits for a person.
-The reason is timing, not ceremony. `.github/workflows/ci.yml` triggers on
-`pull_request` *and* on `push` to `main`, so a locally merged change does
-still meet the gate — but only **after** it is on `main`, where a red run
-is an incident to clean up rather than a merge that simply doesn't happen.
-The PR is the one place the gate runs against the merge result while the
-change can still be turned away, and the one place the branch's diff is
-read as a single piece rather than commit by commit.
+The reason is timing, not ceremony. A locally merged change does still
+meet the gate — but only **after** it is on `main`, where a red run is an
+incident to clean up rather than a merge that simply doesn't happen. The
+PR is the one place the branch's diff is read as a single piece rather
+than commit by commit, while the change can still be turned away.
## Build & test
- `go build ./...`, `go vet ./...`, `go test ./... -count=1` — all clean,
every package (21 as of 2026-08-08; the count grows), before any commit
lands.
-- `gofmt -l` clean for files you touch (three pre-existing unformatted files
- elsewhere are left alone).
-- `.github/workflows/ci.yml` runs that same gate on every PR, plus a
- `GOOS=darwin` compile. **The gate is not Linux-only**: `internal/host` is
- linked into the CLI, which people run on Macs, so a Linux-only type
- assumption breaks `go build .` for them. Cross-compiling is enough to catch
- it — no Mac required.
+- `gofmt -l .` clean over the whole tree — `make test` checks it, and the
+ three pre-existing unformatted files that used to be exempted are gone.
+- **`.amadan/ci.d/` is the only CI this repo has.** The GitHub workflow
+ that used to run this gate was deleted on 2026-08-24, once the amadan
+ steps covered everything it did and more; `.amadan/README.md` records
+ what each step descends from, and `.amadan/ci.d/10-gate`'s header
+ records why the switch was safe. `10-gate` is `go build ./...` then
+ `make test`, so CI and what you run locally are one definition rather
+ than two that can drift.
+- **CI is triggered by a push to the `amadan` remote, not by GitHub.**
+ `origin` is still where PRs live and where `main` is source of truth,
+ but nothing on GitHub runs a build any more. A branch pushed only to
+ `origin` has been gated by exactly nothing — push it to `amadan` too,
+ and read the run, before calling it green.
+- `20-cross@5` adds `GOOS=darwin`/`windows`/`linux-arm64` compiles, one
+ per shard. **The gate is not Linux-only**: `internal/host` is linked
+ into the CLI, which people run on Macs, so a Linux-only type assumption
+ breaks `go build .` for them. Cross-compiling is enough to catch it —
+ no Mac required.
- Sandbox note: use `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod
GOPROXY=off`; go commands may need the sandbox disabled ("read-only file
system" from the toolchain is the tell).
diff --git a/Makefile b/Makefile
index 7e858f35a..5a963c1e4 100644
--- a/Makefile
+++ b/Makefile
@@ -5,10 +5,16 @@ GOARCH ?=
.PHONY: test build fmt
+# The build gate: vet, format, tests. `.amadan/ci.d/10-gate` runs this same
+# target, so what CI checks and what you check are one definition.
+#
+# -count=1 is CLAUDE.md's rule: never satisfy the gate from a cached pass.
+# The 30m timeout is above Go's 10m default because the root package's e2e
+# tests compile the carlos binary many times over.
test:
go vet ./...
- @fmt_out=$$(gofmt -l .); if [ -n "$$fmt_out" ]; then echo "gofmt needed:"; echo "$$fmt_out"; exit 1; fi
- go test ./...
+ @fmt_out=$$(gofmt -l .); if [ -n "$$fmt_out" ]; then echo "gofmt needed:"; echo "$$fmt_out"; exit 1; fi; echo "gofmt -l .: clean"
+ go test ./... -count=1 -timeout 30m
build:
CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags "$(LDFLAGS)" -o carlos .
diff --git a/internal/host/statfs_unix.go b/internal/host/statfs_unix.go
index 4c9916994..a57f9ceba 100644
--- a/internal/host/statfs_unix.go
+++ b/internal/host/statfs_unix.go
@@ -9,7 +9,7 @@ import "syscall"
//
// It returns BYTES, not the raw statfs fields, and that is the point: Bsize
// is int64 on Linux and uint32 on darwin, a difference that already broke
-// the CI build once (see .github/workflows/ci.yml's header) because the
+// the CI build once (see .amadan/ci.d/20-cross@5's header) because the
// multiplication happened at every call site instead of once here. Callers
// now get two plain uint64s and no per-OS arithmetic.
func diskBytes(dir string) (total, avail uint64, err error) {
diff --git a/internal/objstore/contract_test.go b/internal/objstore/contract_test.go
index 6a9489a57..ae328d283 100644
--- a/internal/objstore/contract_test.go
+++ b/internal/objstore/contract_test.go
@@ -28,13 +28,14 @@ import (
// been exercised anywhere in this tree is the actual wire contract a member
// app's client.Options() produces from delivered env, against a real
// S3-compatible implementation. Skipped by default because it needs a
-// running Minio; CI's minio-contract job (.github/workflows/ci.yml) sets
-// CARLOS_MINIO_TEST=1 and stands one up. There is no docker in this
-// sandbox, so the live path here has only ever been verified in CI — see
-// the commit body.
+// running Minio; CI's 30-minio-contract step (.amadan/ci.d/30-minio-contract)
+// sets CARLOS_MINIO_TEST=1 and stands one up, as a plain process on
+// loopback rather than a container. That step is now the only place this
+// test runs live anywhere, so treat a change to it as a change to what
+// this test is worth.
func TestDeliveredContractMinio(t *testing.T) {
if os.Getenv("CARLOS_MINIO_TEST") != "1" {
- t.Skip("set CARLOS_MINIO_TEST=1 (with a running Minio and the MINIO_* env vars) to run the delivered-contract test; see .github/workflows/ci.yml's minio-contract job")
+ t.Skip("set CARLOS_MINIO_TEST=1 (with a running Minio and the MINIO_* env vars) to run the delivered-contract test; see .amadan/ci.d/30-minio-contract, which stands one up")
}
endpoint := requireEnv(t, "MINIO_ENDPOINT")