From 8f0de099a5ded0a0796d52234ef1410ddbb57240 Mon Sep 17 00:00:00 2001 From: Salvydas Lukosius Date: Sat, 23 May 2026 02:11:43 +0100 Subject: [PATCH] feat(labels): add gated apply-mode pilot --- runbooks/labels.md | 40 +++++- scripts/labels-dry-run.rb | 223 +++++++++++++++++++++++++++++++-- scripts/test-labels-dry-run.sh | 60 +++++++++ 3 files changed, 312 insertions(+), 11 deletions(-) create mode 100755 scripts/test-labels-dry-run.sh diff --git a/runbooks/labels.md b/runbooks/labels.md index 4cf982f46..09a01b7da 100644 --- a/runbooks/labels.md +++ b/runbooks/labels.md @@ -116,7 +116,7 @@ Do not delete unknown labels in bulk. If a repository has a local label that is ## Dry-run script -`scripts/labels-dry-run.rb` is read-only. It consumes `lib/labels.yml`, queries GitHub through `gh api`, and reports: +`scripts/labels-dry-run.rb` is read-only by default. It consumes `lib/labels.yml`, queries GitHub through `gh api`, and reports: - canonical labels that would be created - canonical labels whose color or description would be updated @@ -136,6 +136,44 @@ scripts/labels-dry-run.rb --repo z-shell/zi --repo z-shell/wiki scripts/labels-dry-run.rb --repo z-shell/zi --json ``` +## Apply-mode pilot + +Apply mode is intentionally limited while #411 is piloted: + +- default mode remains read-only; +- `--apply` previews canonical label create/update operations without mutating anything; +- `--apply --confirm-apply` may only create missing canonical labels and update canonical label metadata; +- it does not delete unknown labels; +- it does not delete legacy labels; +- it does not migrate labels on issues or pull requests; +- org-wide apply is disabled during the pilot; +- confirmed apply requires explicit `--repo` values; +- confirmed apply is limited to the temporary pilot allowlist unless a maintainer explicitly approves `--allow-non-pilot-repo`. + +Preview commands: + +```sh +# Preview canonical create/update operations for one repo. +scripts/labels-dry-run.rb --repo z-shell/REPO --apply + +# Preview in JSON for artifact comparison. +scripts/labels-dry-run.rb --repo z-shell/REPO --apply --json +``` + +Confirmed apply commands require maintainer approval because they mutate GitHub labels: + +```sh +# No-op safety apply on the clean org metadata repo. +scripts/labels-dry-run.rb --repo z-shell/.github --apply --confirm-apply --include-clean + +# Approved pilot outside the temporary allowlist. +scripts/labels-dry-run.rb \ + --repo z-shell/REPO \ + --apply \ + --confirm-apply \ + --allow-non-pilot-repo +``` + ## See also - `.github/lib/labels.yml` diff --git a/scripts/labels-dry-run.rb b/scripts/labels-dry-run.rb index c9c818799..19d700a1c 100755 --- a/scripts/labels-dry-run.rb +++ b/scripts/labels-dry-run.rb @@ -1,20 +1,29 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# Dry-run z-shell label synchronization audit. +# Dry-run z-shell label synchronization audit, with a tightly gated apply-mode +# pilot for canonical label create/update operations. # -# Reads lib/labels.yml and compares it with one or more GitHub repositories. -# This script is intentionally read-only: it uses only GET-style `gh api` calls -# and never creates, updates, deletes, or migrates labels. +# Default behavior is intentionally read-only: it uses only GET-style `gh api` +# calls and never creates, updates, deletes, or migrates labels unless both +# `--apply` and `--confirm-apply` are passed for explicit `--repo` targets. require "json" require "open3" require "optparse" +require "uri" require "yaml" ROOT = File.expand_path("..", __dir__) DEFAULT_LABELS_FILE = File.join(ROOT, "lib", "labels.yml") +# Temporary pilot allowlist. Keep this intentionally tiny until #411 has one +# reviewed create/update-only pilot result. Use --allow-non-pilot-repo only +# after maintainer approval. +PILOT_APPLY_REPOS = [ + "z-shell/.github" +].freeze + Options = Struct.new( :labels_file, :org, @@ -22,6 +31,9 @@ :all_repos, :json, :include_clean, + :apply, + :confirm_apply, + :allow_non_pilot_repo, keyword_init: true ) @@ -31,7 +43,10 @@ repos: [], all_repos: false, json: false, - include_clean: false + include_clean: false, + apply: false, + confirm_apply: false, + allow_non_pilot_repo: false ) parser = OptionParser.new do |opts| @@ -61,13 +76,31 @@ options.include_clean = true end + opts.on("--apply", "Preview canonical label create/update operations for explicit repos") do + options.apply = true + end + + opts.on("--confirm-apply", "Actually apply --apply canonical label create/update operations") do + options.confirm_apply = true + end + + opts.on("--allow-non-pilot-repo", "Allow confirmed apply outside the temporary pilot allowlist") do + options.allow_non_pilot_repo = true + end + opts.on("-h", "--help", "Show this help") do puts opts exit 0 end end -parser.parse! +begin + parser.parse! +rescue OptionParser::ParseError => e + warn parser + warn "\nerror: #{e.message}" + exit 2 +end if options.all_repos && !options.repos.empty? warn parser @@ -81,6 +114,27 @@ exit 2 end +if options.confirm_apply && !options.apply + warn parser + warn "\nerror: --confirm-apply requires --apply" + exit 2 +end + +if options.apply && options.all_repos + warn parser + warn "\nerror: --apply is only allowed with explicit --repo values during the pilot" + exit 2 +end + +if options.apply && options.confirm_apply && !options.allow_non_pilot_repo + outside_pilot = options.repos.reject { |repo| PILOT_APPLY_REPOS.include?(repo) } + unless outside_pilot.empty? + warn "error: apply pilot is limited to: #{PILOT_APPLY_REPOS.join(', ')}" + warn "rerun with --allow-non-pilot-repo only after maintainer approval" + exit 2 + end +end + def gh_json(*args) stdout, stderr, status = Open3.capture3("gh", *args) unless status.success? @@ -100,6 +154,38 @@ def gh_paginated_array(path) stdout.lines.reject { |line| line.strip.empty? }.map { |line| JSON.parse(line) } end +def gh_api_mutation(*args) + stdout, stderr, status = Open3.capture3("gh", "api", *args) + unless status.success? + raise "gh api #{args.join(' ')} failed: #{stderr.strip.empty? ? stdout.strip : stderr.strip}" + end + stdout.strip.empty? ? {} : JSON.parse(stdout) +end + +def label_path_segment(name) + URI.encode_www_form_component(name).gsub("+", "%20") +end + +def create_label(owner_repo, label) + gh_api_mutation( + "repos/#{owner_repo}/labels", + "--method", "POST", + "-f", "name=#{label.fetch('name')}", + "-f", "color=#{label.fetch('color')}", + "-f", "description=#{label.fetch('description')}" + ) +end + +def update_label(owner_repo, label) + gh_api_mutation( + "repos/#{owner_repo}/labels/#{label_path_segment(label.fetch('name'))}", + "--method", "PATCH", + "-f", "new_name=#{label.fetch('name')}", + "-f", "color=#{label.fetch('color')}", + "-f", "description=#{label.fetch('description')}" + ) +end + def repo_list(org) gh_json("repo", "list", org, "--limit", "1000", "--json", "nameWithOwner").map { |repo| repo.fetch("nameWithOwner") } end @@ -191,19 +277,99 @@ def diff_repo(owner_repo, canonical, legacy_migrations) } end +def planned_label_operations(result, canonical) + creates = result.fetch("missing").map { |name| canonical.fetch(name) } + updates = result.fetch("updates").map { |item| canonical.fetch(item.fetch("name")) } + + { + "would_create" => creates, + "would_update" => updates, + "skipped_legacy" => result.fetch("legacy_present").map { |item| item.fetch("legacy") }, + "skipped_unknown" => result.fetch("unknown") + } +end + +def apply_label_operations(owner_repo, operations) + result = { + "created" => [], + "updated" => [], + "skipped_legacy" => operations.fetch("skipped_legacy"), + "skipped_unknown" => operations.fetch("skipped_unknown"), + "errors" => [] + } + + operations.fetch("would_create").each do |label| + begin + create_label(owner_repo, label) + result.fetch("created") << label.fetch("name") + rescue StandardError => e + result.fetch("errors") << { + "operation" => "create", + "label" => label.fetch("name"), + "message" => e.message + } + return result + end + end + + operations.fetch("would_update").each do |label| + begin + update_label(owner_repo, label) + result.fetch("updated") << label.fetch("name") + rescue StandardError => e + result.fetch("errors") << { + "operation" => "update", + "label" => label.fetch("name"), + "message" => e.message + } + return result + end + end + + result +end + def clean?(result) result.fetch("summary").values.all?(&:zero?) end +def print_label_list(title, labels) + return if labels.empty? + + puts title + labels.each do |label| + name = label.is_a?(Hash) ? label.fetch("name") : label + puts "- #{name}" + end + puts +end + canonical, legacy_migrations, sync_policy = canonical_label_map(options.labels_file) repos = options.all_repos ? repo_list(options.org) : options.repos results = repos.sort.map { |repo| diff_repo(repo, canonical, legacy_migrations) } +if options.apply + results.each do |result| + result["operations"] = planned_label_operations(result, canonical) + end +end + +apply_failed = false +if options.apply && options.confirm_apply + results.each do |result| + result["applied"] = apply_label_operations(result.fetch("repo"), result.fetch("operations")) + apply_failed ||= !result.fetch("applied").fetch("errors").empty? + end +end + payload = { + "mode" => options.apply ? (options.confirm_apply ? "apply" : "apply-preview") : "dry-run", + "confirmed" => options.confirm_apply, "labels_file" => options.labels_file, "canonical_labels" => canonical.length, "legacy_migrations" => legacy_migrations.length, "sync_policy" => sync_policy, + "pilot_apply_repos" => PILOT_APPLY_REPOS, "repos_scanned" => results.length, "repos_with_drift" => results.count { |result| !clean?(result) }, "results" => results @@ -211,19 +377,31 @@ def clean?(result) if options.json puts JSON.pretty_generate(payload) - exit 0 + exit(apply_failed ? 1 : 0) end -puts "# Label sync dry-run" +heading = options.apply ? "# Label sync apply preview" : "# Label sync dry-run" +heading = "# Label sync apply result" if options.apply && options.confirm_apply +puts heading puts +puts "Mode: #{payload.fetch('mode')}" puts "Labels file: `#{options.labels_file}`" puts "Canonical labels: #{canonical.length}" puts "Legacy migrations: #{legacy_migrations.length}" puts "Repos scanned: #{results.length}" puts "Repos with drift: #{payload.fetch('repos_with_drift')}" puts -puts "This is a read-only dry run. No labels or issues were changed." + +if options.apply && options.confirm_apply + puts "Confirmed apply mode: canonical labels may have been created or updated." +elsif options.apply + puts "Apply preview only. Pass --confirm-apply to create/update canonical labels." +else + puts "This is a read-only dry run. No labels or issues were changed." +end +puts "Legacy and unknown labels are skipped/preserved; no labels are deleted." puts + puts "## Sync policy" puts sync_policy.each do |key, value| @@ -231,14 +409,39 @@ def clean?(result) end puts +if options.apply + puts "## Apply pilot guardrails" + puts + puts "- org-wide apply is disabled" + puts "- confirmed apply requires explicit --repo values" + puts "- pilot allowlist: #{PILOT_APPLY_REPOS.join(', ')}" + puts "- use --allow-non-pilot-repo only after maintainer approval" + puts +end + results.each do |result| - next if clean?(result) && !options.include_clean + next if clean?(result) && !options.include_clean && !options.apply puts "## #{result.fetch('repo')}" puts if clean?(result) puts "Clean: no missing, mismatched, legacy, or unknown labels." puts + end + + if options.apply + operations = result.fetch("operations") + print_label_list("### Would create", operations.fetch("would_create")) + print_label_list("### Would update", operations.fetch("would_update")) + print_label_list("### Skipped legacy labels", operations.fetch("skipped_legacy")) + print_label_list("### Skipped unknown local labels", operations.fetch("skipped_unknown")) + + if result.key?("applied") + applied = result.fetch("applied") + print_label_list("### Created", applied.fetch("created")) + print_label_list("### Updated", applied.fetch("updated")) + print_label_list("### Apply errors", applied.fetch("errors")) + end next end diff --git a/scripts/test-labels-dry-run.sh b/scripts/test-labels-dry-run.sh new file mode 100755 index 000000000..ef1cbf3e9 --- /dev/null +++ b/scripts/test-labels-dry-run.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +SCRIPT="$ROOT/scripts/labels-dry-run.rb" +TMPDIR=${TMPDIR:-/tmp} +TEST_TMP=$(mktemp -d "$TMPDIR/labels-dry-run-test.XXXXXX") +OUT="$TEST_TMP/out" +ERR="$TEST_TMP/err" +trap 'rm -rf "$TEST_TMP"' EXIT HUP INT TERM + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +assert_success() { + "$@" >"$OUT" 2>"$ERR" || { + cat "$ERR" >&2 + fail "expected success: $*" + } +} + +assert_exit() { + expected=$1 + shift + set +e + "$@" >"$OUT" 2>"$ERR" + code=$? + set -e + [ "$code" = "$expected" ] || { + cat "$OUT" >&2 || true + cat "$ERR" >&2 || true + fail "expected exit $expected, got $code: $*" + } +} + +assert_json_field() { + expected=$1 + shift + "$@" | ruby -rjson -e "data=JSON.parse(STDIN.read); actual=data.fetch('mode'); abort %(expected mode #{ARGV.fetch(0)}, got #{actual}) unless actual == ARGV.fetch(0)" "$expected" +} + +ruby -c "$SCRIPT" >/dev/null + +# Existing refusal paths. +assert_exit 2 "$SCRIPT" +assert_exit 2 "$SCRIPT" --repo z-shell/.github --all-repos +assert_exit 2 "$SCRIPT" --repo + +# New apply-mode guardrails. +assert_exit 2 "$SCRIPT" --repo z-shell/.github --confirm-apply +assert_exit 2 "$SCRIPT" --all-repos --apply +assert_exit 2 "$SCRIPT" --all-repos --apply --confirm-apply +assert_exit 2 "$SCRIPT" --repo z-shell/zi --apply --confirm-apply + +# Apply preview must be machine-readable and non-mutating. +assert_json_field apply-preview "$SCRIPT" --repo z-shell/.github --apply --json + +printf 'labels-dry-run smoke tests passed\n'