Skip to content

The issue_labels_sync idempotency key varies by runner locale #1136

Description

@dwin-gharibi

Summary

flushIssueLabelMutationBatch in src/clawsweeper-label-mutations.ts sorts the
batched additions and removals with String.prototype.localeCompare and joins them
into the mutation identity the action ledger dedupes on:

let additions = [...batch.additions.values()].sort((left, right) => left.localeCompare(right));
const removals = [...batch.removals.values()].sort((left, right) => left.localeCompare(right));
// ...
identity: `issue_labels_sync:${number}:add=${additions.join("|")}:remove=${removals.join("|")}`

localeCompare is locale-sensitive, so the identity depends on the ICU
configuration of the machine that produced it.

Impact

The identity is an idempotency key. Two spellings of the same key are two different
keys, so a label sync that has already been applied is not recognized as a repeat
and can be published again.

Observed against the shipped module, same label set and same batching:

en_US.UTF-8  issue_labels_sync:321:add=Alpha|apple|äpple|zulu:remove=
sv_SE.UTF-8  issue_labels_sync:321:add=Alpha|apple|zulu|äpple:remove=

There is a second, locale-independent failure in the same line. localeCompare
returns 0 for strings a collator treats as equivalent but that are not equal, so
it is not a total order. GitHub permits emoji in label names, and two names
differing only by a zero-width joiner tie:

"status: 👀‍ ready".localeCompare("status: 👀 ready")  // 0

Array.prototype.sort therefore leaves them in whatever order they were queued, and
the identity follows the queue rather than the label set — on a single runner, with
no locale involved.

Reproduction

Queue the same label set in two different orders and compare the published
identity, or run the same set under two LC_ALL values. Both are covered by
docs/proof/label-sync-identity-determinism/run-proof.mjs.

Proposed solution

Sort with compareCodeUnits from src/stable-json.ts:

import { compareCodeUnits } from "./stable-json.js";
// ...
let additions = [...batch.additions.values()].sort(compareCodeUnits);
const removals = [...batch.removals.values()].sort(compareCodeUnits);

Code-unit order is a total order and is fully specified, so the key depends only on
the label set. It is also the ordering the action ledger already uses for its
canonical JSON, which keeps the two consistent.

Expected fallout

Code-unit order puts uppercase before lowercase, so P2 sorts before
impact:message-loss. That changes the order of names inside the --add-label and
--remove-label arguments. GitHub treats those as sets, so the resulting label
state is unchanged, but three assertions in test/label-mutation-batch.test.ts
pin the current order and need updating:

  • an exact-publication label batch emits one combined deterministic issue edit
  • label definition discovery is cached across item batches
  • optional batch failures retain successful final operations and report skipped additions

In the third, the per-label retry order also flips, which moves where the receipt
lands. result.skippedAdditions is unaffected.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal priority bug or improvement with limited blast radius.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:otherThis issue has meaningful maintainer-visible impact outside the owned taxonomy.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions