diff --git a/.github/label-policy.json b/.github/label-policy.json new file mode 100644 index 0000000..e8b254f --- /dev/null +++ b/.github/label-policy.json @@ -0,0 +1,21 @@ +{ + "contributor_tier_color": "2ED9FF", + "contributor_tiers": [ + { + "label": "distinguished contributor", + "min_merged_prs": 50 + }, + { + "label": "principal contributor", + "min_merged_prs": 20 + }, + { + "label": "experienced contributor", + "min_merged_prs": 10 + }, + { + "label": "trusted contributor", + "min_merged_prs": 5 + } + ] +} diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index 07d0f86..3065182 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -29,14 +29,41 @@ jobs: const issue = context.payload.issue; const pullRequest = context.payload.pull_request; const target = issue ?? pullRequest; - const contributorTierRules = [ - { label: "distinguished contributor", minMergedPRs: 50 }, - { label: "principal contributor", minMergedPRs: 20 }, - { label: "experienced contributor", minMergedPRs: 10 }, - { label: "trusted contributor", minMergedPRs: 5 }, - ]; + async function loadContributorTierPolicy() { + const fallback = { + contributorTierColor: "2ED9FF", + contributorTierRules: [ + { label: "distinguished contributor", minMergedPRs: 50 }, + { label: "principal contributor", minMergedPRs: 20 }, + { label: "experienced contributor", minMergedPRs: 10 }, + { label: "trusted contributor", minMergedPRs: 5 }, + ], + }; + try { + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path: ".github/label-policy.json", + ref: context.payload.repository?.default_branch || "main", + }); + const json = JSON.parse(Buffer.from(data.content, "base64").toString("utf8")); + const contributorTierRules = (json.contributor_tiers || []).map((entry) => ({ + label: String(entry.label || "").trim(), + minMergedPRs: Number(entry.min_merged_prs || 0), + })); + const contributorTierColor = String(json.contributor_tier_color || "").toUpperCase(); + if (!contributorTierColor || contributorTierRules.length === 0) { + return fallback; + } + return { contributorTierColor, contributorTierRules }; + } catch (error) { + core.warning(`failed to load .github/label-policy.json, using fallback policy: ${error.message}`); + return fallback; + } + } + + const { contributorTierColor, contributorTierRules } = await loadContributorTierPolicy(); const contributorTierLabels = contributorTierRules.map((rule) => rule.label); - const contributorTierColor = "2ED9FF"; // Keep in sync with .github/workflows/labeler.yml const managedContributorLabels = new Set(contributorTierLabels); const action = context.payload.action; const changedLabel = context.payload.label?.name; diff --git a/.github/workflows/label-policy-sanity.yml b/.github/workflows/label-policy-sanity.yml index 67d4590..de1bbda 100644 --- a/.github/workflows/label-policy-sanity.yml +++ b/.github/workflows/label-policy-sanity.yml @@ -3,10 +3,12 @@ name: Label Policy Sanity on: pull_request: paths: + - ".github/label-policy.json" - ".github/workflows/labeler.yml" - ".github/workflows/auto-response.yml" push: paths: + - ".github/label-policy.json" - ".github/workflows/labeler.yml" - ".github/workflows/auto-response.yml" @@ -25,39 +27,48 @@ jobs: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Verify contributor-tier parity across workflows + - name: Verify shared label policy and workflow wiring shell: bash run: | set -euo pipefail python3 - <<'PY' + import json import re from pathlib import Path - files = [ + policy_path = Path('.github/label-policy.json') + policy = json.loads(policy_path.read_text(encoding='utf-8')) + color = str(policy.get('contributor_tier_color', '')).upper() + rules = policy.get('contributor_tiers', []) + if not re.fullmatch(r'[0-9A-F]{6}', color): + raise SystemExit('invalid contributor_tier_color in .github/label-policy.json') + if not rules: + raise SystemExit('contributor_tiers must not be empty in .github/label-policy.json') + + labels = set() + prev_min = None + for entry in rules: + label = str(entry.get('label', '')).strip().lower() + min_merged = int(entry.get('min_merged_prs', 0)) + if not label.endswith('contributor'): + raise SystemExit(f'invalid contributor tier label: {label}') + if label in labels: + raise SystemExit(f'duplicate contributor tier label: {label}') + if prev_min is not None and min_merged > prev_min: + raise SystemExit('contributor_tiers must be sorted descending by min_merged_prs') + labels.add(label) + prev_min = min_merged + + workflow_paths = [ Path('.github/workflows/labeler.yml'), Path('.github/workflows/auto-response.yml'), ] + for workflow in workflow_paths: + text = workflow.read_text(encoding='utf-8') + if '.github/label-policy.json' not in text: + raise SystemExit(f'{workflow} must load .github/label-policy.json') + if re.search(r'contributorTierColor\s*=\s*"[0-9A-Fa-f]{6}"', text): + raise SystemExit(f'{workflow} contains hardcoded contributorTierColor') - parsed = {} - for path in files: - text = path.read_text(encoding='utf-8') - rules = re.findall(r'\{ label: "([^"]+ contributor)", minMergedPRs: (\d+) \}', text) - color_match = re.search(r'const contributorTierColor = "([0-9A-Fa-f]{6})"', text) - if not color_match: - raise SystemExit(f'failed to parse contributorTierColor in {path}') - parsed[str(path)] = { - 'rules': rules, - 'color': color_match.group(1).upper(), - } - - baseline = parsed[str(files[0])] - for path in files[1:]: - entry = parsed[str(path)] - if entry != baseline: - raise SystemExit( - 'contributor-tier mismatch between workflows: ' - f'{files[0]}={baseline} vs {path}={entry}' - ) - - print('contributor tier rules/color are consistent across label workflows') + print('label policy file is valid and workflow consumers are wired to shared policy') PY diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 10d8bfb..0e38f00 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -60,14 +60,41 @@ jobs: return; } - const contributorTierRules = [ - { label: "distinguished contributor", minMergedPRs: 50 }, - { label: "principal contributor", minMergedPRs: 20 }, - { label: "experienced contributor", minMergedPRs: 10 }, - { label: "trusted contributor", minMergedPRs: 5 }, - ]; + async function loadContributorTierPolicy() { + const fallback = { + contributorTierColor: "2ED9FF", + contributorTierRules: [ + { label: "distinguished contributor", minMergedPRs: 50 }, + { label: "principal contributor", minMergedPRs: 20 }, + { label: "experienced contributor", minMergedPRs: 10 }, + { label: "trusted contributor", minMergedPRs: 5 }, + ], + }; + try { + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path: ".github/label-policy.json", + ref: context.payload.repository?.default_branch || "main", + }); + const json = JSON.parse(Buffer.from(data.content, "base64").toString("utf8")); + const contributorTierRules = (json.contributor_tiers || []).map((entry) => ({ + label: String(entry.label || "").trim(), + minMergedPRs: Number(entry.min_merged_prs || 0), + })); + const contributorTierColor = String(json.contributor_tier_color || "").toUpperCase(); + if (!contributorTierColor || contributorTierRules.length === 0) { + return fallback; + } + return { contributorTierColor, contributorTierRules }; + } catch (error) { + core.warning(`failed to load .github/label-policy.json, using fallback policy: ${error.message}`); + return fallback; + } + } + + const { contributorTierColor, contributorTierRules } = await loadContributorTierPolicy(); const contributorTierLabels = contributorTierRules.map((rule) => rule.label); - const contributorTierColor = "2ED9FF"; // Keep in sync with .github/workflows/auto-response.yml const managedPathLabels = [ "docs", diff --git a/.github/workflows/pr-hygiene.yml b/.github/workflows/pr-hygiene.yml index 0f36ac5..28f536c 100644 --- a/.github/workflows/pr-hygiene.yml +++ b/.github/workflows/pr-hygiene.yml @@ -26,7 +26,7 @@ jobs: with: script: | const staleHours = Number(process.env.STALE_HOURS || "48"); - const ignoreLabels = new Set(["no-stale", "maintainer", "no-pr-hygiene"]); + const ignoreLabels = new Set(["no-stale", "stale", "maintainer", "no-pr-hygiene"]); const marker = ""; const owner = context.repo.owner; const repo = context.repo.repo; diff --git a/.github/workflows/rust-reusable.yml b/.github/workflows/rust-reusable.yml new file mode 100644 index 0000000..511ccc4 --- /dev/null +++ b/.github/workflows/rust-reusable.yml @@ -0,0 +1,62 @@ +name: Rust Reusable Job + +on: + workflow_call: + inputs: + run_command: + description: "Shell command(s) to execute." + required: true + type: string + timeout_minutes: + description: "Job timeout in minutes." + required: false + default: 20 + type: number + toolchain: + description: "Rust toolchain channel/version." + required: false + default: "stable" + type: string + components: + description: "Optional rustup components." + required: false + default: "" + type: string + targets: + description: "Optional rustup targets." + required: false + default: "" + type: string + use_cache: + description: "Whether to enable rust-cache." + required: false + default: true + type: boolean + +permissions: + contents: read + +jobs: + run: + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: ${{ inputs.timeout_minutes }} + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + toolchain: ${{ inputs.toolchain }} + components: ${{ inputs.components }} + targets: ${{ inputs.targets }} + + - name: Restore Rust cache + if: inputs.use_cache + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + + - name: Run command + shell: bash + run: | + set -euo pipefail + ${{ inputs.run_command }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index bf12c0f..bf0b99a 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -23,19 +23,13 @@ env: jobs: audit: name: Security Audit - runs-on: blacksmith-2vcpu-ubuntu-2404 - timeout-minutes: 20 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - - - name: Install cargo-audit - run: cargo install --locked cargo-audit --version 0.22.1 - - - name: Run cargo-audit - run: cargo audit + uses: ./.github/workflows/rust-reusable.yml + with: + timeout_minutes: 20 + toolchain: stable + run_command: | + cargo install --locked cargo-audit --version 0.22.1 + cargo audit deny: name: License & Supply Chain diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d54e64d..f46af3f 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -24,8 +24,8 @@ jobs: days-before-pr-close: 7 stale-issue-label: stale stale-pr-label: stale - exempt-issue-labels: security,pinned,no-stale,maintainer - exempt-pr-labels: no-stale,maintainer + exempt-issue-labels: security,pinned,no-stale,no-pr-hygiene,maintainer + exempt-pr-labels: no-stale,no-pr-hygiene,maintainer remove-stale-when-updated: true exempt-all-assignees: true operations-per-run: 300 diff --git a/.github/workflows/update-notice.yml b/.github/workflows/update-notice.yml index 22546d0..8f8a80f 100644 --- a/.github/workflows/update-notice.yml +++ b/.github/workflows/update-notice.yml @@ -6,6 +6,10 @@ on: # Run every Sunday at 00:00 UTC - cron: '0 0 * * 0' +concurrency: + group: update-notice-${{ github.ref }} + cancel-in-progress: true + permissions: contents: write pull-requests: write @@ -13,10 +17,10 @@ permissions: jobs: update-notice: name: Update NOTICE with new contributors - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Fetch contributors id: contributors diff --git a/docs/ci-map.md b/docs/ci-map.md index af37881..842bca2 100644 --- a/docs/ci-map.md +++ b/docs/ci-map.md @@ -26,7 +26,9 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u - `.github/workflows/release.yml` (`Release`) - Purpose: build tagged release artifacts and publish GitHub releases - `.github/workflows/label-policy-sanity.yml` (`Label Policy Sanity`) - - Purpose: enforce contributor-tier rule/color parity between `labeler.yml` and `auto-response.yml` + - Purpose: validate shared contributor-tier policy in `.github/label-policy.json` and ensure label workflows consume that policy +- `.github/workflows/rust-reusable.yml` (`Rust Reusable Job`) + - Purpose: reusable Rust setup/cache + command runner for workflow-call consumers ### Optional Repository Automation @@ -62,7 +64,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u - `Release`: tag push (`v*`) - `Security Audit`: push to `main`, PRs to `main`, weekly schedule - `Workflow Sanity`: PR/push when `.github/workflows/**`, `.github/*.yml`, or `.github/*.yaml` change -- `Label Policy Sanity`: PR/push when `.github/workflows/labeler.yml` or `.github/workflows/auto-response.yml` changes +- `Label Policy Sanity`: PR/push when `.github/label-policy.json`, `.github/workflows/labeler.yml`, or `.github/workflows/auto-response.yml` changes - `PR Labeler`: `pull_request_target` lifecycle events - `PR Auto Responder`: issue opened/labeled, `pull_request_target` opened/labeled - `Stale`: daily schedule, manual dispatch