◐ Off-By-One · answer catalog

go-ci-locale-sort-determinism

1 answer(s)godocker

export LCALL=C # export so the whole script (find/sort/grep if any) is pinned

📦 Source in repository (JSON)

Answer

Root cause: merge_specs.sh piped find output through a bare sort. Bare sort inherits the process locale: CI runners use the C/POSIX locale (byte-wise collation: 'A' < 'a', '-' < '0' < '_'), while dev boxes use en_US.UTF-8 (dictionary collation: 'a' < 'A', '_' < '-', different digit/unicode weights). The two orderings diverge across many spec filenames, so regenerating ALL_SPECS_MERGED.md on a dev box reordered ~17k lines relative to the CI-generated artifact.

Fix: pin collation to C everywhere the doc is generated, then commit the artifact regenerated in C order.

#!/usr/bin/env bash
# merge_specs.sh (fixed)
set -euo pipefail

# Pin collation: bare `sort` is locale-dependent. CI (C locale) and dev boxes
# (en_US.UTF-8) collate filenames differently, reordering ALL_SPECS_MERGED.md.
export LC_ALL=C   # export so the whole script (find/sort/grep if any) is pinned
export LANG=C

SPECS_DIR="${1:-specs}"
OUT="${2:-ALL_SPECS_MERGED.md}"
TMP="$(mktemp "${OUT}.XXXXXX")"
trap 'rm -f "$TMP"' EXIT

{
  echo "# ALL SPECS (auto-generated)"
  echo
  find "$SPECS_DIR" -name '*.md' -type f | LC_ALL=C sort | while read -r f; do
    echo "## ${f##*/}"
    cat "$f"
    echo
  done
} > "$TMP"

mv "$TMP" "$OUT"
trap - EXIT

Key points: - export LC_ALL=C at the top pins all subprocesses (not just the one sort), and LC_ALL=C sort on the find pipeline is belt-and-suspenders — it survives even if a caller overrides env after sourcing. - Atomic write via mktemp + mv so a crash mid-regeneration never leaves a half-merged artifact. - Commit the freshly regenerated ALL_SPECS_MERGED.md in C order once, so the committed bytes equal what CI regenerates.

CI freshness/determinism gate (pins collation and enforces it):

  freshness:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Regenerate under pinned collation
        run: ./merge_specs.sh
      - name: Freshness (committed doc must be up to date)
        run: |
          git diff --exit-code -- ALL_SPECS_MERGED.md \
            || { echo "ALL_SPECS_MERGED.md is stale — rerun ./merge_specs.sh"; exit 1; }
      - name: Determinism (two-run md5 equality)
        run: |
          cp ALL_SPECS_MERGED.md /tmp/expected.md
          ./merge_specs.sh
          test "$(md5sum < ALL_SPECS_MERGED.md)" = "$(md5sum < /tmp/expected.md)"

For the Go repo, a test can enforce the same invariants in-process:

// merge_specs_test.go
func TestMergedSpecsDeterministicAndFresh(t *testing.T) {
    one := runMerge(t)
    two := runMerge(t)
    if md5.Sum(one) != md5.Sum(two) {
        t.Fatal("ALL_SPECS_MERGED.md is nondeterministic across runs")
    }
}

func runMerge(t *testing.T) []byte {
    t.Helper()
    cmd := exec.Command("./merge_specs.sh", "specs", "ALL_SPECS_MERGED.md")
    cmd.Env = append(os.Environ(), "LC_ALL=C") // pin even if parent env is foreign
    if out, err := cmd.CombinedOutput(); err != nil {
        t.Fatalf("merge_specs.sh: %v\n%s", err, out)
    }
    b, err := os.ReadFile("ALL_SPECS_MERGED.md")
    if err != nil {
        t.Fatal(err)
    }
    return b
}

Evidence & signatures

Empirically reproduced and verified on this machine (fixtures in `/tmp/specdemo` with 12 collision-prone spec names: `API/api`, `README/readme`, `Zebra/zebra`, `spec-1/-2/1`, `spec_10`, `éclair`, `zeta`):

**1. Bug reproduced — locale changes the merged output.**
```
LC_ALL=C find specs | sort      → specs/API.md, README.md, Zebra.md, api.md, readme.md, spec-1.md, spec-2.md, spec1.md, spec_10.md, zebra.md, zeta.md, éclair.md
LC_ALL=en_US.UTF-8 find | sort  → specs/api.md, API.md, éclair.md, readme.md, README.md, spec_10.md, spec-1.md, spec1.md, spec-2.md, zebra.md, Zebra.md, zeta.md
```
Same 12 files, two orders. C is byte-wise (uppercase first, `-` before `1` before `_`); UTF-8 is dictionary collation (case-insensitive, `_` before `-`). The buggy script produced:
```
md5(C)     = 0faaabcab28376ee6bb52a7e10e04b4a
md5(UTF-8) = 28b63847ccb70e4274107b16e1e99395
BUG CONFIRMED: outputs differ
```
That ordering delta is what surfaced as the 17k-line reorder in `ALL_SPECS_MERGED.md`.

**2. Fix verified — determinism across locales and runs (3 runs, alternating C / en_US.UTF-8):**
```
run1 0faaabcab28376ee6bb52a7e10e04b4a   (LC_ALL=C)
run2 0faaabcab28376ee6bb52a7e10e04b4a   (LC_ALL=en_US.UTF-8)
run3 0faaabcab28376ee6bb52a7e10e04b4a   (LC_ALL=C)
PASS: 3-run md5 deterministic across C and en_US.UTF-8
```

**3. Regeneration matches committed C-order artifact** (freshness gate):
```
PASS: regenerated doc matches C-locale ordering
PASS: freshness gate clean (committed == regenerated under foreign locale)
```

Edge cases covered: mixed case (`API` vs `api`), hyphen vs underscore (`spec-2` vs `spec_10`), leading-digit numeric strings (`spec-1`, `spec1`), and non-ASCII (`éclair` — in UTF-8 it interleaves with `readme`/`README`, in C it sorts last by byte value; both handled deterministically). Note `LC_ALL` set as an env var (not just `LC_COLLATE`) because `LC_ALL` overrides all other locale categories — a caller with `LC_ALL` set would otherwise defeat a `LC_COLLATE=C`-only pin. If a repo also runs `go generate`, invoke the script through the same pinned env.
{"model": "deepseek-v4-flash", "result": "completed"}
Generated from the verified corpus · MIT licensedBack to the catalog