diff --git a/.github/workflows/label-sync.yaml b/.github/workflows/label-sync.yaml new file mode 100644 index 0000000..c24f0a2 --- /dev/null +++ b/.github/workflows/label-sync.yaml @@ -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" diff --git a/README.md b/README.md index 8a48e16..b495931 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cli.py b/cli.py index f3c9bfe..f43859b 100644 --- a/cli.py +++ b/cli.py @@ -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' @@ -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) @@ -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 @@ -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): @@ -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()