feat(ci): sync the label taxonomy to the GitHub mirror automatically #125

Merged
rootiest merged 2 commits from ci/sync-mirror-labels into main 2026-09-01 03:37:17 +00:00
Owner

Summary

Automates the manual label sync that #124 documented, so the taxonomy stays
aligned across both forges without anyone remembering to do it.

  • scripts/sync-labels.py — reads labels from Gitea, makes GitHub match.
    Stdlib only, so the CI step installs nothing beyond python3.
  • .github/workflows/sync-labels.yml — runs it daily at 06:00 UTC, on
    any push to main touching the script or the workflow, and on manual
    dispatch (with a dry_run input).
  • CONTRIBUTING.md — the mirror section said a label "must be created on
    the mirror too — no automation does it for you", which is now false.
    Rewritten to describe what the sync does and the token it needs.

Gitea stays the source of truth. Manage labels in its UI as you do now; the
mirror follows.

Note

This PR was opened stacked on #124, because it edits the
### The GitHub mirror subsection that only existed on that branch. #124
has since merged and this was retargeted to main.

How it works

read Gitea   GET  /api/v1/repos/Rootiest/fish-config/labels    (public, no auth)
read GitHub  GET  /repos/rootiest/fish-config/labels           (Bearer token)

diff by name → missing on GitHub        → POST   create
               color/description drift  → PATCH  update
               extra on GitHub          → in use? → no:  DELETE
                                                    yes: keep + report count

Colors and descriptions are normalized before comparison — Gitea returns
colors bare (00838f), GitHub sometimes with a leading #, and a
description may be null on one side and "" on the other. Without that,
a steady state would rewrite all 33 labels on every run instead of being a
true no-op.

Two guards matter more than the happy path:

  • An extra label is deleted only when nothing carries it. A scheduled job
    running unattended must not be able to strip a label off someone's issue.
    One that's in use is reported with its count and left for a human.
  • An empty source aborts the run. If Gitea returned nothing — an outage,
    a bad token, a moved repo — treating that as truth would propose deleting
    every label on the mirror.

The job is gated with the same github.server_url != 'https://github.com'
check ci.yml uses. Without it, the mirror would queue this forever against
racknerd-mini, the failure #120 fixed.

Scope note

Deliberately one-way, Gitea → GitHub. A label edited directly on GitHub is
treated as drift and corrected on the next run; that side isn't a management
surface.

Notes

  • Renames aren't tracked. Labels are matched by name, so renaming one on
    Gitea reads as delete-plus-create: the new name is created, the old pruned
    only if unused. The two forges share no stable label ID, so nothing better
    is available. Documented in the script header and in CONTRIBUTING.md.
  • GitHub has no exclusive labels, so Gitea's one-of enforcement on
    Priority/, Reviewed/, and Status/ doesn't survive the trip. Unchanged
    by this PR, still true, still documented.
  • The --dry-run path works without a token, using GitHub's unauthenticated
    read quota, so anyone can preview a sync.

Action required before this can run

Create a fine-grained GitHub PAT scoped to rootiest/fish-config only,
with Issues: read and write (GitHub files the labels API under Issues)
and Pull requests: read (so the in-use check sees labels on PRs). Add it
to this repo's Actions secrets as GH_MIRROR_TOKEN.

The job fails with an explicit ::error:: naming the secret and its scopes
if it's absent, rather than silently doing nothing.

Verification

All mutation paths were exercised end-to-end against the live repos by
introducing deliberate drift on Gitea and then reverting it. Both forges are
back to an identical 33-label set.

  • python3 scripts/sync-labels.py --self-test14 checks passed. Covers identical sets, cosmetic-vs-real drift, create/extra classification by direction, the empty-source case, URL encoding of Area/Prompt & Theme and Reviewed/Won't Fix, and stable output ordering.
  • --dry-run at steady state → no changes -- the mirror already matches Gitea. A correct implementation must find nothing to do here, which is the sharpest available test.
  • Create + update detected: set Kind/Chore to #ff00ff with a changed description and added a temporary ZZ/Sync Test label on Gitea → dry run reported exactly would create ZZ/Sync Test and would update Kind/Chore (color 90a4ae -> ff00ff, description).
  • Dry run changes nothing: GitHub still showed Kind/Chore #90a4ae and no ZZ/Sync Test after the run above.
  • Create + update applied: real run produced Kind/Chore #ff00ff with the new description and ZZ/Sync Test #123456 on GitHub, confirmed via the API.
  • Prune of an unused extra: reverted Kind/Chore and deleted ZZ/Sync Test on Gitea → run reported update Kind/Chore (color ff00ff -> 90a4ae, description) and delete ZZ/Sync Test (unused), and GitHub matched.
  • Idempotent: an immediate second run reported no changes.
  • Final parity: Gitea 33 / GitHub 33, identical by name, color, and description — original state fully restored, no test residue.
  • Missing token fails clean: running without GH_MIRROR_TOKEN and without --dry-run exits 1 with the message naming the secret and its required scopes.
  • python3 -c "import yaml; yaml.safe_load(...)" on sync-labels.yml parses; triggers are push, schedule, workflow_dispatch and the four steps are in the intended order.
  • fish tests/run-tests.fish10/10 passed; python3 docs/verify-manual.py74/74 passed.
  • All CONTRIBUTING.md anchors still resolve, and the stale "no automation does it for you" claim is gone.
  • The in-use branch, exercised against real data. Confirmed by labelling a throwaway GitHub issue with a label absent from Gitea and checking the run reports KEPT rather than deleting, then removing the issue.
  • Create the PAT and add GH_MIRROR_TOKEN to the Actions secrets (see above).
  • Dispatch the workflow with dry_run: true and confirm it reports no changes; then dispatch it normally and confirm a green run with the summary table in the job output.
## Summary Automates the manual label sync that #124 documented, so the taxonomy stays aligned across both forges without anyone remembering to do it. - **`scripts/sync-labels.py`** — reads labels from Gitea, makes GitHub match. Stdlib only, so the CI step installs nothing beyond `python3`. - **`.github/workflows/sync-labels.yml`** — runs it daily at 06:00 UTC, on any push to `main` touching the script or the workflow, and on manual dispatch (with a `dry_run` input). - **`CONTRIBUTING.md`** — the mirror section said a label "must be created on the mirror too — no automation does it for you", which is now false. Rewritten to describe what the sync does and the token it needs. Gitea stays the source of truth. Manage labels in its UI as you do now; the mirror follows. > [!NOTE] > This PR was opened stacked on #124, because it edits the > `### The GitHub mirror` subsection that only existed on that branch. #124 > has since merged and this was retargeted to `main`. ## How it works ``` read Gitea GET /api/v1/repos/Rootiest/fish-config/labels (public, no auth) read GitHub GET /repos/rootiest/fish-config/labels (Bearer token) diff by name → missing on GitHub → POST create color/description drift → PATCH update extra on GitHub → in use? → no: DELETE yes: keep + report count ``` Colors and descriptions are normalized before comparison — Gitea returns colors bare (`00838f`), GitHub sometimes with a leading `#`, and a description may be `null` on one side and `""` on the other. Without that, a steady state would rewrite all 33 labels on every run instead of being a true no-op. Two guards matter more than the happy path: - **An extra label is deleted only when nothing carries it.** A scheduled job running unattended must not be able to strip a label off someone's issue. One that's in use is reported with its count and left for a human. - **An empty source aborts the run.** If Gitea returned nothing — an outage, a bad token, a moved repo — treating that as truth would propose deleting every label on the mirror. The job is gated with the same `github.server_url != 'https://github.com'` check `ci.yml` uses. Without it, the mirror would queue this forever against `racknerd-mini`, the failure #120 fixed. ## Scope note Deliberately one-way, Gitea → GitHub. A label edited directly on GitHub is treated as drift and corrected on the next run; that side isn't a management surface. ## Notes - **Renames aren't tracked.** Labels are matched by name, so renaming one on Gitea reads as delete-plus-create: the new name is created, the old pruned only if unused. The two forges share no stable label ID, so nothing better is available. Documented in the script header and in `CONTRIBUTING.md`. - **GitHub has no exclusive labels**, so Gitea's one-of enforcement on `Priority/`, `Reviewed/`, and `Status/` doesn't survive the trip. Unchanged by this PR, still true, still documented. - The `--dry-run` path works without a token, using GitHub's unauthenticated read quota, so anyone can preview a sync. ## Action required before this can run Create a **fine-grained** GitHub PAT scoped to `rootiest/fish-config` only, with **Issues: read and write** (GitHub files the labels API under Issues) and **Pull requests: read** (so the in-use check sees labels on PRs). Add it to this repo's Actions secrets as **`GH_MIRROR_TOKEN`**. The job fails with an explicit `::error::` naming the secret and its scopes if it's absent, rather than silently doing nothing. ## Verification All mutation paths were exercised end-to-end against the live repos by introducing deliberate drift on Gitea and then reverting it. Both forges are back to an identical 33-label set. - [x] `python3 scripts/sync-labels.py --self-test` → `14 checks passed`. Covers identical sets, cosmetic-vs-real drift, create/extra classification by direction, the empty-source case, URL encoding of `Area/Prompt & Theme` and `Reviewed/Won't Fix`, and stable output ordering. - [x] `--dry-run` at steady state → `no changes -- the mirror already matches Gitea`. A correct implementation must find nothing to do here, which is the sharpest available test. - [x] **Create + update detected:** set `Kind/Chore` to `#ff00ff` with a changed description and added a temporary `ZZ/Sync Test` label on Gitea → dry run reported exactly `would create ZZ/Sync Test` and `would update Kind/Chore (color 90a4ae -> ff00ff, description)`. - [x] **Dry run changes nothing:** GitHub still showed `Kind/Chore #90a4ae` and no `ZZ/Sync Test` after the run above. - [x] **Create + update applied:** real run produced `Kind/Chore #ff00ff` with the new description and `ZZ/Sync Test #123456` on GitHub, confirmed via the API. - [x] **Prune of an unused extra:** reverted `Kind/Chore` and deleted `ZZ/Sync Test` on Gitea → run reported `update Kind/Chore (color ff00ff -> 90a4ae, description)` and `delete ZZ/Sync Test (unused)`, and GitHub matched. - [x] **Idempotent:** an immediate second run reported `no changes`. - [x] **Final parity:** Gitea 33 / GitHub 33, identical by name, color, and description — original state fully restored, no test residue. - [x] **Missing token fails clean:** running without `GH_MIRROR_TOKEN` and without `--dry-run` exits 1 with the message naming the secret and its required scopes. - [x] `python3 -c "import yaml; yaml.safe_load(...)"` on `sync-labels.yml` parses; triggers are `push`, `schedule`, `workflow_dispatch` and the four steps are in the intended order. - [x] `fish tests/run-tests.fish` → `10/10 passed`; `python3 docs/verify-manual.py` → `74/74 passed`. - [x] All `CONTRIBUTING.md` anchors still resolve, and the stale "no automation does it for you" claim is gone. - [x] **The in-use branch, exercised against real data.** Confirmed by labelling a throwaway GitHub issue with a label absent from Gitea and checking the run reports `KEPT` rather than deleting, then removing the issue. - [x] Create the PAT and add `GH_MIRROR_TOKEN` to the Actions secrets (see above). - [x] Dispatch the workflow with `dry_run: true` and confirm it reports no changes; then dispatch it normally and confirm a green run with the summary table in the job output.
rootiest added the Kind/FeatureArea/DocsArea/CIArea/Scripts labels 2026-09-01 03:32:59 +00:00
rootiest changed target branch from docs/issue-templates-and-labels to main 2026-09-01 03:36:26 +00:00
rootiest added 2 commits 2026-09-01 03:36:26 +00:00
Labels don't travel with a mirror push -- mirroring copies files, not
repository settings -- but they matter on the GitHub side anyway, because
GitHub reads the same .github/ISSUE_TEMPLATE/ files and silently drops a
labels: entry naming a label it doesn't have. Until now the only thing
keeping the two sets aligned was remembering to do it by hand, which is
exactly the kind of thing that gets forgotten and then fails invisibly.

Add scripts/sync-labels.py and a workflow that runs it daily, on any change
to the script itself, and on manual dispatch. Gitea stays the source of
truth: labels are managed there and GitHub is made to match.

- **Creates and updates** anything missing or drifted. Colors and
  descriptions are normalized before comparison -- Gitea returns colors
  bare, GitHub sometimes with a leading '#', and a description may be null
  on one side and "" on the other -- so a steady state is a true no-op
  rather than a rewrite of all 33 labels every run.
- **Deletes only unused extras.** An extra label on the mirror is removed
  only when no issue or PR there carries it; one in use is reported with
  its count and left alone. An unattended scheduled job must not be able to
  strip a label off somebody's issue.
- **Refuses to run on an empty source**, since treating that as truth would
  propose deleting every label on the mirror.
- **--dry-run** prints the plan and changes nothing; **--self-test** checks
  the diff logic offline against fixtures, and gates the sync step in CI so
  a broken diff can't mutate anything.

Stdlib only, so the CI step installs nothing beyond python3. The job is
gated with the same github.server_url check ci.yml uses -- without it the
mirror would queue this forever against a runner that only exists on Gitea.

Labels are matched by name, so a rename reads as delete-plus-create; the
new name is created and the old is pruned only if unused. The two forges
share no stable label ID, so a rename can't be tracked across them.
The mirror section told contributors a label added on Gitea "must be
created on the mirror too -- no automation does it for you." That's no
longer true, and a stale instruction to do something by hand is worse than
none, since it invites a manual edit that the next scheduled sync would
overwrite anyway.

Describe what the sync actually does: what it creates, updates, and prunes,
that an in-use label is never deleted, the --dry-run and --self-test flags,
and the rename caveat that follows from matching labels by name. Also
record the GH_MIRROR_TOKEN secret the workflow needs and its exact scopes,
since that's the one part of this that can't be automated.

The exclusive-labels caveat below it is unaffected and stays as written.
rootiest merged commit 335fdff433 into main 2026-09-01 03:37:17 +00:00
rootiest deleted branch ci/sync-mirror-labels 2026-09-01 03:37:17 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: rootiest/fish-config#125