From 4088430204df71210a0a315f72f3c53c93b6691e Mon Sep 17 00:00:00 2001 From: Luca Rui Date: Fri, 24 Jul 2026 15:57:29 +0100 Subject: [PATCH 1/5] feat: automate weekly label sync to customer repos Previously the label sync was a manual, interactive `docker run` (last released v0.0.2, 2021) with no scheduled trigger, so target repos drifted out of sync with the leader (giantswarm/giantswarm) even when correctly registered in data/customers.yaml. - cli.py: add a --yes flag so the sync can run unattended (it otherwise blocks on an interactive Y/N prompt), and read the token from the GITHUB_TOKEN env var so CI need not write the secret to disk. - Add a weekly scheduled GitHub Actions workflow (label-sync.yaml) that authenticates via a GitHub App installation token and runs the sync. workflow_dispatch supports a dry_run input for manual preview. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/label-sync.yaml | 71 +++++++++++++++++++++++++++++++ cli.py | 18 ++++++-- 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/label-sync.yaml diff --git a/.github/workflows/label-sync.yaml b/.github/workflows/label-sync.yaml new file mode 100644 index 0000000..356eeaa --- /dev/null +++ b/.github/workflows/label-sync.yaml @@ -0,0 +1,71 @@ +name: Sync labels to customer repos + +# Weekly, unattended run of the label sync. Copies labels matching the +# include rules in config.yaml from the leader repo (giantswarm/giantswarm) +# to giantswarm/roadmap and every repo listed in +# giantswarm/giantswarm/data/customers.yaml. Create/update only, never deletes. +# +# Hand-authored workflow (not a devctl zz_generated.* file). Do not rename to +# the zz_generated prefix, or it may be overwritten by repository automation. + +on: + schedule: + - cron: "0 6 * * 1" # Mondays 06:00 UTC + workflow_dispatch: + inputs: + dry_run: + description: "Print the sync plan without applying any changes" + type: boolean + default: false + +# The workflow's own GITHUB_TOKEN needs nothing; cross-repo writes use the +# App installation token minted below. +permissions: + contents: read + +# Never let a manual dispatch race the scheduled run. +concurrency: + group: label-sync + cancel-in-progress: false + +jobs: + sync: + name: Sync labels + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Mint App installation token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + # A dedicated giantswarm-label-sync App with: + # - contents: read on giantswarm/giantswarm (leader labels + customers.yaml) + # - issues: write on roadmap + all customer repos (labels live under Issues) + # Fallback: swap for the align-files App's ALIGN_FILES_APP_* creds, which + # already grant issues:write across installed repos. + client-id: ${{ vars.LABEL_SYNC_APP_CLIENT_ID }} + private-key: ${{ secrets.LABEL_SYNC_APP_PRIVATE_KEY }} + owner: giantswarm + # Empty list = all repositories the App is installed on. + repositories: "" + + - name: Synchronize labels + env: + # cli.py reads the token from this env var, so nothing touches disk. + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + if [ "${{ inputs.dry_run }}" = "true" ]; then + python cli.py --dry-run + else + python cli.py --yes + fi diff --git a/cli.py b/cli.py index f3c9bfe..dba224f 100644 --- a/cli.py +++ b/cli.py @@ -1,10 +1,13 @@ import click +import os import re import sys import github import yaml +TOKEN_ENV_VAR = 'GITHUB_TOKEN' + RULE_INCLUDE = 'include' RULE_IGNORE = 'ignore' @@ -23,7 +26,8 @@ class RepoArchivedException(Exception): @click.option('--conf', default="./config.yaml", help="Configuration file path.") @click.option('--token-path', default="~/.github-token", help="Github token path.") @click.option('--dry-run', default=False, is_flag=True, help="Show what you would do, but don't do it.") -def main(conf, token_path, dry_run): +@click.option('--yes', default=False, is_flag=True, help="Apply the plan without interactive confirmation (for unattended/CI runs).") +def main(conf, token_path, dry_run, yes): """The main function""" config = read_config(conf) token = read_token(token_path) @@ -86,8 +90,9 @@ def main(conf, token_path, dry_run): print("Exiting without actions, as --dry-run was used.") sys.exit(0) - response = confirm('Do you want to continue to synchronize labels as described above?') - if response == False: + if yes: + print("Proceeding without confirmation, as --yes was used.") + elif confirm('Do you want to continue to synchronize labels as described above?') == False: sys.exit(0) ### Execute sync @@ -215,7 +220,12 @@ def read_config(path): def read_token(path): - with open(path, "r") as input: + # Prefer the token from the environment (e.g. an App installation token in CI), + # so unattended runs don't need to write the secret to disk. Fall back to the file. + env_token = os.environ.get(TOKEN_ENV_VAR) + if env_token: + return env_token.strip() + with open(os.path.expanduser(path), "r") as input: token = input.readline() return token.strip() From e990500f056a7e9338bec3fb3e7d66f8149ee769 Mon Sep 17 00:00:00 2001 From: Luca Rui Date: Fri, 24 Jul 2026 17:18:30 +0100 Subject: [PATCH 2/5] docs: document automated label sync; drop align-files fallback note Co-Authored-By: Claude Opus 4.8 --- .github/workflows/label-sync.yaml | 2 -- README.md | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/label-sync.yaml b/.github/workflows/label-sync.yaml index 356eeaa..5d19c74 100644 --- a/.github/workflows/label-sync.yaml +++ b/.github/workflows/label-sync.yaml @@ -51,8 +51,6 @@ jobs: # A dedicated giantswarm-label-sync App with: # - contents: read on giantswarm/giantswarm (leader labels + customers.yaml) # - issues: write on roadmap + all customer repos (labels live under Issues) - # Fallback: swap for the align-files App's ALIGN_FILES_APP_* creds, which - # already grant issues:write across installed repos. client-id: ${{ vars.LABEL_SYNC_APP_CLIENT_ID }} private-key: ${{ secrets.LABEL_SYNC_APP_PRIVATE_KEY }} owner: giantswarm diff --git a/README.md b/README.md index 8a48e16..ecab5d6 100644 --- a/README.md +++ b/README.md @@ -36,3 +36,21 @@ The tool will present you all actions that it _would_ take, then you have to con If used with `--dry-run`, no synchronization is happening and no confirmation is requested. Use the `--conf` option to specify a configuration file path other than the default `./config.yaml`. + +## Automated synchronization + +Label sync runs automatically once a week (Mondays 06:00 UTC) via the +`.github/workflows/label-sync.yaml` GitHub Actions workflow, so target repos stay in sync +without anyone running the container by hand. It authenticates as the dedicated +`giantswarm-label-sync` GitHub App (installed org-wide, `contents:read` on the leader plus +`issues:write` on the targets) and runs `cli.py --yes`. + +You can also trigger it manually from the **Actions** tab ("Sync labels to customer repos" → +Run workflow), with a `dry_run` toggle to preview the plan without applying any changes. + +### Unattended flags + +- `--yes` — apply the plan without the interactive confirmation prompt (for CI / cron runs). +- `GITHUB_TOKEN` env var — read the token from the environment instead of a `--token-path` + file, so unattended runs need not write the secret to disk. `--token-path` still works for + local use. From 1d28a73eae878a56b3c2cb5e9a3aed1d6407354c Mon Sep 17 00:00:00 2001 From: Luca Date: Tue, 8 Sep 2026 11:56:15 +0100 Subject: [PATCH 3/5] fix: explicit --token-path wins over GITHUB_TOKEN; alert #team-planeteers on failed cron Address review feedback on #116: - read_token: an explicit --token-path now takes precedence over the GITHUB_TOKEN env var, so a token exported for other tools (e.g. gh CLI) no longer silently overrides a file the user asked for. Precedence is now: explicit --token-path > GITHUB_TOKEN > ~/.github-token. - label-sync.yaml: post a warning with the run log link to #team-planeteers when a scheduled run fails, following the pattern used by the team-*-daily-digest workflows in giantswarm/github. - README: document both. --- .github/workflows/label-sync.yaml | 14 ++++++++++++++ README.md | 11 ++++++++--- cli.py | 16 ++++++++++------ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/.github/workflows/label-sync.yaml b/.github/workflows/label-sync.yaml index 5d19c74..badf470 100644 --- a/.github/workflows/label-sync.yaml +++ b/.github/workflows/label-sync.yaml @@ -67,3 +67,17 @@ jobs: else python cli.py --yes fi + + # A silently failing weekly cron is how label drift creeps back in, so make a + # broken scheduled run visible. Manual dispatches are watched by a human already. + - name: Report failed scheduled run to Slack + if: failure() && github.event_name == 'schedule' + env: + SLACK_WEBHOOK_URL: ${{ secrets.TEAM_PLANETEERS_SLACK_WEBHOOK_URL }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + jq -n --arg text ":warning: Weekly label sync to customer repos failed. Run log: $RUN_URL" \ + '{text: $text}' \ + | curl -sS -o /dev/null -w 'HTTP %{http_code}\n' -X POST -H 'Content-type: application/json' \ + --data @- "$SLACK_WEBHOOK_URL" diff --git a/README.md b/README.md index ecab5d6..b495931 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,11 @@ Run workflow), with a `dry_run` toggle to preview the plan without applying any ### Unattended flags - `--yes` — apply the plan without the interactive confirmation prompt (for CI / cron runs). -- `GITHUB_TOKEN` env var — read the token from the environment instead of a `--token-path` - file, so unattended runs need not write the secret to disk. `--token-path` still works for - local use. +- `GITHUB_TOKEN` env var — read the token from the environment instead of the default + `~/.github-token` file, so unattended runs need not write the secret to disk. An explicit + `--token-path` always takes precedence over the env var, so local use is unaffected by a + `GITHUB_TOKEN` exported for other tools (e.g. the `gh` CLI). + +If a scheduled run fails, the workflow posts a warning with the run log link to +`#team-planeteers` (via the `TEAM_PLANETEERS_SLACK_WEBHOOK_URL` secret), so a broken Monday +run does not silently let label drift creep back in. diff --git a/cli.py b/cli.py index dba224f..d2dfc7c 100644 --- a/cli.py +++ b/cli.py @@ -7,6 +7,7 @@ import yaml TOKEN_ENV_VAR = 'GITHUB_TOKEN' +DEFAULT_TOKEN_PATH = '~/.github-token' RULE_INCLUDE = 'include' RULE_IGNORE = 'ignore' @@ -24,7 +25,7 @@ class RepoArchivedException(Exception): @click.command() @click.option('--conf', default="./config.yaml", help="Configuration file path.") -@click.option('--token-path', default="~/.github-token", help="Github token path.") +@click.option('--token-path', default=None, help=f"Github token path (default: {DEFAULT_TOKEN_PATH}, unless the {TOKEN_ENV_VAR} env var is set).") @click.option('--dry-run', default=False, is_flag=True, help="Show what you would do, but don't do it.") @click.option('--yes', default=False, is_flag=True, help="Apply the plan without interactive confirmation (for unattended/CI runs).") def main(conf, token_path, dry_run, yes): @@ -220,11 +221,14 @@ def read_config(path): def read_token(path): - # Prefer the token from the environment (e.g. an App installation token in CI), - # so unattended runs don't need to write the secret to disk. Fall back to the file. - env_token = os.environ.get(TOKEN_ENV_VAR) - if env_token: - return env_token.strip() + # Precedence: an explicit --token-path always wins. Otherwise prefer the token + # from the environment (e.g. an App installation token in CI, so nothing touches + # disk), then fall back to the default token file. + if path is None: + env_token = os.environ.get(TOKEN_ENV_VAR) + if env_token: + return env_token.strip() + path = DEFAULT_TOKEN_PATH with open(os.path.expanduser(path), "r") as input: token = input.readline() return token.strip() From 91f767ce1dc37ba0ed10ad8898b95c5e55c90df5 Mon Sep 17 00:00:00 2001 From: Luca Rui Date: Tue, 15 Sep 2026 21:50:19 +0100 Subject: [PATCH 4/5] ci: fail the Slack alert step when the webhook returns an error curl exits 0 on an HTTP 4xx/5xx unless told otherwise, so a rejected webhook call left the failure step green and the alert never arrived. --fail-with-body makes the step fail and prints Slack's response. --- .github/workflows/label-sync.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/label-sync.yaml b/.github/workflows/label-sync.yaml index badf470..c24f0a2 100644 --- a/.github/workflows/label-sync.yaml +++ b/.github/workflows/label-sync.yaml @@ -79,5 +79,5 @@ jobs: set -euo pipefail jq -n --arg text ":warning: Weekly label sync to customer repos failed. Run log: $RUN_URL" \ '{text: $text}' \ - | curl -sS -o /dev/null -w 'HTTP %{http_code}\n' -X POST -H 'Content-type: application/json' \ + | curl -sS --fail-with-body -X POST -H 'Content-type: application/json' \ --data @- "$SLACK_WEBHOOK_URL" From 36b3f20beaa985d0aef9019fc49d8e5bfc72a4dd Mon Sep 17 00:00:00 2001 From: Luca Rui Date: Tue, 15 Sep 2026 21:50:19 +0100 Subject: [PATCH 5/5] fix: catch GithubException on label create and update, end the run red on failures `except github.GithubException.GithubException` raises AttributeError on PyGithub 2.x, because `github.GithubException` already is the exception class. Any API error on create_label therefore crashed the run instead of being logged. The update branch had no handler at all. Both branches now share one handler: log the error, continue with the rest of the plan, and exit 1 at the end when anything failed, so an unattended run does not look green while labels were not applied. --- cli.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/cli.py b/cli.py index d2dfc7c..f43859b 100644 --- a/cli.py +++ b/cli.py @@ -103,19 +103,27 @@ def main(conf, token_path, dry_run, yes): repo_handlers[repo] = repo = g.get_repo(f"{config['github']['organization']}/{repo}") print('\nExecuting synchronization plan') + failures = 0 for job in jobs: (repo, label, action) = job print(f'{repo}: {action} label {label}') - if action == JOB_ACTION_CREATE: - try: + try: + if action == JOB_ACTION_CREATE: repo_handlers[repo].create_label(name=leader_labels[label].name, color=leader_labels[label].color, description=leader_labels[label].description) - except github.GithubException.GithubException as e: - print(f'ERROR: {e}') - elif action == JOB_ACTION_EDIT: - desc = leader_labels[label].description - if desc is None or desc == '': - desc = github.GithubObject.NotSet - target_labels[repo][label].edit(name=leader_labels[label].name, color=leader_labels[label].color, description=desc) + elif action == JOB_ACTION_EDIT: + desc = leader_labels[label].description + if desc is None or desc == '': + desc = github.GithubObject.NotSet + target_labels[repo][label].edit(name=leader_labels[label].name, color=leader_labels[label].color, description=desc) + except github.GithubException as e: + # Log and carry on, so one broken label does not block the rest of the plan. + print(f'ERROR: {e}') + failures += 1 + + if failures > 0: + # Still end the run red: an unattended run must not look green when labels + # were not applied, otherwise the Slack alert for the schedule never fires. + error(f'{failures} of {len(jobs)} label operations failed.') def read_repo_labels(github_client, organization, reponame, filter_rules=None):