Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/workflows/label-sync.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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)
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

# 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 --fail-with-body -X POST -H 'Content-type: application/json' \
--data @- "$SLACK_WEBHOOK_URL"
Comment on lines +79 to +83

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curl without --fail exits 0 on 403/404/invalid_token, so a rotated or mistyped webhook makes this step pass green while the alert goes nowhere — the exact silent-failure mode the step exists to prevent. set -euo pipefail doesn't cover it, since curl itself succeeded. -w 'HTTP %{http_code}' puts the status in the log, but nobody reads the log of a green run.

Suggested change
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"
set -euo pipefail
jq -n --arg text ":warning: Weekly label sync to customer repos failed. Run log: $RUN_URL" \
'{text: $text}' \
| curl -sS --fail-with-body -X POST -H 'Content-type: application/json' \
--data @- "$SLACK_WEBHOOK_URL"

Generated by Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied as suggested in 91f767c: --fail-with-body replaces -o /dev/null -w 'HTTP %{http_code}', so a rejected webhook call now fails the step and Slack's response body lands in the log instead of a green step with HTTP 403.

23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,26 @@ 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 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.
50 changes: 36 additions & 14 deletions cli.py

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 112 — the per-label error handler raises instead of catching (pre-existing, outside this diff, so no inline anchor)

except github.GithubException.GithubException as e:

github.GithubException is the exception class, not the submodule, so this attribute lookup raises AttributeError the moment a real GithubException occurs. Verified against the pinned PyGithub 2.10.0:

>>> github.GithubException
<class 'github.GithubException.GithubException'>
>>> github.GithubException.GithubException
AttributeError: type object 'GithubException' has no attribute 'GithubException'

Effect once this runs unattended: a single API error on create_label (rate limit, a repo the App isn't installed on, a permissions gap) aborts the run with a traceback and a half-applied plan, instead of logging and continuing to the next label. It does at least fail loudly, so the new Slack step fires. except github.GithubException as e: is what was meant.

Related, same block: the JOB_ACTION_EDIT branch calls target_labels[repo][label].edit(...) at line 118 with no handler at all, so create and update behave differently on error. Worth making them consistent while you're in there.


Generated by Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 36b3f20. The handler is except github.GithubException as e: and the edit() branch sits in the same try, so create and update behave the same on an API error: log ERROR: ... and continue with the next job.

One deliberate addition beyond the literal fix: the loop counts failures and, after the plan has run through, calls error() so the run exits 1 when anything failed. With the corrected handler alone a run with N failed labels would have exited 0 and the Slack step would never fire — the "fails loudly" property you noted would have been lost. Now the plan still runs to completion and the run still ends red.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 103 — execute phase hardcodes the org for customer repos (pre-existing, outside this diff; latent, not a live bug — confirmed all customer repos are in the giantswarm org today)

Read phase honours the per-entry org, execute phase does not:

# read
target_labels[cr['repository']], _ = read_repo_labels(g, cr['organization'], cr['repository'], config['rules'])
# execute
repo_handlers[repo] = repo = g.get_repo(f"{config['github']['organization']}/{repo}")

Since every customers.yaml entry is under giantswarm, both resolve to the same repo and nothing is wrong in practice — flagging only because the unattended weekly run changes the blast radius if that ever stops being true: the first non-giantswarm entry would either 404 mid-plan or, with a same-named repo in our org, write labels into the wrong repository, with no human watching. Keying target_labels by org/name and reusing cr['organization'] in the execute phase would close it off; a follow-up issue is fine, not this PR.


Generated by Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, keeping this out of the PR. Tracked in #119 with your proposed fix: key target_labels by org/name and reuse cr['organization'] in the execute phase.

Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import click
import os
import re
import sys

import github
import yaml

TOKEN_ENV_VAR = 'GITHUB_TOKEN'
DEFAULT_TOKEN_PATH = '~/.github-token'

RULE_INCLUDE = 'include'
RULE_IGNORE = 'ignore'

Expand All @@ -21,9 +25,10 @@ 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.")
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)
Expand Down Expand Up @@ -86,8 +91,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
Expand All @@ -97,19 +103,27 @@ def main(conf, token_path, dry_run):
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):
Expand Down Expand Up @@ -215,7 +229,15 @@ def read_config(path):


def read_token(path):
with open(path, "r") as input:
# 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()

Expand Down