Skip to content

Go 1.27 modernization: crash fixes, dependency refresh, and architecture docs - #637

Open
christiangda wants to merge 18 commits into
mainfrom
chore/go-1.27-modernization
Open

christiangda wants to merge 18 commits into
mainfrom
chore/go-1.27-modernization

Conversation

@christiangda

Copy link
Copy Markdown
Contributor

Go 1.27 modernization: crash fixes, dependency refresh, and architecture docs

Target release: minor, v0.46.0.

Branch is up to date with main (225f2ef, no divergence). 15 commits, 77 files,
+4,556 / −779. Coverage 71.7% → 73.5%, test files 35 → 46.


⚠️ Read this first — operational impact

Two things change on the first run after deploying. Neither needs a config change, and both settle
after one run.

What Why Effect
One membership reconciliation pass The groupsMembers container hash changed (finding M23 below) SCIM PATCH traffic for memberships on the first run only
One larger-than-usual state-file diff Element ordering inside the state file is now deterministic Cosmetic; the S3 object is rewritten once

Groups and users do not re-sync. Their hashes are byte-identical to main — verified by the new
golden test. model.StateSchemaVersion is unchanged at 1.0.0, and the state-file JSON schema is
identical, so this is not a migration.

No breaking changes. Verified against main in a clean worktree:

Surface Result
pkg/aws exported API Additive only (+ErrConflictUnresolved)
pkg/google exported API No change
State-file JSON tags · StateSchemaVersion Identical
CLI flags + shorthands Identical
Config keys + IDPSCIM_* env vars Identical
Credential resolution path Unchanged (only %v%w on two error wraps)

🐛 Crash fixes — the reason this is worth merging

Three code paths panicked on data that Google Workspace and AWS IAM Identity Center legitimately
return. Because the state file is only written after a successful run, a panic meant the next
scheduled invocation re-read the same record and panicked again — so a single malformed directory
entry could block all synchronization indefinitely, with no self-recovery.

C1 — a SCIM user with no email crashed membership resolution.
internal/scim.GetGroupsMembers indexed user.Emails[0] unguarded. buildUser only keeps addresses
flagged primary, so a user with "emails": [] produced a nil slice.
pkg/aws/testdata/ListUserResponse_no_emails.json is a checked-in fixture of exactly that shape.

C2 — a Google user without a family name crashed hashing.
buildUser returns nil to reject records AWS SCIM would refuse, logging why. Three callers stored
that nil, and it reached SetHashCode, whose comparator dereferences every element. Now skip-and-warn,
matching what GetGroups already did for duplicate group names.

H4 — a persistent 409 could exhaust the stack.
CreateOrGetUser/CreateOrGetGroup cleared externalId and recursed with no limit. Now retry exactly
once — which is what their comments always claimed.

Red-test evidence

Every fix landed test-first. Failing output before each:

C2  panic: runtime error: invalid memory address or nil pointer dereference
      model.(*UsersResult).SetHashCode.func1   internal/model/user.go:581
      sort.Slice                               sort/slice.go:29

C1  panic: runtime error: index out of range [0] with length 0
      scim.(*Provider).GetGroupsMembers.func1  internal/scim/scim.go:519

C2  GetUsers() Items = 3, want 1
    GetUsersByGroupsMembers() Resources[0] is nil; rejected records must be skipped

H4  "51" is not less than or equal to "2"
      CreateOrGetUser issued 51 POSTs; the conflict retry must be bounded to 2

M23 GroupsMembersResult hash depends on input order
M17 State hash depends on input order

A3  goroutine header is missing "user", so a panic here could not be attributed to a record

A note on method: gob panics on a nil pointer element, so a nil-safe comparator alone would have
moved the panic from sort.Slice into MarshalBinary. SetHashCode therefore drops nils from the
hash input; slices with no nils are returned unchanged, keeping hashes byte-identical.


🧮 M23 — a hash the sync reads was order-sensitive

Found while writing the M17 test. GroupsMembersResult.SetHashCode sorted its outer slice but not the
members nested inside each entry, and GroupMembers.MarshalBinary walks those in slice order.

This hash is read, by internal/core/actions.go, to decide whether membership needs reconciling.
Two upstream sources reorder members:

  • the Google Directory API guarantees no member ordering;
  • internal/scim.GetGroupsMembers appends in goroutine completion order — non-deterministic by
    construction, every run.

So membership could be fully reconciled for no change. Fixed together with M17 (State.HashCode,
which sorted nothing but is write-only, so it had no runtime effect). All five orderings now live in
internal/model/sort.go and are called by every SetHashCode, so they cannot drift apart again.

Care taken that hashing never reorders the caller's data: copying a GroupMembers struct shares its
Resources backing array, so each entry gets its own copied member slice. A test pins this.


⬆️ Go 1.27

go.mod 1.26.51.27.0. No workflow changes needed — every job already resolves the toolchain
from go.mod. go fix ./... removed a redundant loop-variable re-declaration and adopted the new
struct-literal field selectors.

Every Go 1.27 behavioural change was checked; none affects this project:

Change Assessment
encoding/json backed by v2 v1 API still sorts map keys (verified empirically). State file has no map fields; hashing uses gob.
Timer channels always unbuffered No time.After/NewTimer/NewTicker/Tick anywhere.
Response.Body drains on Close Beneficial — better connection reuse. Nothing aborts a body early.
Stricter //go:linkname No linkname, no unsafe.
HTTP/2 server priorities Server-side only; this is a client.
SSL_CERT_FILE on macOS Local dev only; noted in docs/Development.md.

Deliberately not applied: the omitzero modernizer. omitempty and omitzero differ for false,
0 and empty-non-nil slices, and internal/model uses omitempty on ~40 state-file fields.


📦 Dependencies

All 8 direct dependencies with updates moved forward; no majors, no module-path changes.
aws-lambda-go v1.55.0, aws-sdk-go-v2 v1.45.1 (+ config, credentials, s3, secretsmanager),
testify v1.12.1, google.golang.org/api v0.295.0.

Verified: go mod verify, mocks regenerated byte-identical, govulncheck 0 called.


⚡ Concurrency

Three fan-outs shared a hand-rolled WaitGroup + channel-semaphore + buffered-error-channel shape
with two defects: no cancellation (one failure left siblings burning upstream API quota for discarded
results) and every error after the first silently dropped. errgroup was already a direct dependency
and already used this way in internal/scim.

pkg/google.ListGroupMembersBatch, internal/idp.GetUsersByGroupsMembers and
internal/setup.Secrets now use errgroup.WithContext with explicit limits. setup.Secrets also
takes a context.Context instead of calling context.Background() internally, so the four Secrets
Manager reads are cancellable on Lambda timeout.


🔎 Panics now name the record

Go 1.27 prints runtime/pprof labels in traceback headers:

goroutine 42 [running] {sync: "users", user: "zoe@example.com"}:

Implementation note worth reviewing: this uses pprof.SetGoroutineLabels, not pprof.Do.
pprof.Do defers restoring the previous label set, and on a panic those defers run during unwinding
before the runtime prints the traceback — so labels vanish exactly when needed. runtime.Stack
shows them under either API, so the broken version verifies fine by accident. A test guards it.

Cost measured: 28.65 ns / 104 B per goroutine. Opt out with GODEBUG=tracebacklabels=0.


🔧 Style, CI and two more bugs

Google Go style guide conformance: 22 sentinels fmt.Errorferrors.New; naked returns removed;
%v%w on 6 error wraps (internal/core depends on errors.AsType working); err.Error() != "EOF"
errors.Is(err, io.EOF) on 8 state-file decode sites; sort.Sliceslices.SortStableFunc; a
parameter shadowing the max builtin renamed; pkg/aws path constants actually used at all 18 call
sites via path.Join. buildCreateUserRequest/buildPutUserRequest de-duplicated (~150 near-identical
lines, differing only in ID), guarded by a characterization test written before the change.

Log levels fatal/panic now work. config.Validate always accepted them — logrus leftovers —
but the logger handled neither, so they fell through to info with a misleading "unknown log level"
warning. Now map to slog.LevelError. Chosen over dropping them, which would break a deployed config.

CI:

  • 🔴 Codecov has been receiving nothing. build.yml uploaded ./coverage.out; the Makefile writes
    build/coverage.txt. That path never existed in CI. Fixed and verified from a clean tree.
  • Added .golangci.yml and a Lint job. Baseline is now 0 issues, down from 18, against a
    stricter config. Two documented tunings: govet's shadow disabled (fires on idiomatic
    if err := f(); err != nil), and errcheck excludes Close on response bodies while bodyclose
    still enforces that bodies are closed.

idpscimcli aws users list --filter never worked. Registered on the aws users grouping command
(no RunE), so advertised where nothing consumed it and rejected as unknown flag where it was needed.

.gitignore was hiding the cmd/ source trees. Bare idpscim/idpscimcli patterns match at any
depth, so cmd/idpscim/ and cmd/idpscimcli/ were ignored. Tracked files were immune — but any new
file there was invisible to git status and would be silently dropped from a commit. Now anchored.
This also caused a wrong claim in an earlier commit on this branch, corrected here.

Test isolation: two tests called os.Remove(os.TempDir()), attempting to delete shared /tmp.
Three subtests in pkg/aws/config_test.go passed only because os.Setenv leaked credentials between
them — they asserted credentials came from the environment while their own setup pointed at a
credentials file. All now use t.Setenv/t.TempDir() and pass individually as well as in file order.


📚 Documentation

Three new guides with 10 mermaid diagrams, authored in markdown so they are reviewable in a diff:

  • docs/Architecture.md — system context, package topology, sync algorithm,
    reconciliation, hashing, state-file lifecycle, how SCIM membership is inferred, concurrency, failure modes.
  • docs/Implementation-Guide.md — the invariants, adding a synced
    attribute, testing conventions, state-file compatibility rules.
  • docs/User-Manual.md — deployment and operations with troubleshooting.

Also: every package comment expanded (internal/core's described internal/deepcopy);
.github/copilot-instructions.md rewritten (it claimed "Database: PostgreSQL" — there is no
database) and now carries the architecture invariants; State-File-example.md, Development.md,
Configuration.md, idpscim.md, idpscimcli.md and the README updated. 174 relative links verified.


✅ Verification

go fix no changes · gofmt clean · betteralign no changes · go vet clean ·
golangci-lint 0 issues · 11/11 packages green under -race -tags=unit ·
Lambda linux/arm64 -tags lambda.norpc cross-compile OK · govulncheck 0 called ·
FUZZ_TIME=60s make fuzz clean on both state-file targets · sam validate --lint passes ·
golden hashes verified (only the two intended changed).

🔜 Deliberately out of scope

  • Deprecated oauth2/google.CredentialsFromJSONWithParams (the only staticcheck finding) —
    discarded by the maintainer: credentials come from Secrets Manager and altering that path would
    be a breaking change. A TODO + documented //nolint keeps the deprecation visible and lint at 0.
  • Rewriting the three httptest-based test files onto httptest.NewTestServer + testing/synctest
    (~4,800 lines, no production benefit).

📋 Reviewer checklist

  • Accept the one-off membership reconciliation on first deploy (M23)
  • Confirm fatal/panicerror log-level mapping is the desired call
  • Manually verify the interactive stscreds.StdinTokenProvider MFA path — CI cannot drive stdin
  • Confirm release type: minor, v0.46.0

christiangda and others added 15 commits August 31, 2026 13:54
Bump the go directive from 1.26.5 to 1.27.0 and apply the Go 1.27
modernizers via `go fix ./...`:

* forvar: drop the redundant `user := user` re-declaration in
  internal/scim.GetGroupsMembers. Per-iteration loop variables have been
  the semantics since Go 1.22, so the line was dead.
* embedlit: adopt Go 1.27's struct-literal field selectors, collapsing
  `ListResponse: aws.ListResponse{...}` into the promoted fields in two
  test composite literals. Semantically identical.

Also ignore the local-only .plan/ tracking folder.

No workflow changes are needed: every GitHub Actions job already resolves
the toolchain via `go-version-file: ./go.mod`, and the README Go badge is
derived from go.mod too.

Verified against the Go 1.27 behavioral changes; none affect this project:

* encoding/json is now backed by v2, but the v1 API still sorts map keys
  (verified empirically). The only marshalled map is the SCIM patch value
  in internal/scim, and the state file contains no map fields.
* Timer channels are now always unbuffered; the tree has no time.After,
  time.NewTimer, time.NewTicker or time.Tick.
* http.Response.Body now drains on Close for HTTP/1, which only improves
  connection reuse in pkg/aws; nothing aborts a body early.
* //go:linkname validation is stricter, but there is no linkname and no
  unsafe import anywhere.

Deliberately not applying the omitzero modernizer: omitempty and omitzero
differ for false, 0 and empty-non-nil slices, and internal/model uses
omitempty on ~40 fields that are serialised into the S3 state file.

gofmt, go vet, go build and the full -race test suite are clean, and
golangci-lint reports the same 18 pre-existing issues as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
Runs `make go-mod-update`. All 8 direct dependencies that had updates
available moved forward; no major versions and no module-path changes.

Direct:

* github.com/aws/aws-lambda-go                      v1.54.0   -> v1.55.0
* github.com/aws/aws-sdk-go-v2                      v1.43.4   -> v1.45.1
* github.com/aws/aws-sdk-go-v2/config               v1.32.35  -> v1.33.1
* github.com/aws/aws-sdk-go-v2/credentials          v1.19.34  -> v1.20.1
* github.com/aws/aws-sdk-go-v2/service/s3           v1.107.0  -> v1.109.1
* github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.44.4 -> v1.46.1
* github.com/stretchr/testify                       v1.11.1   -> v1.12.1
* google.golang.org/api                             v0.292.0  -> v0.294.0

The remaining 8 direct dependencies were already current.

Notable indirect movement: cloud.google.com/go/auth v0.22.0 -> v0.23.2,
smithy-go v1.27.7 -> v1.28.1, and the golang.org/x/* set. testify v1.12
dropped its go-spew and go-difflib requirements, so both leave go.mod.

Verification:

* go mod verify: all modules verified
* make go-generate: mocks regenerated, byte-identical (no consumed
  interface changed shape)
* go build, go vet, gofmt: clean
* go test -race: all 10 packages with tests pass
* golangci-lint: same 18 pre-existing issues, no new findings
* govulncheck: 0 vulnerabilities called; 1 in a required-but-unreached module
* FUZZ_TIME=20s make fuzz: both state-file parsing targets pass
  (4.7M and 3.2M execs, no crashers)
* pkg/aws credential-resolution tests pass, including the AWS_PROFILE and
  shared-config-file paths

Still worth a manual check before release: the interactive
stscreds.StdinTokenProvider MFA path in pkg/aws.NewDefaultConf, which CI
cannot exercise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
Each of these panicked on data the upstream APIs legitimately return.
Because the S3 state file is only written after a successful sync, a panic
means the next scheduled run re-reads the same record and panics again, so
a single malformed user could block all synchronisation indefinitely.

Every fix landed test-first; the failing output for each is in .plan.

1. internal/scim.GetGroupsMembers indexed user.Emails[0] unguarded.
   buildUser only keeps addresses flagged primary, so an AWS SCIM user with
   "emails": [] — or with addresses but none primary — produced a nil slice
   and "index out of range [0] with length 0".
   pkg/aws/testdata/ListUserResponse_no_emails.json is a checked-in fixture
   of exactly that shape.

   Replaced with a memberEmail helper that prefers the primary address and
   falls back to the first one, so no existing expectation changes: pure
   GetPrimaryEmailAddress() would have silently dropped emails for values
   where Primary is not set.

2. idp.buildUser and scim.buildUser return nil to reject records the AWS
   SCIM API would refuse (missing name, given name, family name, primary
   email, or SCIM id), logging the reason as they do. Three callers stored
   that nil instead of honouring it:

     * internal/idp.GetUsers
     * internal/idp.GetUsersByGroupsMembers
     * internal/scim.GetUsers

   A nil element then reached *Result.SetHashCode, whose sort comparator
   dereferences it. Verified: "invalid memory address or nil pointer
   dereference" at internal/model/user.go:581. A Google Workspace account
   without a family name was enough to trigger it.

   All three now skip and warn, matching what buildUser already does and
   what GetGroups already does for duplicate group names, so one bad
   directory record cannot block every other user.

3. Defence in depth in internal/model: SetHashCode now drops nil elements
   from the hash input via compactNilPointers. A nil-safe comparator alone
   would not have been enough — gob panics outright on a nil pointer
   ("gob: cannot encode nil pointer of type ..."), so the panic would simply
   have moved from sort.Slice into MarshalBinary. Slices with no nil
   elements are returned unchanged, keeping hashes byte-identical to
   previous releases.

4. pkg/aws.CreateOrGetUser and CreateOrGetGroup called themselves after
   clearing ExternalID, with no attempt limit. An endpoint that keeps
   answering 409 while the follow-up lookup keeps returning nothing would
   recurse until the stack was exhausted; the red test measured 51 POSTs
   before its own tracker cut it off. Both now retry exactly once — which is
   what their comments always claimed — and return ErrConflictUnresolved
   afterwards.

gofmt, go vet, go build clean; full -race suite passes; golangci-lint
reports the same 18 pre-existing issues and no new ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
Three concurrent fan-outs used the same hand-rolled shape: sync.WaitGroup,
a channel semaphore, and a buffered error channel drained once at the end.
That shape has two defects. There is no cancellation, so when one request
fails every sibling still runs to completion, spending upstream API quota on
results the caller will discard. And only the first error off the channel is
ever read; the rest are silently dropped.

errgroup was already a direct dependency and already used correctly in
internal/scim.GetGroupsMembers, so this brings the remaining call sites onto
the idiom the codebase had already settled on.

* pkg/google.ListGroupMembersBatch — errgroup.WithContext with
  SetLimit(listGroupMembersBatchConcurrency). The former inline
  `const maxConcurrent = 10` is now a documented package constant.
  Red test measured 60/60 groups still requested after the first failure.

* internal/idp.GetUsersByGroupsMembers — same treatment, with
  getUsersConcurrency as the documented cap. Red test confirmed no in-flight
  GetUser call ever observed a cancelled context.

* internal/setup.Secrets — now takes a context.Context instead of calling
  context.Background() twice internally, so the four Secrets Manager reads
  are cancellable. Previously a Lambda approaching its deadline could not
  abandon them; they ran until the runtime froze the environment.

  The concurrent read moved into an unexported fetchSecrets that accepts a
  secretsFetcher interface. internal/setup was the only package in the tree
  with no tests at all, despite being the package that wires everything
  together; it now has four, covering field population, error propagation,
  an already-cancelled context, and sibling cancellation.

  cmd/idpscim passes rootCmd.Context() (cobra has set it by the time
  OnInitialize runs), with a defensive Background fallback so no nil context
  can reach errgroup.

Also makes GetUsersByGroupsMembers deterministic. Its result was assembled
by ranging a map, so the order changed run to run. UsersResult.HashCode is
order-insensitive — SetHashCode sorts a copy — so this was never a
correctness bug, but the state file written to S3 preserves slice order, so
every single sync produced a large spurious diff against the previous
object. The result is now sorted by UserName (the user's primary email, with
IPID as a tiebreak), which makes the S3 artifact reviewable.

The first sync after deploying this will therefore write one reordering
diff. Subsequent syncs are stable.

Two existing idp test cases matched GetUser's context argument by exact
value (gomock.Eq on context.Background()). The fan-out now runs under an
errgroup-derived child context, so those matchers were over-specified;
relaxed to gomock.Any(), which is what the rest of the file already used.

gofmt, go vet, go build clean; full -race suite passes; golangci-lint
reports the same 18 pre-existing issues and no new ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
Implements D-6 (M6) and D-18 (M7), the two approved changes that touch hash
computation and state-file decoding. Both were gated on proving the hashes
do not move, so a golden fixture lands first.

Golden proof (internal/model/golden_hash_test.go)

Builds a fixed, fully-populated State and pins all ten hash codes it
produces — the State, the three containers, and each individual resource.
Coverage is deliberate: every optional pointer field is populated in one
user and left nil in another (the nil cases are what exercise the EOF
branches), two groups share an identical hash code so the ordering is
exercised where the sort key is ambiguous, and resources are supplied out of
order so the sort actually does work. It also round-trips the state through
gob and through JSON, asserting the hash survives both and that JSON
serialisation is stable.

The ten golden values were captured before these changes and are unchanged
after them.

D-6 — slices.SortStableFunc

Replaces all six sort.Slice calls in internal/model. SortStableFunc, not
SortFunc: three of these sort by HashCode and one by IPID, none of which is
guaranteed unique across resources. sort.Slice is unstable, so the order of
tied elements was unspecified — and since the container hash is computed
over the sorted slice, the hash of a set containing ties was itself
unspecified. This makes it deterministic. Also converts the sort in
GetPrimaryEmailAddress.

D-18 — errors.Is(err, io.EOF)

Replaces eight err.Error() != "EOF" string comparisons across user.go,
member.go and state.go. gob signals "no further value was encoded" with
io.EOF, which is the expected outcome for a value whose optional pointer
field was nil at encode time. The string comparison missed wrapped errors
and io.ErrUnexpectedEOF, so a genuinely truncated state file could be
reported as a decode failure while a wrapped EOF was mistaken for one.

Verification: all ten golden hashes unchanged; full -race suite green;
FUZZ_TIME=45s make fuzz clean over both state-file parsing targets
(3.4M execs on FuzzDiskRepositoryGetState, no crashers).

New finding recorded while writing the golden fixture — M17

State.SetHashCode is order-dependent, unlike the three container
SetHashCode methods. It rebuilds each *Result via a builder (which sorts
only its own private copy, for its own hash) and then gob-encodes copyState,
whose MarshalBinary walks Resources in slice order. Two runs over identical
data can therefore produce different State.HashCode values.

This is cosmetic today: State.HashCode is written into the S3 object but
never read back — internal/core/actions.go compares only the three container
hashes. It does make the state file's top-level hashCode field useless as an
external change indicator.

Deliberately not fixed here, since it was not part of the approved scope and
fixing it would change that field's value for every deployed state file.
TestState_hashCodeIsOrderDependent documents the behaviour and will skip
with an explanatory message if a future change fixes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
Conformance pass against google.github.io/styleguide/go. No behaviour
changes except M16, which is called out below. Full -race suite green
throughout, and the internal/model golden hashes are untouched.

M1 — no naked returns in non-trivial functions
  internal/core/reconciling.go: all three reconciling* functions returned
  bare `return` from ~50-line bodies with four early exits each, and named
  their error result `e`. Now they declare locals and return explicitly.
  internal/model/operations.go: the same, plus the guard clauses no longer
  assign to every named result before returning
  (`create, update, equal, remove, err = nil, nil, nil, nil, Err…`), and the
  three Merge* helpers plus UpdateGroupsMembersSCIMID drop their unused
  named results.

M2 — errors.New for constant sentinel messages
  Twenty-two sentinels declared with fmt.Errorf and no format verbs, across
  internal/core/reconciling.go, internal/config/config.go, pkg/google and
  internal/scim. The rest of the codebase already used errors.New, so this
  was inconsistency rather than preference. Two inline
  fmt.Errorf("scim: … is nil") values in internal/scim became the exported
  ErrGroupsResultNil and ErrUsersResultNil.

M3 — WithMaxMembersPerRequest(max int) shadowed the max builtin, in a file
  that uses the min builtin forty lines further down. Renamed to n.

M4 — %w instead of %v when wrapping errors
  pkg/google (NewService ×2, GetUser, GetGroup) and pkg/aws/secretsmanager
  (×2) formatted errors with %v, severing the chain. This matters here
  because internal/core relies on errors.AsType to classify
  *types.NoSuchKey and *repository.ErrStateFileEmpty — the codebase already
  depends on wrapping working.

M8 — for range n over a count, in the five UnmarshalBinary loops in
  internal/model. go fix's rangeint correctly declined these because the
  bodies mutate the receiver; the index was never used.

M9 — removed a dead `if len(chunks) > 0` guard in
  internal/scim.patchGroupOperations. Every branch above it appends at
  least one element.

M10 — one logger implementation
  cmd/idpscimcli carried a 35-line copy of internal/setup.Logger's level and
  format switches. It now calls setup.Logger, so the two binaries cannot
  drift apart in log behaviour.

M12 — pkg/aws.NewDefaultConf was the only exported function in the tree
  with no doc comment, and its named results were never referenced. Both
  fixed; the doc comment records why stscreds.StdinTokenProvider is wired in
  for the AWS_PROFILE path and not for Lambda.

M13 — HTTPResponseError had no doc comment and its field comments said
  "Datahub error code", copy-pasted from an unrelated project. Documented
  properly, including that it is part of the package's error contract:
  callers recover it with errors.AsType and branch on StatusCode.

M14 — the SCIM PatchOp schema URN was inlined twice; now the patchOpSchema
  constant, with an RFC 7644 reference.

M15 — pkg/aws declared UsersPath, GroupsPath and ServiceProviderConfigPath
  but bypassed them at 15 of 18 call sites, using raw "/Users" literals and
  fmt.Sprintf("/Users/%s", id). All now go through the constants and
  path.Join, which also means an id containing a slash can no longer forge
  extra path segments.

M16 — log levels fatal and panic now work (behaviour change)
  config.Validate has always accepted "fatal" and "panic" — logrus levels
  that predate the move to log/slog — but setup.Logger handled neither, so
  they fell through its default branch, logged a misleading "unknown log
  level" warning, and silently ran at info. They now map to
  slog.LevelError, the closest slog equivalent, so a deployment configured
  with either gets the severity it asked for.

  Chose this over dropping them from the valid set, which would have turned
  a working deployment's config into a startup failure.

  The idpscim --log-level help text also advertised a "trace" level that was
  never valid at all; corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
… tests

M11 — one field mapping for both user requests

buildCreateUserRequest and buildPutUserRequest were ~150 lines of
near-identical mapping, differing only in that Put sets ID. Adding a newly
synced SCIM attribute meant editing both, and forgetting one would make
creates and updates disagree with nothing to catch it.

aws.CreateUserRequest and aws.PutUserRequest are both declared as
`type X User`, so a single buildAWSUser serves both via conversion. Only the
put sets ID, because only a put targets an existing resource.
internal/scim/helpers.go drops from 307 to 256 lines.

Guarded by a characterisation test written before the change and unchanged
after it. A pure refactor has no bug to make fail first, so instead of
manufacturing a red the test pins the existing contract: both requests must
be field-for-field identical apart from ID, only the primary email is
forwarded, nil in gives nil out, and the marshalled wire payload still
carries every attribute AWS requires including the enterprise extension. It
passed before the refactor and passes after.

M5 — errcheck: 17 findings down to 3

The three that remain are `defer resp.Body.Close()` in pkg/aws/scim.go and
internal/repository/s3.go, where the Close error is not actionable — and
under Go 1.27 even less so, since Close now drains the body. Notably errcheck
flags only 3 of the 16 identical call sites in scim.go, which is its own
argument for the committed .golangci.yml that finding R2 asks for; they are
handled there rather than by scattering `_ =` across otherwise idiomatic Go.

The 14 test findings are fixed properly, and doing so uncovered two real
problems:

1. Two tests did `tmpDir := os.TempDir()` followed by
   `defer os.Remove(tmpDir)` — an attempt to delete the shared system temp
   directory. It failed silently only because /tmp is never empty. Both now
   use t.TempDir(), which creates a per-test directory and removes it during
   cleanup.

2. Switching pkg/aws/config_test.go from os.Setenv to t.Setenv exposed three
   subtests that were passing for the wrong reason.

   os.Setenv never cleaned up, so the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
   and AWS_REGION set by "using access key from env vars" leaked into every
   subtest after it. Three of those subtests therefore asserted
   cred.Source == "EnvConfigCredentials" and AWS_REGION == "us-east-1" while
   their own setup pointed exclusively at a shared credentials file — and
   their names said so ("using credential file", "using credential and config
   file and profile default").

   The key and secret assertions never caught the discrepancy because
   testdata/default/credentials holds exactly the same values the env-var
   subtest uses; only cred.Source differed, and it was reading leaked state.

   Each now asserts what its own setup actually produces:
   SharedConfigCredentials with the corresponding path, and an empty region
   where no config file is supplied. The AWS_PROFILE variant already expected
   the correct source, which is what made the inconsistency findable.

   Verified both ways: the whole file passes in order, and all five subtests
   pass individually, so they no longer depend on execution order.

   Also noted in a comment that "using profile from env vars" points
   AWS_CONFIG_FILE at testdata/case1/config, which does not exist. Left as-is
   — a missing config file is worth covering and is what the empty region
   asserts — but the name looks like a leftover and is worth a look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
R2 — add .golangci.yml

The contributor checklist tells everyone to run `golangci-lint run ./...`,
but with no committed config each contributor and CI runner used whatever
defaults their own version shipped, so results were not reproducible.

The config starts from the standard set and adds bodyclose, copyloopvar,
errorlint, misspell, nilerr, revive, unconvert, usestdlibvars, wastedassign
and whitespace, with goimports grouping local imports under the module path.

Two deliberate tunings, both documented in the file:

  * govet's shadow check is disabled. It is off in govet's own default set for
    good reason — idiomatic `if err := f(); err != nil` shadows any outer err
    and trips it every time. All five hits in this tree were exactly that,
    in tests.
  * errcheck excludes (io.Closer).Close and (io.ReadCloser).Close. The Close
    error on an HTTP response body is not actionable, and under Go 1.27 Close
    also drains the body, so not checking it cannot leak a connection.
    Excluded rather than papered over with `_ =` at 17 sites. bodyclose stays
    enabled and still enforces that bodies ARE closed, which is the part that
    matters. (Notably errcheck flagged only 3 of the 16 identical call sites
    in pkg/aws/scim.go — its inconsistency here is itself an argument for
    pinning the config.)

Generated mocks and test files carry narrow, commented exclusions.

R6 — add a Lint job to build.yml

Runs golangci-lint v2.13.1 against the committed config. The action is
SHA-pinned to v9.3.0 (ba0d7d2), consistent with the repo's existing pinning
convention for OpenSSF Scorecard.

R3 — codecov has been receiving nothing

build.yml uploaded `./coverage.out`, but the Makefile writes coverage to
`$(BUILD_DIR)/coverage.txt`. That path never existed in CI, so every run
uploaded nothing and the README badge has been reporting stale data. Now
points at ./build/coverage.txt, verified by running `make test` from a clean
tree and confirming the file appears there.

R4 — documented that -tags=unit is a no-op

No file in the tree carries a //go:build tag, so PROJECT_COVERAGE_TAGS
selects nothing. Left in place as the hook for a future unit/integration
split, but the Makefile now says so, rather than implying a split that does
not exist.

Result: golangci-lint reports 0 issues, down from 18 at baseline against a
stricter configuration.

Getting there also closed 21 revive findings — exported symbols with no doc
comment, which the Google style guide requires. Documented model.PhoneNumber,
model.Manager, model.EnterpriseData, the six ErrorMessage/ErrorCode methods on
the repository error types, the four Validate methods and three types in
pkg/aws/scim_model.go, pkg/google.DirectoryServiceConfig, and split
pkg/aws's combined path/content-type const block so each constant carries its
own comment. Also fixed an empty `// ErrEmailsEmpty` stub comment.

The one remaining staticcheck finding, SA1019 on the deprecated
oauth2/google.CredentialsFromJSONWithParams, carries a documented nolint
alongside a TODO explaining the migration target and why it is deliberately
deferred to its own change: it is the authentication path of a production
Lambda and needs separate tests plus a manual run against a real Google
Workspace tenant. Tracked as finding D1.

Verified the internal/model golden hashes are still unchanged after the whole
style and lint pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
…ariants

.github/copilot-instructions.md is the single source of truth for every
assistant — CLAUDE.md, AGENTS.md, .cursorrules, .clinerules and
DEVELOPMENT_GUIDELINES.md are all symlinks to it. All five verified to still
resolve.

Two factual errors removed:

  * "Database: PostgreSQL" — there is no database anywhere in this project.
    State is a single JSON object in S3 (internal/repository/s3.go), with a
    disk-backed alternative for local runs. This actively misled agents into
    looking for, or proposing, a data layer.
  * "Go 1.26+" — now 1.27+.

The document was otherwise a generic Go style checklist that said nothing about
the things an agent will actually get wrong here. Added:

  * What the project is, and that it mutates real production identity data, so
    correctness outranks elegance.
  * The real stack, replacing the unhelpful "Framework: Go standard library".
  * **Architecture invariants** — the most valuable addition. Never construct a
    model.* value directly (Build() is what sets Items and HashCode); the
    hand-written MarshalBinary methods define the gob hash input, so changing
    field order invalidates every deployed state file; SCIMID is excluded from
    hashes on purpose; sync compares container hashes, not the State hash, and
    those must stay order-independent; schema changes require bumping
    StateSchemaVersion; interfaces are declared by the consumer; internal/core
    must not import concrete AWS or Google types; a nil from buildUser means
    "skip and warn", never "store the nil".
  * Repo-specific style rules distilled from this pass: %w not %v, no naked
    returns, errgroup over hand-rolled fan-outs, context flows from cmd/, never
    log secrets, SortStableFunc when the key feeds a hash.
  * Testing requirements, including red-test-first for fixes and refactors —
    with the explicit carve-out that a pure refactor gets a characterization
    test rather than a manufactured red — plus t.Setenv/t.TempDir over
    os.Setenv/os.TempDir, mock regeneration, fuzzing, and keeping
    golden_hash_test.go green.
  * The post-change gate, now including golangci-lint reporting 0 issues.
  * Planning and tracking: .plan/ with a live PROGRESS.md, and behaviour-
    changing decisions recorded and approved before implementation.
  * Documentation requirements, including mermaid diagrams over binary exports
    so they are reviewable in a diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
…iagrams

The docs/ folder was entirely operational — how to deploy and configure — with
nothing explaining how the system is built. The only diagrams were a .drawio.png
and a .drawio.html, neither reviewable in a diff.

Three new guides, 10 mermaid diagrams, all verified against the code rather than
written from the README's description.

docs/Architecture.md
  System context; package topology (with the interface-ownership table showing
  that internal/core declares its own dependencies and imports no AWS or Google
  types); the first-run-vs-state-run branch and why both exist; the
  create/update/equal/remove reconciliation model and the matching keys that
  make it safe across two systems assigning their own identifiers; the hashing
  scheme, what it excludes and why; the state-file lifecycle; how SCIM group
  membership has to be inferred by inverting the query, since AWS never
  populates the members array; every concurrency limit; and a failure-mode
  table.

docs/Implementation-Guide.md
  Repository layout, the five invariants that are easy to break by accident, a
  9-step flow for adding a synced user attribute (the change most likely to be
  done half-right), the mock workflow, testing conventions, state-file
  compatibility rules split by whether a change is safe / forces a re-sync /
  needs a schema bump, and the local gate.

docs/User-Manual.md
  End-to-end operator guide: prerequisites, Google Workspace service-account and
  domain-wide delegation setup, IAM Identity Center SCIM enablement, all four
  deployment options, read-only verification with idpscimcli before letting the
  sync write anything, what to watch in CloudWatch, a troubleshooting table, and
  upgrade/rollback including that deleting the state file is the standard
  recovery.

Updated:

* Every package comment expanded. internal/core/docs.go described utilities for
  deep copying data structures — which is internal/deepcopy, not core.
* docs/State-File-example.md now explains what participates in a hash and why
  SCIMID does not, that the MarshalBinary methods are effectively a wire format,
  and that deleting the file is safe because the next run adopts the existing
  SCIM population rather than recreating it.
* docs/Development.md: Go 1.27 prerequisite, the full local gate including
  golangci-lint at a 0-issue baseline, the additional per-area checks, the
  dependency-update flow, a warning that internal/model changes must keep
  golden_hash_test.go green, and a macOS note about Go 1.27 honouring
  SSL_CERT_FILE / SSL_CERT_DIR.
* docs/Configuration.md: documents the accepted log levels, including that
  fatal and panic are now aliases for error rather than silently degrading to
  info.
* docs/Whats-New.md: full entry for this work with motivation, impact and the
  target version, flagging the one-off state-file reordering diff.
* README.md: Quick Start, an inline mermaid architecture diagram, an
  Architecture-at-a-Glance section, a Contributing section, consistent emoji
  headings, and a documentation table covering all 14 docs instead of 5.

Verified: 173 relative markdown links across README and docs all resolve; all
code fences balanced; mermaid blocks checked for the two constructs that break
the parser (escaped quotes in labels, raw angle brackets in sequence-diagram
text) and cleaned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
…ding cmd/

Follow-up round from finishing the idpscim/idpscimcli documentation audit I had
flagged as shallow. It turned up two real bugs and a correction to something
this branch had already committed.

1. `idpscimcli aws users list --filter` never worked

   The flag was registered on awsUsersCmd — the `aws users` grouping command,
   which has no RunE. Cobra's Flags() are local to the command they are set on,
   so the flag was advertised by `idpscimcli aws users --help`, where nothing
   consumes it, and rejected by `idpscimcli aws users list --filter …` with
   "unknown flag: --filter". `aws groups list` registered it correctly on the
   leaf command all along.

   Moved to awsUsersListCmd, and corrected the example in the help text from a
   groups-shaped filter (`displayName eq …`) to a users-shaped one
   (`userName eq …`).

   Red test first, in cmd/idpscimcli/cmd/flags_test.go: both leaf list commands
   must accept `--filter` with shorthand `-q`, and neither grouping command may
   advertise it while it has no run function.

2. .gitignore was hiding the cmd/ source trees

   The entries `idpscim` and `idpscimcli` were meant for stray binaries built
   into the repository root. But a gitignore pattern containing no slash matches
   at *any* depth, so both also matched the cmd/idpscim/ and cmd/idpscimcli/
   **source directories**.

   Already-tracked files are never affected by .gitignore, which is why the
   repository works and nobody noticed. The damage was to anything new: a file
   added under either directory was invisible to `git status` and would be
   silently omitted from a commit. Every ignore-aware tool — ripgrep, editors,
   Docker build contexts, some scanners — skipped both trees entirely.

   Both patterns are now anchored as `/idpscim` and `/idpscimcli`. Verified: a
   probe file under cmd/idpscim/ is now visible to `git status`, and a binary at
   the repository root is still ignored.

3. Correction: -tags=unit is NOT a no-op

   Commit 40b36a2 in this branch added a Makefile comment claiming no file in
   the tree carries a //go:build tag and that `-tags=unit` therefore selects
   nothing. Both claims were wrong. cmd/idpscimcli/cmd/common_test.go is tagged
   `unit`, so a bare `go test ./...` reports "no test files" for that package
   while `make test` runs it.

   The root cause of the mistake is item 2: the shell's grep is an
   ignore-file-aware wrapper, so the survey that produced the claim could not
   see into cmd/ at all. Re-ran that audit with the real grep once the ignore
   bug was understood; nothing else was missed.

   The Makefile comment now describes the actual behaviour and points at the
   go.buildTags setting that keeps editors consistent with it.

4. Documentation audit, properly this time

   The earlier pass had only compared flag *names* between the docs and the
   code. Now compared against real `--help` output:

   * Defaults column added to every flag table in idpscim.md and idpscimcli.md,
     matching the binary exactly.
   * `--log-level` values documented, including that fatal and panic are aliases
     for error, cross-linked to Configuration.md.
   * Valid `--sync-user-fields` values listed, with a note that narrowing the set
     also narrows what is fetched from Google and causes a one-off update of
     every user.
   * AWS filter flags documented for both list commands, with a
     `users list --filter` example that now actually runs.
   * `--output-format`, `--timeout` and the flags with no shorthand marked as
     such.
   * Example paths no longer put the service-account JSON in the working
     directory; added a caution that the default `credentials.json` resolves
     there and that production resolves the JSON from Secrets Manager instead.

Also includes the requested .vscode/settings.json change: go.buildTags set to
"integration,unit" so gopls and the editor's test discovery see the tagged files
that `make test` runs.

Verified: go fix reports no changes; gofmt, go vet clean; golangci-lint 0
issues; full -race suite green with -tags=unit; internal/model golden hashes
unchanged; 174 relative markdown links resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
ddcb5bc bumped google.golang.org/api from v0.294.0 to v0.295.0 after the
Whats-New entry was written. The entry now matches go.mod.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
Findings M17 and M23. One root cause, one fix, but they differ sharply in
consequence: M23 changes a hash the sync reads, M17 does not.

The problem

Every hash here is a gob encoding, and the hand-written MarshalBinary methods
walk their Resources slices in slice order. Arrival order is therefore part of
the hash unless normalised first — and it must be normalised, because the
identity-provider fan-outs and the SCIM membership inversion all build their
slices from maps.

The normalisation was inlined per SetHashCode method, and two had drifted.

M23 — GroupsMembersResult (this hash IS read)

It sorted its outer slice but not the members nested inside each entry.
GroupMembers.MarshalBinary walks its own members in slice order, so member order
*within* a group reached the bytes of the enclosing hash, while GroupMembers' own
hash stayed stable because that method does sort its copy.

internal/core/actions.go compares GroupsMembersResult hashes to decide whether
membership needs reconciling, so a reordering upstream produced a spurious full
membership sync — real SCIM PATCH traffic for no change. Two sources reorder:
the Google Directory API guarantees no member ordering, and
internal/scim.GetGroupsMembers appends members in goroutine completion order,
which is non-deterministic by construction.

Demonstrated before the fix: identical data, reversed members within one group,
GroupsMembersResult hash ba6fa20d… vs 0ac789a7….

M17 — State (write-only)

State.SetHashCode sorted nothing. It rebuilt each *Result through a builder and
gob-encoded the result; Build() sorts only its own private copy to compute that
container's HashCode field, so the sort never reached the State hash bytes.

Nothing reads State.HashCode — verified there is no read anywhere — so this had
no runtime effect. It did make the state file's top-level hashCode field useless
as an external change indicator, which is a trap worth closing.

The fix

The five orderings now live in internal/model/sort.go and are called by every
SetHashCode, so they cannot drift apart again. GroupsMembersResult additionally
normalises the members nested in each entry, and State normalises all three of
its rebuilt slices.

Computing a hash must not reorder the caller's live data. deepcopy.SliceOfPointers
copies each *GroupMembers struct, but a struct copy shares the backing array of
its Resources slice — so sorting those members in place would still mutate the
caller. Each entry now gets its own copied member slice.
TestSetHashCode_doesNotMutateCallerOrdering pins this.

Impact

* groupsMembers golden hash changed: 6c91be62… -> f391383e…
  The first run after deploying performs one membership reconciliation pass.
* state golden hash changed: 841819f1… -> e7a1cd95…
  No runtime effect; the field is never read.
* groups, users, and every individual resource hash are UNCHANGED, so groups and
  users do not re-sync.

MembersResult deliberately still orders by IPID rather than email; switching it
would change every MembersResult hash for no benefit.

Tests

Red first. TestGoldenHashes_containerHashesAreOrderIndependent was strengthened
to reverse the members inside each group as well as the outer slices, which is
what exposed M23 from the existing golden fixture; it failed with
"GroupsMembersResult hash depends on input order". TestState_hashCodeIsOrderDependent,
which previously documented M17 as accepted behaviour, is replaced by
TestGoldenHashes_stateHashIsOrderIndependent asserting the opposite. Added
TestGroupsMembersResult_hashIgnoresWithinGroupMemberOrder covering all three
hashes at once.

Verified: go fix no changes; gofmt, go vet clean; golangci-lint 0 issues; full
-race suite green; FUZZ_TIME=30s make fuzz clean on both state-file targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
Finding A3, enabled by Go 1.27.

Go 1.27 prints runtime/pprof goroutine labels in traceback headers, so a panic
in CloudWatch reads:

    goroutine 42 [running] {sync: "users", user: "zoe@example.com"}:

instead of leaving an operator to bisect the directory. The C1/C2 crashes fixed
earlier in this branch were exactly that shape — an index-out-of-range inside a
bounded fan-out worker, with nothing in the trace identifying the record.

Labelled:

  * core.SyncGroupsAndTheirMembers   sync=root, codeVersion
  * idp.GetUsersByGroupsMembers      sync=users, user=<email>
  * google.ListGroupMembersBatch     sync=group-members, group=<id>
  * scim.GetGroupsMembers            sync=scim-group-members, user=<scimid>

Uses pprof.SetGoroutineLabels, NOT pprof.Do

This is the part worth knowing. pprof.Do is the idiomatic scoped API and the
obvious choice, but it does not work for this purpose: it defers restoring the
previous label set, and on a panic those defers run during unwinding *before* the
runtime prints the traceback. The labels are therefore gone at exactly the moment
they are needed.

Verified both ways against Go 1.27 before choosing:

    pprof.Do              -> goroutine 1 [running]:
    SetGoroutineLabels    -> goroutine 1 [running] {user: "zoe@example.com"}:

runtime.Stack shows the labels under either API, so the pprof.Do version passes
a naive check and fails only in the case it exists to serve.
TestGetUsersByGroupsMembers_labelsGoroutinesForTracebacks captures a real
traceback from inside a worker and asserts the labels are in the goroutine
header, so the substitution cannot be made silently.

Cost

Measured: 28.65 ns/op, 104 B/op, 3 allocs/op — per goroutine, not per iteration.
For a 500-user sync that is roughly 15 µs and 52 KB in total, noise against the
network round-trips it accompanies. Opt out with GODEBUG=tracebacklabels=0.

Purely additive: no control flow, no exported API, no hashing, no state-file
change. The internal/model golden hashes are unchanged, as they must be.

Documented in docs/Architecture.md (Failure modes) and docs/Whats-New.md,
including the pprof.Do caveat so the reasoning is not lost.

Verified: go fix no changes; gofmt, go vet clean; golangci-lint 0 issues; full
-race suite green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
@christiangda christiangda self-assigned this Aug 31, 2026
christiangda and others added 3 commits August 31, 2026 14:08
The path fix in the previous commit was only half the problem, which a real CI
run on PR #637 revealed.

The path fix did work. Codecov went from finding nothing to:

    info -- Found 1 coverage files to report
    info -- > /home/runner/.../build/coverage.txt

But the upload is then rejected:

    error -- Upload queued for processing failed:
             {"message":"Token required because branch is protected"}

`main` is a protected branch, and codecov requires a token for protected
branches. The CODECOV_TOKEN repository secret does not exist — the repo has only
AWS_OIDC_ROLE_TO_ASSUME, DOCKER_HUB_TOKEN, DOCKER_HUB_USER, GH_PAT, GH_USER and
SAM_APP_BUCKET.

Because fail_ci_if_error defaults to false, both failures were silent. That is
exactly how the wrong path survived unnoticed, so the step now carries a comment
saying to check its log after the token is added.

The workflow passes token: ${{ secrets.CODECOV_TOKEN }}, which completes the fix
the moment the secret is created. fail_ci_if_error is deliberately left false so
a missing token cannot break the build.

Also corrects docs/Whats-New.md, which claimed codecov was "fixed and verified".
That was true of the path and not of the upload, and overstated the result.

ACTION REQUIRED: add the CODECOV_TOKEN secret (Settings -> Secrets and variables
-> Actions) for the coverage badge to update.

Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit intended to correct this but its text replacement silently
failed to match, so the overstated wording stayed in the PR.

docs/Whats-New.md claimed codecov was "fixed and verified from a clean tree".
That was true of the file path and not of the upload: `main` is a protected
branch, codecov requires a token for protected branches, and the CODECOV_TOKEN
secret does not exist. The entry now states both causes and that adding the
secret is what completes the fix.

Signed-off-by: Christian González Di Antonio <christian.gonzalez@aizon.ai>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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