From 5349685608083d009356dc92424eaa3b325f3334 Mon Sep 17 00:00:00 2001 From: Ming Date: Sat, 11 Jul 2026 08:50:33 +0800 Subject: [PATCH 1/8] feat: define organization label policy --- .github/label-policy.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/label-policy.yml diff --git a/.github/label-policy.yml b/.github/label-policy.yml new file mode 100644 index 0000000..48ae778 --- /dev/null +++ b/.github/label-policy.yml @@ -0,0 +1,37 @@ +version: 1 + +managed: + # 这些前缀定义组织拥有的标签命名空间。删除前缀下的正式标签后, + # 同步器仍会清理各仓库中遗留的同前缀标签。 + prefixes: + - "type:" + - "status:" + - "priority:" + - "impact:" + - "process:" + - "resolution:" + + # 没有组织前缀、但由 MathArts 统一管理的 GitHub 约定标签。 + exact_names: + - "good first issue" + - "help wanted" + + # 组织曾经拥有且仍需清理的旧标签名。 + # 从 labels.yml 的 aliases 中移除迁移映射后,也应暂时保留在这里, + # 直到所有 Allowlist 仓库都确认完成清理。 + legacy_names: + - "bug" + - "enhancement" + - "documentation" + - "duplicate" + - "wontfix" + +repositories: + # 只有显式列出的仓库会被预览或修改。新增仓库不会自动进入同步范围。 + include: + - ".github" + - "ecosystem" + - "epheon" + - "matharts" + - "skills" + - "ziwei" From 72d73e9f7028794cc286b78c0c594fd3bc4d9a3b Mon Sep 17 00:00:00 2001 From: Ming Date: Sat, 11 Jul 2026 08:50:42 +0800 Subject: [PATCH 2/8] feat: pass label policy to synchronization --- .github/actions/sync-labels/action.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/actions/sync-labels/action.yml b/.github/actions/sync-labels/action.yml index 33de43f..1a855e1 100644 --- a/.github/actions/sync-labels/action.yml +++ b/.github/actions/sync-labels/action.yml @@ -1,5 +1,5 @@ name: "MathArts Sync Labels" -description: "同步组织级受管标签,同时保留仓库自行维护的扩展标签" +description: "按组织策略同步受管标签,同时保留仓库自行维护的扩展标签" inputs: token: @@ -15,13 +15,18 @@ inputs: required: false default: ".github/labels.yml" + policy_file: + description: "标签所有权和仓库 Allowlist 策略文件路径" + required: false + default: ".github/label-policy.yml" + dry_run: description: "仅预览变更,不实际修改标签" required: false default: "true" repository: - description: "只同步指定仓库;留空时同步全部仓库" + description: "只同步 Allowlist 中的指定仓库;留空时同步全部 Allowlist 仓库" required: false default: "" @@ -34,12 +39,13 @@ runs: using: "composite" steps: - - name: Validate and synchronize labels + - name: Validate policy and synchronize labels shell: bash env: SYNC_LABELS_TOKEN: ${{ inputs.token }} SYNC_LABELS_OWNER: ${{ inputs.owner }} SYNC_LABELS_CONFIG_FILE: ${{ inputs.config_file }} + SYNC_LABELS_POLICY_FILE: ${{ inputs.policy_file }} SYNC_LABELS_DRY_RUN: ${{ inputs.dry_run }} SYNC_LABELS_REPOSITORY: ${{ inputs.repository }} SYNC_LABELS_API_URL: ${{ inputs.api_url }} From 90dee43cef7390ebd1872c8c0d8c1cf31bd649e9 Mon Sep 17 00:00:00 2001 From: Ming Date: Sat, 11 Jul 2026 08:51:25 +0800 Subject: [PATCH 3/8] feat: enforce label ownership and repository allowlist --- .github/actions/sync-labels/sync-labels.rb | 203 ++++++++++++++++----- 1 file changed, 156 insertions(+), 47 deletions(-) diff --git a/.github/actions/sync-labels/sync-labels.rb b/.github/actions/sync-labels/sync-labels.rb index 9a07e9f..a09d227 100644 --- a/.github/actions/sync-labels/sync-labels.rb +++ b/.github/actions/sync-labels/sync-labels.rb @@ -10,26 +10,11 @@ TOKEN = ENV.fetch("SYNC_LABELS_TOKEN", "") OWNER = ENV.fetch("SYNC_LABELS_OWNER", "") CONFIG_FILE = ENV.fetch("SYNC_LABELS_CONFIG_FILE", ".github/labels.yml") +POLICY_FILE = ENV.fetch("SYNC_LABELS_POLICY_FILE", ".github/label-policy.yml") ONLY_REPOSITORY = ENV.fetch("SYNC_LABELS_REPOSITORY", "").strip API_URL = ENV.fetch("SYNC_LABELS_API_URL", "https://api.github.com").sub(%r{/\z}, "") DRY_RUN = %w[1 true yes on].include?(ENV.fetch("SYNC_LABELS_DRY_RUN", "true").downcase) -MANAGED_PREFIXES = %w[ - type: - status: - priority: - impact: - process: - resolution: -].freeze - -MANAGED_EXACT_NAMES = Set.new( - [ - "good first issue", - "help wanted" - ] -).freeze - class GitHubApi def initialize(token:, base_url:) @token = token @@ -142,13 +127,21 @@ def repository_path(full_name) "#{escape_segment(owner)}/#{escape_segment(repository)}" end -def load_labels(path) - parsed = YAML.safe_load( +def load_yaml(path, description) + YAML.safe_load( File.read(path, encoding: "UTF-8"), permitted_classes: [], permitted_symbols: [], aliases: false ) +rescue Errno::ENOENT + raise "找不到#{description}:#{path}" +rescue Psych::SyntaxError => error + raise "#{description} YAML 无效:#{error.message}" +end + +def load_labels(path) + parsed = load_yaml(path, "标签配置文件") raise "#{path} 的 YAML 根节点必须是数组。" unless parsed.is_a?(Array) raise "#{path} 不能为空。" if parsed.empty? @@ -206,30 +199,155 @@ def load_labels(path) end labels -rescue Errno::ENOENT - raise "找不到标签配置文件:#{path}" -rescue Psych::SyntaxError => error - raise "标签配置 YAML 无效:#{error.message}" end -def managed_label?(name, alias_keys) +def policy_string_list(container, key, path, allow_empty: false) + values = container[key] + raise "#{path} 的 #{key} 必须是数组。" unless values.is_a?(Array) + + normalized = values.map { |value| value.to_s.strip } + raise "#{path} 的 #{key} 不能包含空值。" if normalized.any?(&:empty?) + raise "#{path} 的 #{key} 不能为空。" if !allow_empty && normalized.empty? + + keys = normalized.map { |value| label_key(value) } + raise "#{path} 的 #{key} 包含重复值。" unless keys.uniq.length == keys.length + + normalized +end + +def load_policy(path) + parsed = load_yaml(path, "标签同步策略文件") + raise "#{path} 的 YAML 根节点必须是对象。" unless parsed.is_a?(Hash) + + allowed_root = Set.new(%w[version managed repositories]) + unknown_root = parsed.keys.map(&:to_s).to_set - allowed_root + raise "#{path} 包含未知根字段:#{unknown_root.to_a.sort.join(', ')}" unless unknown_root.empty? + raise "#{path} 的 version 必须是 1。" unless parsed["version"] == 1 + + managed = parsed["managed"] + repositories = parsed["repositories"] + raise "#{path} 的 managed 必须是对象。" unless managed.is_a?(Hash) + raise "#{path} 的 repositories 必须是对象。" unless repositories.is_a?(Hash) + + allowed_managed = Set.new(%w[prefixes exact_names legacy_names]) + unknown_managed = managed.keys.map(&:to_s).to_set - allowed_managed + raise "#{path} 的 managed 包含未知字段:#{unknown_managed.to_a.sort.join(', ')}" unless unknown_managed.empty? + + allowed_repositories = Set.new(%w[include]) + unknown_repositories = repositories.keys.map(&:to_s).to_set - allowed_repositories + unless unknown_repositories.empty? + raise "#{path} 的 repositories 包含未知字段:#{unknown_repositories.to_a.sort.join(', ')}" + end + + prefixes = policy_string_list(managed, "prefixes", path) + exact_names = policy_string_list(managed, "exact_names", path, allow_empty: true) + legacy_names = policy_string_list(managed, "legacy_names", path, allow_empty: true) + repository_names = policy_string_list(repositories, "include", path) + + invalid_prefix = prefixes.find { |prefix| !prefix.end_with?(":") } + raise "#{path} 的受管前缀必须以冒号结尾:#{invalid_prefix}" if invalid_prefix + + invalid_repository = repository_names.find { |name| !name.match?(/\A[A-Za-z0-9._-]+\z/) } + raise "#{path} 包含无效仓库名称:#{invalid_repository}" if invalid_repository + + exact_keys = exact_names.map { |name| label_key(name) }.to_set + legacy_keys = legacy_names.map { |name| label_key(name) }.to_set + overlap = exact_keys & legacy_keys + raise "#{path} 的 exact_names 与 legacy_names 不能重叠:#{overlap.to_a.sort.join(', ')}" unless overlap.empty? + + { + prefixes: prefixes.map { |prefix| label_key(prefix) }.freeze, + exact_names: exact_keys.freeze, + legacy_names: legacy_keys.freeze, + repositories: repository_names.freeze + }.freeze +end + +def desired_label_managed?(name, policy) key = label_key(name) - MANAGED_PREFIXES.any? { |prefix| key.start_with?(prefix) } || - MANAGED_EXACT_NAMES.include?(key) || - alias_keys.include?(key) + policy[:prefixes].any? { |prefix| key.start_with?(prefix) } || policy[:exact_names].include?(key) +end + +def managed_label?(name, policy) + desired_label_managed?(name, policy) || policy[:legacy_names].include?(label_key(name)) +end + +def validate_label_policy!(labels, policy) + unmanaged = labels.reject { |label| desired_label_managed?(label["name"], policy) }.map { |label| label["name"] } + unless unmanaged.empty? + raise "标签配置包含不在组织受管范围内的正式标签:#{unmanaged.sort.join(', ')}" + end + + aliases = labels.flat_map { |label| label["aliases"] }.map { |name| label_key(name) }.to_set + missing_legacy = aliases - policy[:legacy_names] + unless missing_legacy.empty? + raise "标签 aliases 必须同时登记到策略 legacy_names:#{missing_legacy.to_a.sort.join(', ')}" + end + + desired = labels.map { |label| label_key(label["name"]) }.to_set + legacy_conflicts = desired & policy[:legacy_names] + unless legacy_conflicts.empty? + raise "策略 legacy_names 不能同时是正式标签:#{legacy_conflicts.to_a.sort.join(', ')}" + end +end + +def normalize_requested_repository(owner, value) + return "" if value.empty? + + parts = value.split("/", 2) + if parts.length == 2 + requested_owner, repository = parts + unless requested_owner.casecmp(owner).zero? + raise "指定仓库不属于 #{owner} 组织:#{value}" + end + return repository + end + + value +end + +def load_repositories(api, owner, policy, only_repository) + names = policy[:repositories] + requested = normalize_requested_repository(owner, only_repository) + + unless requested.empty? + selected = names.find { |name| name.casecmp(requested).zero? } + raise "仓库 #{owner}/#{requested} 不在标签同步 Allowlist 中。" unless selected + + names = [selected] + end + + names.map do |name| + repository = api.get("/repos/#{escape_segment(owner)}/#{escape_segment(name)}") + raise "GitHub API 未返回仓库对象:#{owner}/#{name}" unless repository.is_a?(Hash) + + full_name = repository["full_name"].to_s + unless full_name.casecmp("#{owner}/#{name}").zero? + raise "Allowlist 仓库解析不一致:期望 #{owner}/#{name},实际 #{full_name.inspect}" + end + + unsupported_states = [] + unsupported_states << "archived" if repository["archived"] + unsupported_states << "disabled" if repository["disabled"] + unsupported_states << "fork" if repository["fork"] + unless unsupported_states.empty? + raise "Allowlist 仓库 #{full_name} 处于不可同步状态:#{unsupported_states.join(', ')}。请先更新策略。" + end + + repository + end end def mutate(api, dry_run, method, *arguments) api.public_send(method, *arguments) unless dry_run end -def sync_repository(api, full_name, desired_labels, dry_run:) +def sync_repository(api, full_name, desired_labels, policy:, dry_run:) path = repository_path(full_name) existing = api.paginate("/repos/#{path}/labels") labels_by_name = existing.to_h { |label| [label_key(label["name"]), label] } desired_keys = desired_labels.map { |label| label_key(label["name"]) }.to_set - alias_keys = desired_labels.flat_map { |label| label["aliases"] }.map { |name| label_key(name) }.to_set counts = { created: 0, @@ -329,9 +447,7 @@ def sync_repository(api, full_name, desired_labels, dry_run:) end remaining = labels_by_name.values.reject { |label| desired_keys.include?(label_key(label["name"])) } - stale_managed, repository_specific = remaining.partition do |label| - managed_label?(label["name"], alias_keys) - end + stale_managed, repository_specific = remaining.partition { |label| managed_label?(label["name"], policy) } stale_managed.sort_by { |label| label["name"].downcase }.each do |label| name = label.fetch("name") @@ -360,9 +476,10 @@ def write_summary(results, failures) summary.puts "# 标签同步结果" summary.puts summary.puts "- 组织:`#{OWNER}`" - summary.puts "- 配置:`#{CONFIG_FILE}`" + summary.puts "- 标签配置:`#{CONFIG_FILE}`" + summary.puts "- 同步策略:`#{POLICY_FILE}`" summary.puts "- Dry Run:`#{DRY_RUN}`" - summary.puts "- 模式:组织级标签受管子集" + summary.puts "- 模式:组织级受管标签 + 仓库 Allowlist" summary.puts summary.puts "| 仓库 | 状态 | 新建 | 更新 | 重命名 | 删除 | 未变化 | 保留扩展 |" summary.puts "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |" @@ -392,23 +509,15 @@ def run raise "SYNC_LABELS_OWNER 不能为空。" if OWNER.empty? desired_labels = load_labels(CONFIG_FILE) - api = GitHubApi.new(token: TOKEN, base_url: API_URL) - repositories = api.paginate( - "/orgs/#{escape_segment(OWNER)}/repos?type=all&sort=full_name&direction=asc" - ).select do |repository| - !repository["archived"] && !repository["disabled"] && !repository["fork"] - end + policy = load_policy(POLICY_FILE) + validate_label_policy!(desired_labels, policy) - unless ONLY_REPOSITORY.empty? - expected = ONLY_REPOSITORY.include?("/") ? ONLY_REPOSITORY : "#{OWNER}/#{ONLY_REPOSITORY}" - repositories.select! { |repository| repository["full_name"].casecmp(expected).zero? } - if repositories.empty? - raise "找不到可同步仓库 #{expected}。请检查名称、令牌访问范围和仓库状态。" - end - end + api = GitHubApi.new(token: TOKEN, base_url: API_URL) + repositories = load_repositories(api, OWNER, policy, ONLY_REPOSITORY) puts "Owner: #{OWNER}" puts "Config: #{CONFIG_FILE}" + puts "Policy: #{POLICY_FILE}" puts "Dry run: #{DRY_RUN}" puts "Repositories: #{repositories.length}" puts @@ -420,7 +529,7 @@ def run full_name = repository.fetch("full_name") begin - counts = sync_repository(api, full_name, desired_labels, dry_run: DRY_RUN) + counts = sync_repository(api, full_name, desired_labels, policy: policy, dry_run: DRY_RUN) results << { repository: full_name, status: DRY_RUN ? "预览完成" : "同步完成", From c59d5665e5d5df6c3e6d4c915c34842110bb3a9b Mon Sep 17 00:00:00 2001 From: Ming Date: Sat, 11 Jul 2026 08:52:30 +0800 Subject: [PATCH 4/8] test: cover label policy and repository allowlist --- .../actions/sync-labels/test_sync_labels.rb | 80 +++++++++++++++++-- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/.github/actions/sync-labels/test_sync_labels.rb b/.github/actions/sync-labels/test_sync_labels.rb index 92e1753..24267e2 100644 --- a/.github/actions/sync-labels/test_sync_labels.rb +++ b/.github/actions/sync-labels/test_sync_labels.rb @@ -6,11 +6,17 @@ class FakeApi attr_reader :calls - def initialize(labels) + def initialize(labels: [], repositories: {}) @labels = labels + @repositories = repositories @calls = [] end + def get(path) + @calls << [:get, path] + @repositories.fetch(path) + end + def paginate(_path) @labels.map(&:dup) end @@ -50,9 +56,16 @@ class SyncLabelsTest < Minitest::Test } ].freeze + POLICY = { + prefixes: ["type:"].freeze, + exact_names: Set.new(["help wanted"]).freeze, + legacy_names: Set.new(%w[bug enhancement]).freeze, + repositories: %w[example docs].freeze + }.freeze + def test_preserves_repository_specific_labels_and_removes_stale_managed_labels api = FakeApi.new( - [ + labels: [ { "name" => "bug", "color" => "FFFFFF", "description" => "legacy" }, { "name" => "type: feature", "color" => "A2EEEF", "description" => "新增能力或改进现有功能" }, { "name" => "enhancement", "color" => "FFFFFF", "description" => "legacy duplicate" }, @@ -63,7 +76,7 @@ def test_preserves_repository_specific_labels_and_removes_stale_managed_labels ] ) - counts = sync_repository(api, "matharts/example", DESIRED, dry_run: false) + counts = sync_repository(api, "matharts/example", DESIRED, policy: POLICY, dry_run: false) assert_equal 1, counts[:created] assert_equal 0, counts[:updated] @@ -81,17 +94,74 @@ def test_preserves_repository_specific_labels_and_removes_stale_managed_labels def test_dry_run_does_not_mutate api = FakeApi.new( - [ + labels: [ { "name" => "bug", "color" => "FFFFFF", "description" => "legacy" }, { "name" => "area: calendar", "color" => "123456", "description" => "repository extension" } ] ) - counts = sync_repository(api, "matharts/example", DESIRED, dry_run: true) + counts = sync_repository(api, "matharts/example", DESIRED, policy: POLICY, dry_run: true) assert_empty api.calls assert_equal 1, counts[:renamed] assert_equal 2, counts[:created] assert_equal 1, counts[:preserved] end + + def test_rejects_repository_outside_allowlist + api = FakeApi.new + + error = assert_raises(RuntimeError) do + load_repositories(api, "matharts", POLICY, "private-project") + end + + assert_includes error.message, "不在标签同步 Allowlist" + assert_empty api.calls + end + + def test_loads_only_allowlisted_repositories + api = FakeApi.new( + repositories: { + "/repos/matharts/example" => { + "full_name" => "matharts/example", + "archived" => false, + "disabled" => false, + "fork" => false + }, + "/repos/matharts/docs" => { + "full_name" => "matharts/docs", + "archived" => false, + "disabled" => false, + "fork" => false + } + } + ) + + repositories = load_repositories(api, "matharts", POLICY, "") + + assert_equal %w[matharts/example matharts/docs], repositories.map { |repository| repository["full_name"] } + assert_equal [ + [:get, "/repos/matharts/example"], + [:get, "/repos/matharts/docs"] + ], api.calls + end + + def test_policy_requires_aliases_to_remain_owned_as_legacy_names + policy = POLICY.merge(legacy_names: Set.new(["bug"]).freeze) + + error = assert_raises(RuntimeError) do + validate_label_policy!(DESIRED, policy) + end + + assert_includes error.message, "enhancement" + end + + def test_repository_policy_matches_current_configuration + root = File.expand_path("../../..", __dir__) + labels = load_labels(File.join(root, ".github/labels.yml")) + policy = load_policy(File.join(root, ".github/label-policy.yml")) + + validate_label_policy!(labels, policy) + assert_equal %w[.github ecosystem epheon matharts skills ziwei], policy[:repositories] + end end From 04599346166eafff24b9aa00563541b00c292642 Mon Sep 17 00:00:00 2001 From: Ming Date: Sat, 11 Jul 2026 08:52:40 +0800 Subject: [PATCH 5/8] ci: preview only allowlisted repositories --- .github/workflows/preview-labels.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-labels.yml b/.github/workflows/preview-labels.yml index e9ed549..143143b 100644 --- a/.github/workflows/preview-labels.yml +++ b/.github/workflows/preview-labels.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: repository: - description: "只预览指定仓库;留空预览全部可见仓库" + description: "只预览 Allowlist 中的指定仓库;留空预览全部" required: false type: string default: "" @@ -12,6 +12,7 @@ on: pull_request: paths: - ".github/labels.yml" + - ".github/label-policy.yml" - ".github/actions/sync-labels/**" - ".github/workflows/preview-labels.yml" - ".github/workflows/sync-labels.yml" @@ -21,12 +22,13 @@ on: - main paths: - ".github/labels.yml" + - ".github/label-policy.yml" - ".github/actions/sync-labels/**" - ".github/workflows/preview-labels.yml" - ".github/workflows/sync-labels.yml" schedule: - # 每周一 UTC 03:17 检查公开可见仓库的标签漂移。 + # 每周一 UTC 03:17 检查 Allowlist 仓库的标签漂移。 - cron: "17 3 * * 1" permissions: @@ -55,6 +57,7 @@ jobs: token: ${{ github.token }} owner: ${{ github.repository_owner }} config_file: ".github/labels.yml" + policy_file: ".github/label-policy.yml" dry_run: "true" repository: >- ${{ github.event_name == 'workflow_dispatch' From c1d18a2b20439bd2dafb92cef7cb5c1ec0925458 Mon Sep 17 00:00:00 2001 From: Ming Date: Sat, 11 Jul 2026 08:52:49 +0800 Subject: [PATCH 6/8] ci: apply labels only to allowlisted repositories --- .github/workflows/sync-labels.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml index 1d92463..12ecfcd 100644 --- a/.github/workflows/sync-labels.yml +++ b/.github/workflows/sync-labels.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: repository: - description: "只同步指定仓库;留空同步全部仓库" + description: "只同步 Allowlist 中的指定仓库;留空同步全部" required: false type: string default: "" @@ -51,5 +51,6 @@ jobs: token: ${{ steps.app-token.outputs.token }} owner: ${{ github.repository_owner }} config_file: ".github/labels.yml" + policy_file: ".github/label-policy.yml" dry_run: "false" repository: ${{ inputs.repository }} From da7c679690b237959e0fc74c3adc9a447d8f2873 Mon Sep 17 00:00:00 2001 From: Ming Date: Sat, 11 Jul 2026 08:53:06 +0800 Subject: [PATCH 7/8] docs: explain label policy sources and lifecycle --- README.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 02ccda4..d92a8cd 100644 --- a/README.md +++ b/README.md @@ -36,20 +36,28 @@ ## 标签治理 -MathArts 的组织级标签由两个权威来源共同维护: +MathArts 的组织级标签由三个职责不同的权威来源共同维护: -| 文件 | 职责 | -| ------------------------------------------------------ | ------------------------ | -| [`.github/labels.yml`](.github/labels.yml) | 定义标签名称、颜色、描述和旧标签别名 | -| [`docs/label-governance.md`](docs/label-governance.md) | 定义标签的使用规则、维护责任、扩展边界和生命周期 | +| 文件 | 职责 | +| ------------------------------------------------------ | ------------------------------------------ | +| [`.github/labels.yml`](.github/labels.yml) | 定义目标标签名称、颜色、描述,以及当前仍需执行的一对一迁移别名 | +| [`.github/label-policy.yml`](.github/label-policy.yml) | 定义组织拥有的标签命名空间、历史受管名称和参与同步的仓库 Allowlist | +| [`docs/label-governance.md`](docs/label-governance.md) | 定义标签的使用规则、维护责任、扩展边界和生命周期 | -完整标签清单只在 `.github/labels.yml` 中维护,其他文档和模板不得复制第二份正式清单。 +完整目标标签清单只在 `.github/labels.yml` 中维护。同步器只会删除 `label-policy.yml` 声明为组织所有的标签,并保留仓库自行维护的扩展标签。 -标签同步只管理组织保留的 `type:*`、`status:*`、`priority:*`、`impact:*`、`process:*`、`resolution:*` 系列、贡献入口标签及其历史别名。项目仓库可以增加 `area:*`、`package:*`、`platform:*` 或其他项目特定标签,自动化会保留这些扩展标签。 +`label-policy.yml` 采用显式仓库 Allowlist。新增仓库不会自动进入标签同步范围;加入或移除仓库必须经过本仓库的 Pull Request。手动指定仓库时,也只能选择 Allowlist 中的仓库。 + +历史标签的职责分为两层: + +* `labels.yml` 的 `aliases` 表示当前需要执行的一对一重命名; +* `label-policy.yml` 的 `legacy_names` 表示组织仍然拥有并允许同步器清理的旧名称。 + +从 `aliases` 移除迁移映射时,不应立即移除对应的 `legacy_names`;应先确认所有 Allowlist 仓库已完成清理,避免旧标签成为无人管理的残留。 标签自动化分为两个权限边界: -* [`preview-labels.yml`](.github/workflows/preview-labels.yml) 在推送、定时任务或手动触发时使用只读 `github.token` 预览公开可见仓库的标签漂移,不读取 GitHub App 私钥; +* [`preview-labels.yml`](.github/workflows/preview-labels.yml) 在 Pull Request、推送、定时任务或手动触发时使用只读 `github.token` 预览 Allowlist 仓库的标签漂移,不读取 GitHub App 私钥; * [`sync-labels.yml`](.github/workflows/sync-labels.yml) 只允许从 `main` 手动触发真实变更,并绑定 `label-governance-production` Environment。 真实同步通过 GitHub App 生成短时效 Installation Access Token。`label-governance-production` Environment 必须配置: From 5d0ded4b35c9a79371ec46b6212ca587d1c5df71 Mon Sep 17 00:00:00 2001 From: Ming Date: Sat, 11 Jul 2026 08:53:20 +0800 Subject: [PATCH 8/8] ci: validate label policy as required governance --- .github/workflows/validate-documentation.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate-documentation.yml b/.github/workflows/validate-documentation.yml index 83e94ce..881f4ff 100644 --- a/.github/workflows/validate-documentation.yml +++ b/.github/workflows/validate-documentation.yml @@ -59,6 +59,8 @@ jobs: "SUPPORT.md" "profile/README.md" ".github/pull_request_template.md" + ".github/labels.yml" + ".github/label-policy.yml" ) for path in "${required_files[@]}"; do test -s "$path" || exit 1 @@ -94,7 +96,7 @@ jobs: - name: Check immutable GitHub Action references run: ruby .github/scripts/validate-action-pins.rb - - name: Test label synchronization behavior + - name: Test label synchronization behavior and policy run: ruby .github/actions/sync-labels/test_sync_labels.rb links: