reconcile 0.4.0

A reconciliation storage service to sync a key-value map over multiple instances
Documentation
name: labels

# Keeps the label set itself mechanical (AGENTS.md ยง10). Two jobs, two failure modes:
#   sync     the label set drifts from `.github/labels.tsv` -> re-apply the file on every change
#   triage   an issue opens with no labels at all           -> stamp `S-needs-triage`
#
# The third failure mode -- an issue carrying an invalid *combination* -- is audited weekly by
# `issue-integrity.yml`, alongside the closed-issue-SHA audit: both look at the tracker's existing
# state rather than at any one diff, so they share one schedule instead of two.

on:
  push:
    branches: [ "main" ]
    paths:
      - '.github/labels.tsv'
      - 'scripts/sync-labels.sh'
      - '.github/workflows/labels.yml'
  issues:
    types: [ opened ]
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

jobs:
  sync:
    if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-latest
    permissions:
      issues: write
    steps:
      - uses: actions/checkout@v4
      # No `--prune`: deleting a label strips it from every issue carrying it, which is not
      # recoverable from the file. Removing a label is therefore a deliberate local run
      # (`./scripts/sync-labels.sh --apply --prune`), never a side effect of a merge.
      - name: Apply .github/labels.tsv
        run: ./scripts/sync-labels.sh --apply
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  triage:
    if: github.event_name == 'issues'
    runs-on: ubuntu-latest
    permissions:
      issues: write
    steps:
      # The default state has to be applied by something, or "exactly one S-" is false for every
      # issue the moment it is opened, and the weekly audit becomes noise. The issue template also
      # sets it, which covers only template-created issues -- this covers the rest (API,
      # `gh issue create`, transfers).
      #
      # Guarded on "no S- label yet": an issue created through the API with its full C-/A-/S- set
      # already present (the shape every issue in this backlog's 2026-08-14 split/rescope pass
      # used) still fires this same `issues: opened` webhook, and an unconditional `--add-label`
      # stamped a second, contradictory S- onto seven of nine such issues before this guard existed
      # -- `--add-label` merges, it does not replace, so "exactly one S-" broke immediately for
      # the one case this job exists to prevent it for. `gh issue view --json labels` right before
      # acting keeps the check and the write on the same event instead of trusting a separate run.
      - name: Stamp S-needs-triage, unless an S- label is already present
        run: |
          set -euo pipefail
          existing=$(gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" --json labels --jq '.labels[].name')
          if grep -q '^S-' <<<"$existing"; then
            echo "already carries an S- label, not stamping:"
            grep '^S-' <<<"$existing"
          else
            gh issue edit "$NUMBER" --repo "$GITHUB_REPOSITORY" --add-label S-needs-triage
          fi
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NUMBER: ${{ github.event.issue.number }}