feat(ci): sync the label taxonomy to the GitHub mirror automatically
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.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
name: Sync labels to mirror
|
||||
|
||||
# Labels do not 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 does not have. Gitea is the source of
|
||||
# truth; this makes the mirror match.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 06:00 UTC daily. Label churn is rare, so a slower cadence than this
|
||||
# would leave the mirror wrong for most of a working day after an edit.
|
||||
- cron: "0 6 * * *"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
# Exercise the sync as soon as its own logic changes, rather than
|
||||
# waiting for the next scheduled run to find out it is broken.
|
||||
- "scripts/sync-labels.py"
|
||||
- ".github/workflows/sync-labels.yml"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Report the plan without changing anything"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
sync-labels:
|
||||
# This file is mirrored to GitHub as-is. The runner label below only
|
||||
# exists on the Gitea instance, so on GitHub the job would sit queued
|
||||
# forever against a runner that will never pick it up -- the same
|
||||
# problem the github-mirror stand-in in ci.yml exists to solve. A
|
||||
# skipped job costs nothing and produces no stuck status.
|
||||
if: github.server_url != 'https://github.com'
|
||||
runs-on: racknerd-mini
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Install Python
|
||||
run: |
|
||||
sudo apt-get -o Acquire::Retries=3 update -qq
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install \
|
||||
--no-install-recommends -y python3
|
||||
|
||||
# Cheap, offline, and no token needed. Catches a broken diff before
|
||||
# anything is allowed to mutate labels on the mirror.
|
||||
- name: Check the diff logic
|
||||
run: python3 scripts/sync-labels.py --self-test
|
||||
|
||||
- name: Sync labels
|
||||
env:
|
||||
GH_MIRROR_TOKEN: ${{ secrets.GH_MIRROR_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$GH_MIRROR_TOKEN" ]; then
|
||||
echo "::error::GH_MIRROR_TOKEN is not set in this repository's Actions secrets."
|
||||
echo "Create a fine-grained GitHub token scoped to rootiest/fish-config with"
|
||||
echo "Issues: read and write, plus Pull requests: read, and add it as"
|
||||
echo "GH_MIRROR_TOKEN under Settings -> Actions -> Secrets."
|
||||
exit 1
|
||||
fi
|
||||
if [ "${{ inputs.dry_run }}" = "true" ]; then
|
||||
python3 scripts/sync-labels.py --dry-run
|
||||
else
|
||||
python3 scripts/sync-labels.py
|
||||
fi
|
||||
Executable
+425
@@ -0,0 +1,425 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (C) 2026 Rootiest
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# SYNOPSIS
|
||||
# sync-labels.py [--dry-run] [--self-test]
|
||||
#
|
||||
# DESCRIPTION
|
||||
# Syncs the label taxonomy from the canonical Gitea repo to the GitHub
|
||||
# mirror. Gitea is the source of truth: labels are managed there, in the
|
||||
# web UI or via its API, and this script makes GitHub match.
|
||||
#
|
||||
# Mirroring copies files, not repository settings, so labels do not travel
|
||||
# with a push. They matter on the mirror anyway, because GitHub reads the
|
||||
# same .github/ISSUE_TEMPLATE/ files and silently drops a labels: entry
|
||||
# naming a label that does not exist there.
|
||||
#
|
||||
# Three actions, decided by comparing the two sets by label name:
|
||||
#
|
||||
# missing on GitHub created
|
||||
# color or description drift updated
|
||||
# extra on GitHub deleted only if no issue or PR carries it,
|
||||
# otherwise left alone and reported
|
||||
#
|
||||
# The usage check is what keeps an unattended scheduled run from stripping
|
||||
# a label off somebody's issue. An extra label that is in use is reported
|
||||
# with its count and requires a human decision.
|
||||
#
|
||||
# ARGUMENTS
|
||||
# --dry-run Print the plan and exit without changing anything. Works
|
||||
# without a token, using GitHub's unauthenticated read quota.
|
||||
# --self-test Check the diff logic against in-memory fixtures and exit.
|
||||
# Runs offline; makes no network calls.
|
||||
#
|
||||
# ENVIRONMENT
|
||||
# GH_MIRROR_TOKEN GitHub token with Issues: read and write (labels live
|
||||
# under Issues) and Pull requests: read (so the usage
|
||||
# check sees labels attached to PRs). Required unless
|
||||
# --dry-run or --self-test.
|
||||
#
|
||||
# EXIT STATUS
|
||||
# 0 Success, or a dry run that completed.
|
||||
# 1 An API call failed, the token is missing, or a self-test failed.
|
||||
#
|
||||
# NOTES
|
||||
# Labels are matched by name, so renaming one on Gitea reads here as a
|
||||
# delete plus a create: the new name is created, and the old one is pruned
|
||||
# only if unused. The two forges share no stable label ID, so a rename
|
||||
# cannot be tracked across them.
|
||||
#
|
||||
# GitHub has no exclusive labels. Gitea's one-of enforcement on Priority/,
|
||||
# Reviewed/, and Status/ does not survive the trip and holds by convention
|
||||
# on the mirror.
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
GITEA_API = "https://git.rootiest.dev/api/v1"
|
||||
GITEA_REPO = "Rootiest/fish-config"
|
||||
GITHUB_API = "https://api.github.com"
|
||||
GITHUB_REPO = "rootiest/fish-config"
|
||||
TOKEN_ENV = "GH_MIRROR_TOKEN"
|
||||
TIMEOUT = 30
|
||||
|
||||
|
||||
class SyncError(RuntimeError):
|
||||
"""An API call failed, or the environment is not usable."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Pure diff core. No I/O lives below this line until the HTTP section, so
|
||||
# --self-test can exercise the decision logic offline.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def normalize(label):
|
||||
"""Reduce a forge's label dict to (name, color, description).
|
||||
|
||||
Gitea returns colors bare ("00838f"), GitHub sometimes with a leading
|
||||
"#", and either may use mixed case; a description may be null on GitHub
|
||||
but "" on Gitea. Without this the two sets never compare equal and every
|
||||
run would rewrite every label.
|
||||
"""
|
||||
return (
|
||||
label["name"],
|
||||
(label.get("color") or "").lstrip("#").lower(),
|
||||
label.get("description") or "",
|
||||
)
|
||||
|
||||
|
||||
def plan(source, target):
|
||||
"""Compare two label lists and return the work to do.
|
||||
|
||||
Returns (to_create, to_update, extras): two lists of label dicts and a
|
||||
list of names present on the target but not the source. Deciding whether
|
||||
an extra is safe to delete needs the network, so that is left to the
|
||||
caller.
|
||||
"""
|
||||
src = {name: (color, desc) for name, color, desc in map(normalize, source)}
|
||||
tgt = {name: (color, desc) for name, color, desc in map(normalize, target)}
|
||||
|
||||
to_create = [
|
||||
{"name": name, "color": src[name][0], "description": src[name][1]}
|
||||
for name in sorted(src.keys() - tgt.keys())
|
||||
]
|
||||
to_update = [
|
||||
{
|
||||
"name": name,
|
||||
"color": src[name][0],
|
||||
"description": src[name][1],
|
||||
"was": tgt[name],
|
||||
}
|
||||
for name in sorted(src.keys() & tgt.keys())
|
||||
if src[name] != tgt[name]
|
||||
]
|
||||
extras = sorted(tgt.keys() - src.keys())
|
||||
return to_create, to_update, extras
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HTTP
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def request(url, token=None, method="GET", payload=None):
|
||||
body = json.dumps(payload).encode() if payload is not None else None
|
||||
req = urllib.request.Request(url, data=body, method=method)
|
||||
req.add_header("Accept", "application/json")
|
||||
if body is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if token:
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as response:
|
||||
raw = response.read()
|
||||
return json.loads(raw) if raw else None
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")[:300]
|
||||
raise SyncError(f"{method} {url} -> HTTP {exc.code}: {detail}") from None
|
||||
except urllib.error.URLError as exc:
|
||||
raise SyncError(f"{method} {url} -> {exc.reason}") from None
|
||||
|
||||
|
||||
def paginate(url_template, token=None, per_page=50):
|
||||
"""Collect every page of a list endpoint.
|
||||
|
||||
url_template takes {page} and {per_page}. Both forges stop returning
|
||||
full pages at the end, which is the signal used here rather than trying
|
||||
to parse differing Link headers.
|
||||
"""
|
||||
collected = []
|
||||
page = 1
|
||||
while True:
|
||||
batch = request(
|
||||
url_template.format(page=page, per_page=per_page), token=token
|
||||
)
|
||||
if not batch:
|
||||
break
|
||||
collected.extend(batch)
|
||||
if len(batch) < per_page:
|
||||
break
|
||||
page += 1
|
||||
return collected
|
||||
|
||||
|
||||
def quoted(name):
|
||||
"""URL-encode a label name. Names carry '/', ' ', '&' and apostrophes."""
|
||||
return urllib.parse.quote(name, safe="")
|
||||
|
||||
|
||||
def fetch_gitea_labels():
|
||||
return paginate(
|
||||
f"{GITEA_API}/repos/{GITEA_REPO}/labels?limit={{per_page}}&page={{page}}"
|
||||
)
|
||||
|
||||
|
||||
def fetch_github_labels(token):
|
||||
return paginate(
|
||||
f"{GITHUB_API}/repos/{GITHUB_REPO}/labels?per_page={{per_page}}&page={{page}}",
|
||||
token=token,
|
||||
)
|
||||
|
||||
|
||||
def usage_count(name, token):
|
||||
"""How many issues or PRs on the mirror carry this label.
|
||||
|
||||
Caps at 100; the exact number past that does not change the decision,
|
||||
which is only ever "zero" versus "not zero".
|
||||
"""
|
||||
items = request(
|
||||
f"{GITHUB_API}/repos/{GITHUB_REPO}/issues"
|
||||
f"?labels={quoted(name)}&state=all&per_page=100",
|
||||
token=token,
|
||||
)
|
||||
return len(items or [])
|
||||
|
||||
|
||||
def create_label(label, token):
|
||||
request(
|
||||
f"{GITHUB_API}/repos/{GITHUB_REPO}/labels",
|
||||
token=token,
|
||||
method="POST",
|
||||
payload={
|
||||
"name": label["name"],
|
||||
"color": label["color"],
|
||||
"description": label["description"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def update_label(label, token):
|
||||
request(
|
||||
f"{GITHUB_API}/repos/{GITHUB_REPO}/labels/{quoted(label['name'])}",
|
||||
token=token,
|
||||
method="PATCH",
|
||||
payload={
|
||||
"new_name": label["name"],
|
||||
"color": label["color"],
|
||||
"description": label["description"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def delete_label(name, token):
|
||||
request(
|
||||
f"{GITHUB_API}/repos/{GITHUB_REPO}/labels/{quoted(name)}",
|
||||
token=token,
|
||||
method="DELETE",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Reporting
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def emit_summary(lines):
|
||||
"""Append a run summary to the CI step summary, when running under CI."""
|
||||
path = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path, "a", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(lines) + "\n")
|
||||
except OSError as exc:
|
||||
print(f"note: could not write step summary: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Self-test
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def self_test():
|
||||
checks = 0
|
||||
|
||||
def check(condition, message):
|
||||
nonlocal checks
|
||||
checks += 1
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
|
||||
# Identical sets produce no work. This is the case every scheduled run
|
||||
# hits once the two forges agree, so it must be exactly empty rather
|
||||
# than merely small.
|
||||
same = [{"name": "Kind/Bug", "color": "ee0701", "description": "Broken"}]
|
||||
create, update, extras = plan(same, list(same))
|
||||
check(create == [] and update == [] and extras == [], "identical sets differ")
|
||||
|
||||
# Cosmetic representation differences are not drift. A "#" prefix,
|
||||
# uppercase hex, and null-versus-empty description all appear in real
|
||||
# API responses.
|
||||
create, update, extras = plan(
|
||||
[{"name": "A", "color": "00838F", "description": ""}],
|
||||
[{"name": "A", "color": "#00838f", "description": None}],
|
||||
)
|
||||
check(update == [], "cosmetic color/description difference reported as drift")
|
||||
|
||||
# A real difference in either field is drift.
|
||||
_, update, _ = plan(
|
||||
[{"name": "A", "color": "111111", "description": "new"}],
|
||||
[{"name": "A", "color": "222222", "description": "old"}],
|
||||
)
|
||||
check(len(update) == 1 and update[0]["color"] == "111111", "color drift missed")
|
||||
_, update, _ = plan(
|
||||
[{"name": "A", "color": "111111", "description": "new"}],
|
||||
[{"name": "A", "color": "111111", "description": "old"}],
|
||||
)
|
||||
check(len(update) == 1, "description drift missed")
|
||||
|
||||
# Missing and extra are classified by direction, not lumped together.
|
||||
create, update, extras = plan(
|
||||
[{"name": "keep", "color": "1", "description": ""},
|
||||
{"name": "add", "color": "2", "description": ""}],
|
||||
[{"name": "keep", "color": "1", "description": ""},
|
||||
{"name": "stale", "color": "3", "description": ""}],
|
||||
)
|
||||
check([c["name"] for c in create] == ["add"], "missing label not queued")
|
||||
check(extras == ["stale"], "extra label not detected")
|
||||
check(update == [], "unchanged label queued for update")
|
||||
|
||||
# An empty source must not be read as "delete everything silently" --
|
||||
# extras still route through the caller's usage check.
|
||||
create, update, extras = plan([], [{"name": "x", "color": "1", "description": ""}])
|
||||
check(create == [] and update == [] and extras == ["x"], "empty source mishandled")
|
||||
|
||||
# Names that need URL encoding survive the round trip.
|
||||
check(quoted("Area/Prompt & Theme") == "Area%2FPrompt%20%26%20Theme", "bad quoting")
|
||||
check(quoted("Reviewed/Won't Fix") == "Reviewed%2FWon%27t%20Fix", "bad quoting")
|
||||
checks += 2
|
||||
|
||||
# Output ordering is stable, so a run's log is diffable against the last.
|
||||
create, _, extras = plan(
|
||||
[{"name": "b", "color": "1", "description": ""},
|
||||
{"name": "a", "color": "1", "description": ""}],
|
||||
[{"name": "z", "color": "1", "description": ""},
|
||||
{"name": "y", "color": "1", "description": ""}],
|
||||
)
|
||||
check([c["name"] for c in create] == ["a", "b"], "creates not sorted")
|
||||
check(extras == ["y", "z"], "extras not sorted")
|
||||
|
||||
print(f"self-test: {checks} checks passed")
|
||||
return 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run(dry_run):
|
||||
token = os.environ.get(TOKEN_ENV)
|
||||
if not token and not dry_run:
|
||||
raise SyncError(
|
||||
f"{TOKEN_ENV} is not set. Add a GitHub token to the Gitea repo's "
|
||||
f"Actions secrets as {TOKEN_ENV}, with Issues: read and write "
|
||||
"plus Pull requests: read, scoped to " + GITHUB_REPO + "."
|
||||
)
|
||||
|
||||
source = fetch_gitea_labels()
|
||||
if not source:
|
||||
raise SyncError(
|
||||
"Gitea returned no labels. Refusing to continue, since treating "
|
||||
"that as the source of truth would propose deleting every label "
|
||||
"on the mirror."
|
||||
)
|
||||
target = fetch_github_labels(token)
|
||||
to_create, to_update, extras = plan(source, target)
|
||||
|
||||
prefix = "would " if dry_run else ""
|
||||
log = [f"Gitea: {len(source)} labels GitHub: {len(target)} labels", ""]
|
||||
|
||||
for label in to_create:
|
||||
log.append(f" {prefix}create {label['name']}")
|
||||
if not dry_run:
|
||||
create_label(label, token)
|
||||
|
||||
for label in to_update:
|
||||
old_color, old_desc = label["was"]
|
||||
changed = []
|
||||
if old_color != label["color"]:
|
||||
changed.append(f"color {old_color} -> {label['color']}")
|
||||
if old_desc != label["description"]:
|
||||
changed.append("description")
|
||||
log.append(f" {prefix}update {label['name']} ({', '.join(changed)})")
|
||||
if not dry_run:
|
||||
update_label(label, token)
|
||||
|
||||
skipped = []
|
||||
for name in extras:
|
||||
count = usage_count(name, token)
|
||||
if count:
|
||||
skipped.append((name, count))
|
||||
log.append(
|
||||
f" KEPT {name} (in use by {count} issue/PR"
|
||||
f"{'s' if count != 1 else ''} -- delete by hand if intended)"
|
||||
)
|
||||
else:
|
||||
log.append(f" {prefix}delete {name} (unused)")
|
||||
if not dry_run:
|
||||
delete_label(name, token)
|
||||
|
||||
total = len(to_create) + len(to_update) + len(extras) - len(skipped)
|
||||
if total == 0 and not skipped:
|
||||
log.append(" no changes -- the mirror already matches Gitea")
|
||||
log.append("")
|
||||
log.append(
|
||||
f"{'planned' if dry_run else 'applied'}: "
|
||||
f"{len(to_create)} created, {len(to_update)} updated, "
|
||||
f"{len(extras) - len(skipped)} deleted, {len(skipped)} kept in use"
|
||||
)
|
||||
|
||||
print("\n".join(log))
|
||||
emit_summary(["## Label sync", "", "```", *log, "```"])
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Sync the label taxonomy from Gitea to the GitHub mirror."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="print the plan, change nothing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--self-test", action="store_true", help="check the diff logic offline"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.self_test:
|
||||
return self_test()
|
||||
try:
|
||||
return run(args.dry_run)
|
||||
except SyncError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user