From 53fdc6257f7dfd91ce18e556a0236425f96e9718 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 01:41:52 +0000 Subject: [PATCH 01/22] feat(label-governance): add the locked-files guard and inventory token guard Spec 008 tasks T075 and T080. - T080: .github/workflows/locked-files-guard.yml runs scripts/validation/check-locked-file-approval.cjs from the base branch. A PR that changes a LOCKED file (labels, issue types, issue and PR templates, PR routing files) fails unless its body links a change request recorded as approved in change-requests.json or approved in a comment by @ashleyshaw. CODEOWNERS makes @ashleyshaw the only owner of those paths. Listed in workflow-lint's actionlint run. - T075: label-inventory.js reads LABEL_INVENTORY_TOKEN, refuses to run in GitHub Actions, records private repositories and fails when it lists fewer than the organisation reports. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011cim45iF9naViRzx5vUysK --- .github/workflows/locked-files-guard.yml | 103 +++++++++++++ .github/workflows/workflow-lint.yml | 1 + CODEOWNERS | 11 +- .../__tests__/label-inventory.test.js | 45 ++++++ scripts/automation/label-inventory.js | 71 ++++++++- .../check-locked-file-approval.test.js | 114 ++++++++++++++ .../validation/check-locked-file-approval.cjs | 144 ++++++++++++++++++ 7 files changed, 483 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/locked-files-guard.yml create mode 100644 scripts/validation/__tests__/check-locked-file-approval.test.js create mode 100644 scripts/validation/check-locked-file-approval.cjs diff --git a/.github/workflows/locked-files-guard.yml b/.github/workflows/locked-files-guard.yml new file mode 100644 index 0000000000..60a5e8bd4a --- /dev/null +++ b/.github/workflows/locked-files-guard.yml @@ -0,0 +1,103 @@ +# Locked Files Guard +# +# Constitution Principle II: LOCKED files change only after an approved change +# request. This check fails a pull request that changes a locked file unless +# its body links a change-request issue that is approved, either recorded as +# `approved` in the spec 008 change-request register or carrying an +# "Approved…" comment by @ashleyshaw (spec 008, task T080). +# +# The guard script and the register are read from the BASE branch, so a pull +# request cannot approve itself by editing them. Make this check required in +# the branch protection rules for `develop` and `main`. + +name: Locked Files Guard + +on: + pull_request: + branches: [develop, main] + types: [opened, synchronize, reopened, edited, ready_for_review] + +concurrency: + group: locked-files-guard-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + guard: + name: Locked files need an approved change request + runs-on: ubuntu-latest + timeout-minutes: 5 + # pull-requests: read for the PR files API; issues: read for approval comments. + permissions: + contents: read + pull-requests: read + issues: read + steps: + - name: Check out the base branch (guard and register only) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + fetch-depth: 1 + persist-credentials: false + sparse-checkout: | + scripts/validation/check-locked-file-approval.cjs + .github/reports/audits/2026-09-14-label-audit/evidence/change-requests.json + sparse-checkout-cone-mode: false + + - name: Check locked files + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('fs'); + const path = require('path'); + const guardPath = path.join(process.env.GITHUB_WORKSPACE, 'scripts/validation/check-locked-file-approval.cjs'); + if (!fs.existsSync(guardPath)) { + core.notice('The locked-file guard is not on the base branch yet; skipping this run.'); + return; + } + const guard = require(guardPath); + + const pr = context.payload.pull_request; + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100, + }); + // A rename moves a locked file too, so check both paths. + const changedFiles = files.flatMap((file) => + file.previous_filename ? [file.filename, file.previous_filename] : [file.filename] + ); + + let register = null; + const registerPath = path.join(process.env.GITHUB_WORKSPACE, guard.REGISTER_PATH); + if (fs.existsSync(registerPath)) { + register = JSON.parse(fs.readFileSync(registerPath, 'utf8')); + } + + const commentsByIssue = {}; + const issues = guard.referencedIssues(pr.body || '').slice(0, 20); + if (changedFiles.some(guard.isLocked)) { + for (const issue of issues) { + try { + commentsByIssue[issue] = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue, + per_page: 100, + }); + } catch (error) { + core.warning(`Could not read comments on #${issue}: ${error.message}`); + } + } + } + + const result = guard.evaluate({ changedFiles, body: pr.body || '', register, commentsByIssue }); + await core.summary.addHeading('Locked Files Guard', 3).addRaw(result.message).write(); + if (result.ok) { + core.info(result.message); + } else { + core.setFailed(result.message); + } diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index a930f4e88c..300bbbbb64 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -75,6 +75,7 @@ jobs: .github/workflows/changelog-unified.yml \ .github/workflows/documentation.yml \ .github/workflows/labeling-unified.yml \ + .github/workflows/locked-files-guard.yml \ .github/workflows/orchestrate-phase-progression.yml \ .github/workflows/phase-progression.yml \ .github/workflows/pr-template-routing.yml \ diff --git a/CODEOWNERS b/CODEOWNERS index ac474f50c3..2cac18f46d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -16,10 +16,17 @@ /.github/workflows/ @ashleyshaw @lightspeedwp/lightspeed # Labels and Issue Types (critical for automation) -/.github/labels.yml @ashleyshaw @lightspeedwp/lightspeed -/.github/issue-types.yml @ashleyshaw @lightspeedwp/lightspeed /.github/labeler.yml @ashleyshaw @lightspeedwp/lightspeed +# LOCKED files (constitution Principle II, v1.4.0): only @ashleyshaw approves. +# Changes also need an approved change request (Locked Files Guard, spec 008 T080). +/.github/labels.yml @ashleyshaw +/.github/issue-types.yml @ashleyshaw +/.github/ISSUE_TEMPLATE/*.md @ashleyshaw +/.github/PULL_REQUEST_TEMPLATE/ @ashleyshaw +/.github/branch-types.yml @ashleyshaw +/.github/branch-labels.yml @ashleyshaw + # AI and Copilot Instructions /.github/custom-instructions.md @ashleyshaw @lightspeedwp/lightspeed /.github/agents/ @ashleyshaw @lightspeedwp/lightspeed diff --git a/scripts/automation/__tests__/label-inventory.test.js b/scripts/automation/__tests__/label-inventory.test.js index 82b00d27b2..54cac1bb21 100644 --- a/scripts/automation/__tests__/label-inventory.test.js +++ b/scripts/automation/__tests__/label-inventory.test.js @@ -9,6 +9,8 @@ import { collectPages, buildInventory, incompleteRepositories, + privateRepositoryGap, + resolveToken, } from '../label-inventory.js'; const labelsOf = (n, prefix = 'l') => @@ -76,4 +78,47 @@ describe('label-inventory', () => { }; expect(incompleteRepositories(inv)).toEqual(['o/full', 'o/short']); }); + + describe('token and private repository guards (T075)', () => { + it('reads the token from LABEL_INVENTORY_TOKEN, not GITHUB_TOKEN', () => { + expect(resolveToken({ LABEL_INVENTORY_TOKEN: 'org-token' })).toEqual({ token: 'org-token' }); + expect(resolveToken({ GITHUB_TOKEN: 'repo-token' }).error).toMatch( + /LABEL_INVENTORY_TOKEN is required/ + ); + }); + + it('refuses to run inside GitHub Actions even when a token is set', () => { + const result = resolveToken({ GITHUB_ACTIONS: 'true', LABEL_INVENTORY_TOKEN: 'org-token' }); + expect(result.token).toBeUndefined(); + expect(result.error).toMatch(/refusing to run inside GitHub Actions/); + }); + + it('records private repositories and the organisation-reported count', async () => { + const client = pagedClient([{ name: 'pub' }, { name: 'priv', private: true }], { + pub: 1, + priv: 2, + }); + client.rest.orgs = { get: async () => ({ data: { total_private_repos: 1 } }) }; + const inv = await buildInventory(client, 'lightspeedwp'); + expect(inv.private_repository_count).toBe(1); + expect(inv.reported_private_repository_count).toBe(1); + expect(inv.repositories.find((r) => r.repository === 'lightspeedwp/priv').private).toBe(true); + expect(privateRepositoryGap(inv)).toBeNull(); + }); + + it('fails when the token cannot see every private repository', async () => { + const client = pagedClient([{ name: 'pub' }], { pub: 1 }); + client.rest.orgs = { get: async () => ({ data: { total_private_repos: 3 } }) }; + const inv = await buildInventory(client, 'lightspeedwp'); + expect(privateRepositoryGap(inv)).toMatch( + /listed 0 private repositories but lightspeedwp reports 3/ + ); + }); + + it('skips the private check when the organisation count is not readable', async () => { + const inv = await buildInventory(pagedClient([{ name: 'a' }], { a: 1 }), 'lightspeedwp'); + expect(inv.reported_private_repository_count).toBeNull(); + expect(privateRepositoryGap(inv)).toBeNull(); + }); + }); }); diff --git a/scripts/automation/label-inventory.js b/scripts/automation/label-inventory.js index 9a0180bffe..6d61e2e82b 100644 --- a/scripts/automation/label-inventory.js +++ b/scripts/automation/label-inventory.js @@ -9,8 +9,14 @@ * label-sync.js read only the first 100). Read-only: it never creates, * edits or deletes labels. * + * Credentials (FR-018, task T075): the token comes from LABEL_INVENTORY_TOKEN, + * a token with organisation read access (the organisation GitHub App or a + * fine-grained token). The repository-scoped Actions GITHUB_TOKEN sees only + * public repositories in other organisation repos, so the script refuses to + * run inside GitHub Actions and fails when private repositories are missing. + * * Usage: - * GITHUB_TOKEN=... node scripts/automation/label-inventory.js [--org lightspeedwp] [--output path] + * LABEL_INVENTORY_TOKEN=... node scripts/automation/label-inventory.js [--org lightspeedwp] [--output path] */ import fs from 'fs'; @@ -18,6 +24,8 @@ import path from 'path'; export const PER_PAGE = 100; +export const TOKEN_VARIABLE = 'LABEL_INVENTORY_TOKEN'; + const DEFAULT_OUTPUT = path.join( '.github', 'reports', @@ -51,6 +59,16 @@ export async function collectPages(fetchPage) { * @returns {Promise} Inventory with one entry per repository. */ export async function buildInventory(client, org) { + // The organisation's own count of private repositories, used to detect a + // token that cannot see them. Null when the client cannot read it. + let reportedPrivateCount = null; + if (client.rest.orgs && typeof client.rest.orgs.get === 'function') { + const { data: orgData } = await client.rest.orgs.get({ org }); + if (Number.isInteger(orgData.total_private_repos)) { + reportedPrivateCount = orgData.total_private_repos; + } + } + const { items: repos } = await collectPages(async (page) => { const { data } = await client.rest.repos.listForOrg({ org, @@ -76,6 +94,7 @@ export async function buildInventory(client, org) { repository: `${org}/${repo.name}`, archived: Boolean(repo.archived), fork: Boolean(repo.fork), + private: Boolean(repo.private), label_count: labels.length, pages_read: pagesRead, labels: labels.map((l) => ({ @@ -90,6 +109,8 @@ export async function buildInventory(client, org) { generated_at: new Date().toISOString(), organisation: org, repository_count: repositories.length, + private_repository_count: repositories.filter((r) => r.private).length, + reported_private_repository_count: reportedPrivateCount, label_total: repositories.reduce((n, r) => n + r.label_count, 0), repositories, }; @@ -112,6 +133,43 @@ export function incompleteRepositories(inventory) { .map((r) => r.repository); } +/** + * Check that the token could see every private repository the organisation + * reports, so a repository-scoped token cannot produce a silently incomplete + * inventory. + * @param {object} inventory Result of buildInventory. + * @returns {string|null} Error message, or null when the inventory is complete. + */ +export function privateRepositoryGap(inventory) { + const reported = inventory.reported_private_repository_count; + if (reported === null || reported === undefined) return null; + if (inventory.private_repository_count < reported) { + return `listed ${inventory.private_repository_count} private repositories but ${inventory.organisation} reports ${reported}; the token cannot see them all`; + } + return null; +} + +/** + * Resolve the token from the environment (FR-018, task T075). + * @param {Record} env Environment variables. + * @returns {{token?: string, error?: string}} The token, or why none can be used. + */ +export function resolveToken(env) { + if (env.GITHUB_ACTIONS === 'true') { + return { + error: + 'refusing to run inside GitHub Actions: the inventory needs an organisation read token, never the repository-scoped GITHUB_TOKEN', + }; + } + const token = env[TOKEN_VARIABLE]; + if (!token) { + return { + error: `${TOKEN_VARIABLE} is required (a token with organisation read access; never GITHUB_TOKEN)`, + }; + } + return { token }; +} + function parseArgs(argv) { const args = { org: 'lightspeedwp', output: DEFAULT_OUTPUT }; for (let i = 0; i < argv.length; i += 1) { @@ -122,9 +180,9 @@ function parseArgs(argv) { } async function main() { - const token = process.env.GITHUB_TOKEN; - if (!token) { - console.error('❌ GITHUB_TOKEN environment variable is required'); + const { token, error } = resolveToken(process.env); + if (error) { + console.error(`❌ ${error}`); process.exit(1); } const { Octokit } = await import('octokit'); @@ -137,6 +195,11 @@ async function main() { console.error(`❌ Incomplete pagination for: ${incomplete.join(', ')}`); process.exit(1); } + const privateGap = privateRepositoryGap(inventory); + if (privateGap) { + console.error(`❌ Incomplete inventory: ${privateGap}`); + process.exit(1); + } fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, `${JSON.stringify(inventory, null, 2)}\n`); diff --git a/scripts/validation/__tests__/check-locked-file-approval.test.js b/scripts/validation/__tests__/check-locked-file-approval.test.js new file mode 100644 index 0000000000..321e8dd7b0 --- /dev/null +++ b/scripts/validation/__tests__/check-locked-file-approval.test.js @@ -0,0 +1,114 @@ +/** + * Tests for the locked-file approval guard (spec 008, task T080). + */ + +const { + APPROVER, + approvedInRegister, + evaluate, + isApprovalComment, + isLocked, + referencedIssues, +} = require('../check-locked-file-approval.cjs'); + +const register = { + requests: [ + { issue_number: 3556, status: 'approved' }, + { issue_number: 3557, status: 'pending' }, + ], +}; + +describe('check-locked-file-approval', () => { + test.each([ + '.github/labels.yml', + '.github/issue-types.yml', + '.github/ISSUE_TEMPLATE/06-decision.md', + '.github/PULL_REQUEST_TEMPLATE/pr_aiops.md', + '.github/PULL_REQUEST_TEMPLATE/config.yml', + '.github/branch-types.yml', + '.github/branch-labels.yml', + ])('treats %s as locked', (file) => { + expect(isLocked(file)).toBe(true); + }); + + test.each([ + '.github/labeler.yml', + '.github/ISSUE_TEMPLATE/config.yml', + '.github/workflows/labeling-unified.yml', + 'docs/LABEL_STRATEGY.md', + 'labels.yml', + ])('does not treat %s as locked', (file) => { + expect(isLocked(file)).toBe(false); + }); + + test('finds issue references and ignores code spans, comments and URLs to other repositories', () => { + const body = [ + 'Closes #3729', + 'Relates to https://github.com/lightspeedwp/.github/issues/3556', + 'Not this: `#999`, , https://github.com/other/repo/issues/997, a#1', + 'Part of #449 and #3729 again', + ].join('\n'); + expect(referencedIssues(body)).toEqual([3729, 449, 3556]); + }); + + test('reads approved requests from the register and tolerates a missing one', () => { + expect([...approvedInRegister(register)]).toEqual([3556]); + expect(approvedInRegister(null).size).toBe(0); + }); + + test('accepts only the approver’s comment that starts with "Approved"', () => { + expect( + isApprovalComment({ user: { login: APPROVER }, body: 'Approved 2026-10-02: all of it' }) + ).toBe(true); + expect(isApprovalComment({ user: { login: 'someone-else' }, body: 'Approved' })).toBe(false); + expect(isApprovalComment({ user: { login: APPROVER }, body: 'Not approved yet' })).toBe(false); + }); + + test('passes when no locked file changes', () => { + const result = evaluate({ changedFiles: ['docs/README.md'], body: '', register }); + expect(result.ok).toBe(true); + expect(result.locked).toEqual([]); + }); + + test('fails a locked-file change with no linked approved request', () => { + const result = evaluate({ + changedFiles: ['.github/labels.yml', 'docs/LABEL_STRATEGY.md'], + body: 'Relates to #3557', + register, + }); + expect(result.ok).toBe(false); + expect(result.locked).toEqual(['.github/labels.yml']); + expect(result.message).toMatch(/no linked change request is approved/); + }); + + test('passes a locked-file change linked to a request approved in the register', () => { + const result = evaluate({ + changedFiles: ['.github/issue-types.yml'], + body: 'Closes #3556', + register, + }); + expect(result.ok).toBe(true); + expect(result.approvedBy).toEqual({ 3556: 'change-request register' }); + }); + + test('passes a locked-file change linked to an issue with the approver’s approval comment', () => { + const result = evaluate({ + changedFiles: ['.github/PULL_REQUEST_TEMPLATE/pr_docs.md'], + body: 'Closes #4000', + register, + commentsByIssue: { 4000: [{ user: { login: APPROVER }, body: 'Approved, ship it' }] }, + }); + expect(result.ok).toBe(true); + expect(result.approvedBy).toEqual({ 4000: `approval comment by @${APPROVER}` }); + }); + + test('does not accept an approval comment from anyone else', () => { + const result = evaluate({ + changedFiles: ['.github/branch-labels.yml'], + body: 'Closes #4000', + register, + commentsByIssue: { 4000: [{ user: { login: 'teammate' }, body: 'Approved' }] }, + }); + expect(result.ok).toBe(false); + }); +}); diff --git a/scripts/validation/check-locked-file-approval.cjs b/scripts/validation/check-locked-file-approval.cjs new file mode 100644 index 0000000000..9e639164c7 --- /dev/null +++ b/scripts/validation/check-locked-file-approval.cjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node + +/** + * Locked-file approval guard (spec 008, task T080; constitution Principle II). + * + * A pull request that changes a LOCKED file may merge only after its change + * request is approved. The PR body must link the change-request issue, and + * that issue must either be recorded as `approved` in the spec 008 + * change-request register or carry an approval comment by the approver. + * + * The workflow `.github/workflows/locked-files-guard.yml` runs this module + * from the base branch, and reads the register from the base branch too, so + * a pull request cannot approve itself by editing either file. + * + * @module scripts/validation/check-locked-file-approval + */ + +'use strict'; + +/** Constitution v1.4.0 LOCKED files. */ +const LOCKED_PATTERNS = [ + /^\.github\/labels\.yml$/, + /^\.github\/issue-types\.yml$/, + /^\.github\/ISSUE_TEMPLATE\/[^/]+\.md$/, + /^\.github\/PULL_REQUEST_TEMPLATE\/[^/]+\.md$/, + /^\.github\/PULL_REQUEST_TEMPLATE\/config\.yml$/, + /^\.github\/branch-types\.yml$/, + /^\.github\/branch-labels\.yml$/, +]; + +/** The GitHub login whose approval counts (constitution: Approval Authority). */ +const APPROVER = 'ashleyshaw'; + +/** Path of the spec 008 change-request register. */ +const REGISTER_PATH = '.github/reports/audits/2026-09-14-label-audit/evidence/change-requests.json'; + +/** + * @param {string} file Repository-relative path. + * @returns {boolean} True when the file is LOCKED. + */ +function isLocked(file) { + return LOCKED_PATTERNS.some((pattern) => pattern.test(file)); +} + +/** + * Issue numbers referenced in a pull request body (`#123` or an issue URL in + * this repository). Code spans and HTML comments are ignored. + * @param {string} body Pull request body. + * @param {string} [repo] `owner/name`, to match full issue URLs. + * @returns {number[]} Unique issue numbers, in order of appearance. + */ +function referencedIssues(body, repo = 'lightspeedwp/.github') { + const text = String(body || '') + .replace(//g, ' ') + .replace(/`[^`]*`/g, ' '); + const numbers = []; + for (const match of text.matchAll(/(?:^|[^\w&/])#(\d+)\b/g)) { + numbers.push(Number(match[1])); + } + const escaped = repo.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const url = new RegExp(`https://github\\.com/${escaped}/issues/(\\d+)\\b`, 'g'); + for (const match of text.matchAll(url)) { + numbers.push(Number(match[1])); + } + return [...new Set(numbers)]; +} + +/** + * Issue numbers recorded as approved in the change-request register. + * @param {object|null} register Parsed register (`{ requests: [...] }`). + * @returns {Set} Approved issue numbers. + */ +function approvedInRegister(register) { + const requests = (register && Array.isArray(register.requests) && register.requests) || []; + return new Set( + requests + .filter((request) => request && request.status === 'approved') + .map((request) => Number(request.issue_number)) + .filter(Number.isInteger) + ); +} + +/** + * True when a comment is the approver's approval. + * @param {{user?: {login?: string}, body?: string}} comment Issue comment. + * @returns {boolean} + */ +function isApprovalComment(comment) { + return ( + Boolean(comment) && + comment.user && + comment.user.login === APPROVER && + /^\s*approved\b/i.test(String(comment.body || '')) + ); +} + +/** + * Decide whether a pull request may merge. + * @param {object} input + * @param {string[]} input.changedFiles Paths the pull request changes. + * @param {string} input.body Pull request body. + * @param {object|null} input.register Change-request register from the base branch. + * @param {Record>} [input.commentsByIssue] Comments on each referenced issue. + * @returns {{locked: string[], issues: number[], approvedBy: Record, ok: boolean, message: string}} + */ +function evaluate({ changedFiles, body, register, commentsByIssue = {} }) { + const locked = changedFiles.filter(isLocked); + const issues = referencedIssues(body); + if (locked.length === 0) { + return { locked, issues, approvedBy: {}, ok: true, message: 'No locked file changed.' }; + } + + const approved = approvedInRegister(register); + const approvedBy = {}; + for (const issue of issues) { + if (approved.has(issue)) { + approvedBy[issue] = 'change-request register'; + } else if ((commentsByIssue[issue] || []).some(isApprovalComment)) { + approvedBy[issue] = `approval comment by @${APPROVER}`; + } + } + + const ok = Object.keys(approvedBy).length > 0; + const list = locked.map((file) => `\`${file}\``).join(', '); + const message = ok + ? `Locked files changed (${list}); approved through ${Object.entries(approvedBy) + .map(([issue, how]) => `#${issue} (${how})`) + .join(', ')}.` + : `Locked files changed (${list}), but no linked change request is approved. ` + + `Link the [LABEL-UPDATE-REQUEST], [ISSUE-TYPE-UPDATE-REQUEST] or [TEMPLATE-UPDATE-REQUEST] issue ` + + `in the PR body, and get it approved by @${APPROVER} (constitution Principle II).`; + return { locked, issues, approvedBy, ok, message }; +} + +module.exports = { + APPROVER, + LOCKED_PATTERNS, + REGISTER_PATH, + approvedInRegister, + evaluate, + isApprovalComment, + isLocked, + referencedIssues, +}; From 465c740b6e853d3024b4758c92f76173ec78d0b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 01:44:13 +0000 Subject: [PATCH 02/22] docs(changelog): record the locked-files guard and inventory token changes (#3734) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011cim45iF9naViRzx5vUysK --- CHANGELOG.md | 168 ++++++++++++++++++++++++++------------------------- 1 file changed, 85 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b99225be7a..ae5446c2c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Locked Files Guard** — Pull requests that change locked labels, issue types, templates or routing files now fail a check until a linked change request is approved by @ashleyshaw. (#3734) - **Governance Audit Phase 0-2 Infrastructure** — Added audit rule loader, violation tracker and report generator scripts with governance rules, plus Spec 006 design docs. (#3367) - **Governance audit report ids are unique and self-consistent** — Fixed duplicate violation ids and recommendation references that no longer resolved, plus Spec 006 count and enforcement wording. (#3367) - **Governance audit reports now render in a stable order** — Fixed report output changing with the order findings arrived, so two runs of the same audit are comparable. (#3367) @@ -47,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Label inventory token** — The organisation label inventory now reads `LABEL_INVENTORY_TOKEN`, refuses the Actions token and fails when private repositories are missing. (#3734) - **Local Scans No Longer Dirty the Repository** — Semgrep's settings and log files are ignored, so a security scan leaves no untracked files to commit by accident. (#3713) - **Jest Config Lint Error Fixed** — A redundant escape in the transform ignore pattern is gone and the file is formatted, clearing the only lint error it carried. (#3713) - **Task-Issue Creation Gated** — Bulk issue creation is now opt-in per spec with stale-path checks, and the skill file is free of bot footer spam. ([#3540](https://github.com/lightspeedwp/.github/issues/3540)) @@ -492,17 +494,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **WordPress & Tour Operator agents** — Standardised to Phase 1 pattern. ([PR #1142](https://github.com/lightspeedwp/.github/pull/1142), [PR #1140](https://github.com/lightspeedwp/.github/pull/1140)) -- **PRD Combined Agent (Phase 2B)** — Consolidated prd-agent and prd-factory-planner-agent (917 files, 144k LOC) into unified multi-provider agent with full standardization. ([PR #1196](https://github.com/lightspeedwp/.github/pull/1196) — _feat(agents): Phase 2 Batch 2 - PRD Combined Agent_, [#1094](https://github.com/lightspeedwp/.github/issues/1094), [#1095](https://github.com/lightspeedwp/.github/issues/1095)) +- **PRD Combined Agent (Phase 2B)** — Consolidated prd-agent and prd-factory-planner-agent (917 files, 144k LOC) into unified multi-provider agent with full standardization. ([PR #1196](https://github.com/lightspeedwp/.github/pull/1196) — *feat(agents): Phase 2 Batch 2 - PRD Combined Agent*, [#1094](https://github.com/lightspeedwp/.github/issues/1094), [#1095](https://github.com/lightspeedwp/.github/issues/1095)) -- **Agent batch standardization (Phase 1 complete)** — Standardised 4 agents to multi-provider pattern with 6 more in progress as part of Phase 2A. ([PR #1195](https://github.com/lightspeedwp/.github/pull/1195) — _feat(agents): Phase 2A standardization — 4 agents complete, 6 in progress_) +- **Agent batch standardization (Phase 1 complete)** — Standardised 4 agents to multi-provider pattern with 6 more in progress as part of Phase 2A. ([PR #1195](https://github.com/lightspeedwp/.github/pull/1195) — *feat(agents): Phase 2A standardization — 4 agents complete, 6 in progress*) -- **Issue Definition of Done (DoD) validation workflow** — Automated CI workflow validates all issues have DoD sections with proper formatting, preventing incomplete issues from being shipped. ([PR #1200](https://github.com/lightspeedwp/.github/pull/1200) — _feat(ci): add issue DoD validation workflow_, [#1014](https://github.com/lightspeedwp/.github/issues/1014)) +- **Issue Definition of Done (DoD) validation workflow** — Automated CI workflow validates all issues have DoD sections with proper formatting, preventing incomplete issues from being shipped. ([PR #1200](https://github.com/lightspeedwp/.github/pull/1200) — *feat(ci): add issue DoD validation workflow*, [#1014](https://github.com/lightspeedwp/.github/issues/1014)) -- **Quirky footers system with schema validation** — Implemented footer parsing and schema validation infrastructure with comprehensive test coverage. ([PR #1212](https://github.com/lightspeedwp/.github/pull/1212) — _feat: implement quirky footers system with schema validation_) +- **Quirky footers system with schema validation** — Implemented footer parsing and schema validation infrastructure with comprehensive test coverage. ([PR #1212](https://github.com/lightspeedwp/.github/pull/1212) — *feat: implement quirky footers system with schema validation*) -- **KWV block-theme skills** — Contributed 8 skill stubs and 11 new substantive block-theme skills for WordPress theme development. ([PR #1149](https://github.com/lightspeedwp/.github/pull/1149) — _feat(skills): contribute KWV block-theme skills — flesh out 8 stubs + add 11 new_) +- **KWV block-theme skills** — Contributed 8 skill stubs and 11 new substantive block-theme skills for WordPress theme development. ([PR #1149](https://github.com/lightspeedwp/.github/pull/1149) — *feat(skills): contribute KWV block-theme skills — flesh out 8 stubs + add 11 new*) -- **Org issue-field writer infrastructure (MVP)** — Added infrastructure for programmatic issue field writing to support automation. ([PR #1151](https://github.com/lightspeedwp/.github/pull/1151) — _feat: add org issue-field writer infrastructure (MVP for #1145)_, [#1145](https://github.com/lightspeedwp/.github/issues/1145)) +- **Org issue-field writer infrastructure (MVP)** — Added infrastructure for programmatic issue field writing to support automation. ([PR #1151](https://github.com/lightspeedwp/.github/pull/1151) — *feat: add org issue-field writer infrastructure (MVP for #1145)*, [#1145](https://github.com/lightspeedwp/.github/issues/1145)) - **Phase 1B.i: Changelog consolidation** — Merged 2 workflows, 18 tests, bug fixes. ([PR #1280](https://github.com/lightspeedwp/.github/pull/1280), [#1227](https://github.com/lightspeedwp/.github/issues/1227)) @@ -510,31 +512,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Optimise markdown-linting scope** — Audit identified 38–45% reduction opportunity. ([PR #1226](https://github.com/lightspeedwp/.github/pull/1226), [#1224](https://github.com/lightspeedwp/.github/issues/1224)) -- **PR automation workflow improvements** — Added PR labeling rules and research/* label automation to prevent duplicate labeling. ([PR #1191](https://github.com/lightspeedwp/.github/pull/1191) — _docs: Add PR automation workflow audit framework + fix research/\* label automation_) +- **PR automation workflow improvements** — Added PR labeling rules and research/* label automation to prevent duplicate labeling. ([PR #1191](https://github.com/lightspeedwp/.github/pull/1191) — *docs: Add PR automation workflow audit framework + fix research/\* label automation*) -- **16 new specialist agents** — Expanded agent portfolio with 16 new specialist agents in agents directory for expanded capability coverage. ([PR #1040](https://github.com/lightspeedwp/.github/pull/1040) — _feat: add 16 new specialist agents to agents directory_) +- **16 new specialist agents** — Expanded agent portfolio with 16 new specialist agents in agents directory for expanded capability coverage. ([PR #1040](https://github.com/lightspeedwp/.github/pull/1040) — *feat: add 16 new specialist agents to agents directory*) -- **Phase 2B Skills Audit completion** — Comprehensive audit of Batch 2-3 agents completed (11 agents, 258 skills) with full skill inventory, conflict analysis, and classification framework. ([PR #1284](https://github.com/lightspeedwp/.github/pull/1284) — _audit(phase-2b): Complete skills audit for Batch 2-3 agents (11 agents, 258 skills)_) +- **Phase 2B Skills Audit completion** — Comprehensive audit of Batch 2-3 agents completed (11 agents, 258 skills) with full skill inventory, conflict analysis, and classification framework. ([PR #1284](https://github.com/lightspeedwp/.github/pull/1284) — *audit(phase-2b): Complete skills audit for Batch 2-3 agents (11 agents, 258 skills)*) - **Expand documentation standards** — Added GitHub template governance for agents. ([PR #1082](https://github.com/lightspeedwp/.github/pull/1082)) -- **Changelog automation hardening (Phase 2)** — Rebuilt lost history from 76 PRs (May 24–July 24), implemented format validation rules, integration tests for section header preservation, and contributor guidelines. Created comprehensive automation documentation with validation, testing, and release integration guidance. ([PR #1281](https://github.com/lightspeedwp/.github/pull/1281) — _chore(changelog): rebuild lost history & apply phase 2_, [#1271](https://github.com/lightspeedwp/.github/issues/1271)) +- **Changelog automation hardening (Phase 2)** — Rebuilt lost history from 76 PRs (May 24–July 24), implemented format validation rules, integration tests for section header preservation, and contributor guidelines. Created comprehensive automation documentation with validation, testing, and release integration guidance. ([PR #1281](https://github.com/lightspeedwp/.github/pull/1281) — *chore(changelog): rebuild lost history & apply phase 2*, [#1271](https://github.com/lightspeedwp/.github/issues/1271)) -- **Workflows consolidation Phase 1B completion** — Consolidated changelog and metrics workflow suites into unified management workflows (Phase 1B.i and Phase 1B.ii), eliminating scheduling gaps and reducing configuration complexity. ([PR #1280](https://github.com/lightspeedwp/.github/pull/1280) — _refactor: Consolidate changelog management workflows (Phase 1B.i)_, [PR #1282](https://github.com/lightspeedwp/.github/pull/1282) — _refactor: Consolidate metrics collection and reporting workflows (Phase 1B.ii)_, [PR #1286](https://github.com/lightspeedwp/.github/pull/1286) — _refactor(workflows): consolidate changelog & metrics workflows (Phase 1B)_, [#1227](https://github.com/lightspeedwp/.github/issues/1227)) +- **Workflows consolidation Phase 1B completion** — Consolidated changelog and metrics workflow suites into unified management workflows (Phase 1B.i and Phase 1B.ii), eliminating scheduling gaps and reducing configuration complexity. ([PR #1280](https://github.com/lightspeedwp/.github/pull/1280) — *refactor: Consolidate changelog management workflows (Phase 1B.i)*, [PR #1282](https://github.com/lightspeedwp/.github/pull/1282) — *refactor: Consolidate metrics collection and reporting workflows (Phase 1B.ii)*, [PR #1286](https://github.com/lightspeedwp/.github/pull/1286) — *refactor(workflows): consolidate changelog & metrics workflows (Phase 1B)*, [#1227](https://github.com/lightspeedwp/.github/issues/1227)) -- **Schema consolidation and PLAN-EXPANDED updates** — Fixed schema compliance issues and documented previously-completed Batch 2-3 standardization work with expanded planning documentation. ([PR #1285](https://github.com/lightspeedwp/.github/pull/1285) — _docs(standards): Fix PLAN-EXPANDED.md schema compliance and document Batch 2-3 agent standardization_) +- **Schema consolidation and PLAN-EXPANDED updates** — Fixed schema compliance issues and documented previously-completed Batch 2-3 standardization work with expanded planning documentation. ([PR #1285](https://github.com/lightspeedwp/.github/pull/1285) — *docs(standards): Fix PLAN-EXPANDED.md schema compliance and document Batch 2-3 agent standardization*) ### Changed - **Harden Gitleaks (pins & checksums)** — Pinned version, SHA verification, explicit permissions. ([PR #1455](https://github.com/lightspeedwp/.github/pull/1455), [#1454](https://github.com/lightspeedwp/.github/issues/1454)) -- **Routine dependency updates** — Multiple rounds of security and feature updates across project dependencies including TypeScript (5.9.3 → 7.0.2), markdownlint-cli2 (0.19.0 → 0.23.1), Svelte ecosystem updates (5.56.3 → 5.56.6 in website), Astro framework (6.4.8 → 7.1.3), and GitHub Actions (setup-node 4 → 7, mergifyio/gha-mergify-ci 22 → 24). ([PR #1027](https://github.com/lightspeedwp/.github/pull/1027) — _chore(deps-dev): bump lint-staged from 17.0.7 to 17.0.8_, [PR #1030](https://github.com/lightspeedwp/.github/pull/1030) — _chore(deps): bump svelte from 5.56.3 to 5.56.4 in /website_, [PR #1032](https://github.com/lightspeedwp/.github/pull/1032) — _chore(deps-dev): bump markdownlint-cli2 from 0.19.0 to 0.23.0_, [PR #1034](https://github.com/lightspeedwp/.github/pull/1034) — _chore(deps): bump @astrojs/svelte from 8.1.2 to 9.0.1 in /website_, [PR #1035](https://github.com/lightspeedwp/.github/pull/1035) — _chore(deps-dev): bump @typescript-eslint/parser from 8.61.1 to 8.62.1_, [PR #1037](https://github.com/lightspeedwp/.github/pull/1037) — _chore(deps): bump marked from 18.0.5 to 18.0.6 in /website_, [PR #1038](https://github.com/lightspeedwp/.github/pull/1038) — _chore(deps): bump astro from 6.4.8 to 7.0.7 in /website_, [PR #1048](https://github.com/lightspeedwp/.github/pull/1048) — _chore(deps): bump mergifyio/gha-mergify-ci from 22 to 23_, [PR #1049](https://github.com/lightspeedwp/.github/pull/1049) — _chore(deps): bump actions/setup-node from 4 to 7_, [PR #1050](https://github.com/lightspeedwp/.github/pull/1050) — _chore(deps-dev): bump typescript from 5.9.3 to 7.0.2_, [PR #1051](https://github.com/lightspeedwp/.github/pull/1051) — _chore(deps-dev): bump markdownlint-cli2 from 0.23.0 to 0.23.1_, [PR #1052](https://github.com/lightspeedwp/.github/pull/1052) — _chore(deps): bump svelte from 5.56.5 to 5.56.6 in /website_, [PR #1053](https://github.com/lightspeedwp/.github/pull/1053) — _chore(deps): bump astro from 7.0.9 to 7.1.0 in /website_, [PR #1056](https://github.com/lightspeedwp/.github/pull/1056) — _chore(deps): bump astro from 7.0.9 to 7.1.3 in /website_, [PR #1060](https://github.com/lightspeedwp/.github/pull/1060) — _chore(deps): bump marked from 18.0.6 to 18.0.7 in /website_, [PR #1061](https://github.com/lightspeedwp/.github/pull/1061) — _chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.64.0 to 8.65.0_, [PR #1062](https://github.com/lightspeedwp/.github/pull/1062) — _chore(deps-dev): bump prettier from 3.9.5 to 3.9.6_, [PR #1063](https://github.com/lightspeedwp/.github/pull/1063) — _chore(deps-dev): bump @typescript-eslint/parser from 8.64.0 to 8.65.0_, [PR #1064](https://github.com/lightspeedwp/.github/pull/1064) — _chore(deps-dev): bump lint-staged from 17.0.8 to 17.1.1_, [PR #1065](https://github.com/lightspeedwp/.github/pull/1065) — _chore(deps-dev): bump @stoplight/spectral-cli from 6.16.1 to 6.16.2_) +- **Routine dependency updates** — Multiple rounds of security and feature updates across project dependencies including TypeScript (5.9.3 → 7.0.2), markdownlint-cli2 (0.19.0 → 0.23.1), Svelte ecosystem updates (5.56.3 → 5.56.6 in website), Astro framework (6.4.8 → 7.1.3), and GitHub Actions (setup-node 4 → 7, mergifyio/gha-mergify-ci 22 → 24). ([PR #1027](https://github.com/lightspeedwp/.github/pull/1027) — *chore(deps-dev): bump lint-staged from 17.0.7 to 17.0.8*, [PR #1030](https://github.com/lightspeedwp/.github/pull/1030) — *chore(deps): bump svelte from 5.56.3 to 5.56.4 in /website*, [PR #1032](https://github.com/lightspeedwp/.github/pull/1032) — *chore(deps-dev): bump markdownlint-cli2 from 0.19.0 to 0.23.0*, [PR #1034](https://github.com/lightspeedwp/.github/pull/1034) — *chore(deps): bump @astrojs/svelte from 8.1.2 to 9.0.1 in /website*, [PR #1035](https://github.com/lightspeedwp/.github/pull/1035) — *chore(deps-dev): bump @typescript-eslint/parser from 8.61.1 to 8.62.1*, [PR #1037](https://github.com/lightspeedwp/.github/pull/1037) — *chore(deps): bump marked from 18.0.5 to 18.0.6 in /website*, [PR #1038](https://github.com/lightspeedwp/.github/pull/1038) — *chore(deps): bump astro from 6.4.8 to 7.0.7 in /website*, [PR #1048](https://github.com/lightspeedwp/.github/pull/1048) — *chore(deps): bump mergifyio/gha-mergify-ci from 22 to 23*, [PR #1049](https://github.com/lightspeedwp/.github/pull/1049) — *chore(deps): bump actions/setup-node from 4 to 7*, [PR #1050](https://github.com/lightspeedwp/.github/pull/1050) — *chore(deps-dev): bump typescript from 5.9.3 to 7.0.2*, [PR #1051](https://github.com/lightspeedwp/.github/pull/1051) — *chore(deps-dev): bump markdownlint-cli2 from 0.23.0 to 0.23.1*, [PR #1052](https://github.com/lightspeedwp/.github/pull/1052) — *chore(deps): bump svelte from 5.56.5 to 5.56.6 in /website*, [PR #1053](https://github.com/lightspeedwp/.github/pull/1053) — *chore(deps): bump astro from 7.0.9 to 7.1.0 in /website*, [PR #1056](https://github.com/lightspeedwp/.github/pull/1056) — *chore(deps): bump astro from 7.0.9 to 7.1.3 in /website*, [PR #1060](https://github.com/lightspeedwp/.github/pull/1060) — *chore(deps): bump marked from 18.0.6 to 18.0.7 in /website*, [PR #1061](https://github.com/lightspeedwp/.github/pull/1061) — *chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.64.0 to 8.65.0*, [PR #1062](https://github.com/lightspeedwp/.github/pull/1062) — *chore(deps-dev): bump prettier from 3.9.5 to 3.9.6*, [PR #1063](https://github.com/lightspeedwp/.github/pull/1063) — *chore(deps-dev): bump @typescript-eslint/parser from 8.64.0 to 8.65.0*, [PR #1064](https://github.com/lightspeedwp/.github/pull/1064) — *chore(deps-dev): bump lint-staged from 17.0.8 to 17.1.1*, [PR #1065](https://github.com/lightspeedwp/.github/pull/1065) — *chore(deps-dev): bump @stoplight/spectral-cli from 6.16.1 to 6.16.2*) - **Harden Dependabot automation** — Enhanced auto-merge via Mergify. ([PR #1059](https://github.com/lightspeedwp/.github/pull/1059)) - **Optimise Actions Phase 2** — Reduced consumption across workflows. ([PR #1057](https://github.com/lightspeedwp/.github/pull/1057)) -- **Babel toolchain upgraded to 8.x** — Major version bump with peer-dependency requirements. Removed deprecated proposal plugins (natively handled by preset-env now). ([PR #1044](https://github.com/lightspeedwp/.github/pull/1044) — _chore(deps): upgrade Babel toolchain to 8.x (coordinated migration)_) +- **Babel toolchain upgraded to 8.x** — Major version bump with peer-dependency requirements. Removed deprecated proposal plugins (natively handled by preset-env now). ([PR #1044](https://github.com/lightspeedwp/.github/pull/1044) — *chore(deps): upgrade Babel toolchain to 8.x (coordinated migration)*) - **Upgrade ESLint to 10.x** — Major bump with 25 violations fixed. ([PR #1046](https://github.com/lightspeedwp/.github/pull/1046)) @@ -542,9 +544,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Upgrade @typescript-eslint** — Version bump. ([PR #1045](https://github.com/lightspeedwp/.github/pull/1045)) -- **GitHub Actions minute optimisation** — Reduced duplicate CI and high-fanout workflow triggers, strengthened concurrency cancellation. ([PR #1054](https://github.com/lightspeedwp/.github/pull/1054) — _ci: reduce Actions minute consumption and add savings watcher_) +- **GitHub Actions minute optimisation** — Reduced duplicate CI and high-fanout workflow triggers, strengthened concurrency cancellation. ([PR #1054](https://github.com/lightspeedwp/.github/pull/1054) — *ci: reduce Actions minute consumption and add savings watcher*) -- **Branch cleanup automation** — Added reusable cleanup script, weekly scheduled workflow, and report generation for stale merged branches with safety guardrails. ([PR #1067](https://github.com/lightspeedwp/.github/pull/1067) — _Add scheduled branch cleanup automation and reporting_, [#1066](https://github.com/lightspeedwp/.github/issues/1066)) +- **Branch cleanup automation** — Added reusable cleanup script, weekly scheduled workflow, and report generation for stale merged branches with safety guardrails. ([PR #1067](https://github.com/lightspeedwp/.github/pull/1067) — *Add scheduled branch cleanup automation and reporting*, [#1066](https://github.com/lightspeedwp/.github/issues/1066)) ### Changed @@ -574,7 +576,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Remove checksums.sha256 manifest** — Removed unverified checksum manifest. ([PR #1392](https://github.com/lightspeedwp/.github/pull/1392), [#1394](https://github.com/lightspeedwp/.github/issues/1394)) -- **Legacy README workflows (Phase 2.4 consolidation)** — Removed three legacy README management workflows (`readme-audit.yml`, `readme-regen.yml`, `readme-update.yml`) consolidated into unified `documentation.yml` workflow. Eliminates 449 lines of code duplication (~44% reduction for README workflows), saves ~3-4 min/month GitHub Actions execution time, and establishes single source of truth for README validation logic. Push trigger re-enabled in `documentation.yml` following consolidation. ([PR #1317](https://github.com/lightspeedwp/.github/pull/1317) — _chore(workflows): cleanup legacy README workflows (Phase 2.4)_, [Epic #1227](https://github.com/lightspeedwp/.github/issues/1227), [#1310](https://github.com/lightspeedwp/.github/issues/1310)) +- **Legacy README workflows (Phase 2.4 consolidation)** — Removed three legacy README management workflows (`readme-audit.yml`, `readme-regen.yml`, `readme-update.yml`) consolidated into unified `documentation.yml` workflow. Eliminates 449 lines of code duplication (~44% reduction for README workflows), saves ~3-4 min/month GitHub Actions execution time, and establishes single source of truth for README validation logic. Push trigger re-enabled in `documentation.yml` following consolidation. ([PR #1317](https://github.com/lightspeedwp/.github/pull/1317) — *chore(workflows): cleanup legacy README workflows (Phase 2.4)*, [Epic #1227](https://github.com/lightspeedwp/.github/issues/1227), [#1310](https://github.com/lightspeedwp/.github/issues/1310)) ### Fixed @@ -590,33 +592,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Validation: Footer truncation fix** — Fixed data loss bug in replaceFooterTail(). ([PR #1123](https://github.com/lightspeedwp/.github/pull/1123), [#1118](https://github.com/lightspeedwp/.github/issues/1118)) -- **Milestone capacity: Type exclusion filtering not implemented** — Configured type exclusions (chore, task, docs) were not applied when counting issues toward capacity limits. Implemented type-based filtering. ([PR #1132](https://github.com/lightspeedwp/.github/pull/1132) — _fix(milestones): implement capacity exclusion filtering_, [#1131](https://github.com/lightspeedwp/.github/issues/1131)) +- **Milestone capacity: Type exclusion filtering not implemented** — Configured type exclusions (chore, task, docs) were not applied when counting issues toward capacity limits. Implemented type-based filtering. ([PR #1132](https://github.com/lightspeedwp/.github/pull/1132) — *fix(milestones): implement capacity exclusion filtering*, [#1131](https://github.com/lightspeedwp/.github/issues/1131)) -- **CI linting workflow blockers** — Consolidated duplicate checks and fixed invalid YAML structure in cleanup-branches.yml workflow. ([PR #1159](https://github.com/lightspeedwp/.github/pull/1159) — _ci: fix linting workflow blockers — consolidate duplicate checks, exclude temporary directories_, [PR #1075](https://github.com/lightspeedwp/.github/pull/1075) — _fix(ci): add missing steps key in cleanup-branches.yml_, [#1074](https://github.com/lightspeedwp/.github/issues/1074)) +- **CI linting workflow blockers** — Consolidated duplicate checks and fixed invalid YAML structure in cleanup-branches.yml workflow. ([PR #1159](https://github.com/lightspeedwp/.github/pull/1159) — *ci: fix linting workflow blockers — consolidate duplicate checks, exclude temporary directories*, [PR #1075](https://github.com/lightspeedwp/.github/pull/1075) — *fix(ci): add missing steps key in cleanup-branches.yml*, [#1074](https://github.com/lightspeedwp/.github/issues/1074)) -- **Mergify config: Invalid speculative_checks field** — Removed unsupported field that was causing Mergify's status checks to permanently fail on all PRs. ([PR #1077](https://github.com/lightspeedwp/.github/pull/1077) — _fix(ci): remove unsupported speculative\_checks field from mergify.yml_, [#1076](https://github.com/lightspeedwp/.github/issues/1076)) +- **Mergify config: Invalid speculative_checks field** — Removed unsupported field that was causing Mergify's status checks to permanently fail on all PRs. ([PR #1077](https://github.com/lightspeedwp/.github/pull/1077) — *fix(ci): remove unsupported speculative\_checks field from mergify.yml*, [#1076](https://github.com/lightspeedwp/.github/issues/1076)) -- **Branch cleanup automation: Safety hardening** — Fixed four critical safety issues: daysSince() date handling, isMerged() false positives, deleteLocalBranch() force-delete risk, and error handling for invalid patterns. ([PR #1071](https://github.com/lightspeedwp/.github/pull/1071) — _fix: improve branch cleanup safety and robustness_, [#1069](https://github.com/lightspeedwp/.github/issues/1069)) +- **Branch cleanup automation: Safety hardening** — Fixed four critical safety issues: daysSince() date handling, isMerged() false positives, deleteLocalBranch() force-delete risk, and error handling for invalid patterns. ([PR #1071](https://github.com/lightspeedwp/.github/pull/1071) — *fix: improve branch cleanup safety and robustness*, [#1069](https://github.com/lightspeedwp/.github/issues/1069)) -- **Meta-agent workflow: Branch name accumulation** — Fixed branch naming to prevent PR accumulation and added Mergify rule. ([PR #1138](https://github.com/lightspeedwp/.github/pull/1138) — _fix(meta): use fixed branch name to prevent PR accumulation + add mergify rule_) +- **Meta-agent workflow: Branch name accumulation** — Fixed branch naming to prevent PR accumulation and added Mergify rule. ([PR #1138](https://github.com/lightspeedwp/.github/pull/1138) — *fix(meta): use fixed branch name to prevent PR accumulation + add mergify rule*) -- **YAML deprecation and footer validation** — Resolved js-yaml 5.x safeLoad deprecation and fixed false positives in footer validation. ([PR #1137](https://github.com/lightspeedwp/.github/pull/1137) — _fix(ci): resolve yaml.safeLoad deprecation and footer validation false positives_) +- **YAML deprecation and footer validation** — Resolved js-yaml 5.x safeLoad deprecation and fixed false positives in footer validation. ([PR #1137](https://github.com/lightspeedwp/.github/pull/1137) — *fix(ci): resolve yaml.safeLoad deprecation and footer validation false positives*) -- **Priority field vocabulary reconciliation** — Reconciled conflicting Priority field values and added regression test. ([PR #1148](https://github.com/lightspeedwp/.github/pull/1148) — _fix: reconcile Priority field vocabulary + add parity regression test_) +- **Priority field vocabulary reconciliation** — Reconciled conflicting Priority field values and added regression test. ([PR #1148](https://github.com/lightspeedwp/.github/pull/1148) — *fix: reconcile Priority field vocabulary + add parity regression test*) -- **Project-meta-sync secret-gating** — Made GitHub App secret gating explicit to fail loudly on configuration errors instead of silently. ([PR #1150](https://github.com/lightspeedwp/.github/pull/1150) — _build: make project-meta-sync secret-gating explicit (fail loud, not silent)_) +- **Project-meta-sync secret-gating** — Made GitHub App secret gating explicit to fail loudly on configuration errors instead of silently. ([PR #1150](https://github.com/lightspeedwp/.github/pull/1150) — *build: make project-meta-sync secret-gating explicit (fail loud, not silent)*) -- **CI template enforcement: Re-fetch live issue state** — Fixed stale issue state being used in template enforcement guards. ([PR #1086](https://github.com/lightspeedwp/.github/pull/1086) — _fix(ci): re-fetch live issue state in template-enforcement guards_, [#1085](https://github.com/lightspeedwp/.github/issues/1085)) +- **CI template enforcement: Re-fetch live issue state** — Fixed stale issue state being used in template enforcement guards. ([PR #1086](https://github.com/lightspeedwp/.github/pull/1086) — *fix(ci): re-fetch live issue state in template-enforcement guards*, [#1085](https://github.com/lightspeedwp/.github/issues/1085)) -- **Meta-agent: Honour --dry-run flag** — Fixed dry-run mode not being honoured in footer, badges, and metrics writes. ([PR #1084](https://github.com/lightspeedwp/.github/pull/1084) — _fix: honour --dry-run in meta.agent.js footer, badges, and metrics writes_, [#1083](https://github.com/lightspeedwp/.github/issues/1083)) +- **Meta-agent: Honour --dry-run flag** — Fixed dry-run mode not being honoured in footer, badges, and metrics writes. ([PR #1084](https://github.com/lightspeedwp/.github/pull/1084) — *fix: honour --dry-run in meta.agent.js footer, badges, and metrics writes*, [#1083](https://github.com/lightspeedwp/.github/issues/1083)) -- **CI label governance: Missing labels** — Added missing type label aliases and removed non-existent labels from governance configuration. ([PR #1202](https://github.com/lightspeedwp/.github/pull/1202) — _fix(ci): add missing aliases for type labels_, [PR #1203](https://github.com/lightspeedwp/.github/pull/1203) — _fix(ci): remove non-existent comp:help-tabs from label governance_) +- **CI label governance: Missing labels** — Added missing type label aliases and removed non-existent labels from governance configuration. ([PR #1202](https://github.com/lightspeedwp/.github/pull/1202) — *fix(ci): add missing aliases for type labels*, [PR #1203](https://github.com/lightspeedwp/.github/pull/1203) — *fix(ci): remove non-existent comp:help-tabs from label governance*) -- **Template enforcement: Silent issue reopening** — Replaced silent reopening with guidance comment and status:needs-more-info label for incomplete issues. ([PR #1201](https://github.com/lightspeedwp/.github/pull/1201) — _fix(ci): replace silent reopening with guidance comment and label_, [#1014](https://github.com/lightspeedwp/.github/issues/1014)) +- **Template enforcement: Silent issue reopening** — Replaced silent reopening with guidance comment and status:needs-more-info label for incomplete issues. ([PR #1201](https://github.com/lightspeedwp/.github/pull/1201) — *fix(ci): replace silent reopening with guidance comment and label*, [#1014](https://github.com/lightspeedwp/.github/issues/1014)) -- **Footer cleanup and validation** — Comprehensive footer format standardization and validation improvements. ([PR #1115](https://github.com/lightspeedwp/.github/pull/1115) — _fix: footer cleanup and validation_) +- **Footer cleanup and validation** — Comprehensive footer format standardization and validation improvements. ([PR #1115](https://github.com/lightspeedwp/.github/pull/1115) — *fix: footer cleanup and validation*) -- **Babel peer-dependency conflict resolution** — Fixed critical npm ci blocker caused by Babel 8.x peer-dependency constraints, enabling consistent builds across development environments. ([PR #1043](https://github.com/lightspeedwp/.github/pull/1043) — _fix(deps): resolve Babel peer-dependency conflict blocking npm ci_) +- **Babel peer-dependency conflict resolution** — Fixed critical npm ci blocker caused by Babel 8.x peer-dependency constraints, enabling consistent builds across development environments. ([PR #1043](https://github.com/lightspeedwp/.github/pull/1043) — *fix(deps): resolve Babel peer-dependency conflict blocking npm ci*) - **Documentation: Schema & AI standards** — Added schema consolidation and AI reference standards documentation. ([PR #1211](https://github.com/lightspeedwp/.github/pull/1211), [PR #1221](https://github.com/lightspeedwp/.github/pull/1221), [PR #1222](https://github.com/lightspeedwp/.github/pull/1222)) @@ -630,11 +632,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Issue automation hardening** — Expanded `checklist-finalisation.yml` to auto-tick `Steps / Checklist` and `Acceptance Criteria` sections on issue close (previously only DoR/DoD were covered). Added `enforce-close-guard` job to `template-enforcement.yml`: issues closed as "completed" that are missing DoR/DoD sections or still carry `status:needs-more-info` are automatically re-opened with an explanation comment. Activated `issues.agent.js` apply mode — the workflow now writes `status:needs-triage`, `priority:normal`, and a detected `type:*` label to newly opened issues when those label categories are not already present; updated `issues.yml` permissions from `issues: read` to `issues: write`. ([#1014](https://github.com/lightspeedwp/.github/issues/1014)) -- **GitHub Merge Queue support** — Added `merge_group: types: [checks_requested]` trigger to `checks.yml`, `validate-pr-template.yml`, and `main-branch-guard.yml` so required status checks fire correctly inside GitHub's Merge Queue. Skipped branch-name validation for `merge_group` runs and updated the changed-files comparison to use merge-queue SHAs. Added `merge_queue` rule (ALLGREEN grouping, 60-minute check timeout) to both `develop` and `main` branch rulesets. ([PR #1008](https://github.com/lightspeedwp/.github/pull/1008) — _ci(workflows): add merge\_group trigger to unblock GitHub Merge Queue_, [Issue #1008](https://github.com/lightspeedwp/.github/issues/1008)) +- **GitHub Merge Queue support** — Added `merge_group: types: [checks_requested]` trigger to `checks.yml`, `validate-pr-template.yml`, and `main-branch-guard.yml` so required status checks fire correctly inside GitHub's Merge Queue. Skipped branch-name validation for `merge_group` runs and updated the changed-files comparison to use merge-queue SHAs. Added `merge_queue` rule (ALLGREEN grouping, 60-minute check timeout) to both `develop` and `main` branch rulesets. ([PR #1008](https://github.com/lightspeedwp/.github/pull/1008) — *ci(workflows): add merge\_group trigger to unblock GitHub Merge Queue*, [Issue #1008](https://github.com/lightspeedwp/.github/issues/1008)) -- **Mermaid WCAG 2.2 AA colour contrast validation** — Added `scripts/validation/validate-mermaid-colour-contrast.js` which checks every `style` declaration in Mermaid diagrams against a pre-verified WCAG 2.2 AA palette (minimum 4.5:1 contrast ratio). The validator supports `--changed-files` scoping for CI efficiency, flags unparseable hex values as errors, strips inline `%%` comments before parsing, and generates a dated markdown report under `.github/reports/mermaid/`. Added `npm run validate:mermaid-contrast` script. Updated `.github/workflows/validate-mermaid-pr.yml` to run all three diagram checks (syntax, accessibility, contrast) on every PR that modifies `.md`/`.mdx` files, posting a consolidated status comment. Updated `instructions/mermaid.instructions.md` with the approved seven-role WCAG AA palette and required structure. Fixed all existing diagram `style` declarations across `README.md`, `docs/AGENT_CREATION.md`, `profile/README.md`, `scripts/README.md`, `tests/README.md`, and `.github/ISSUE_TEMPLATE/README.md` to use the approved palette triples (`fill`, `color`, `stroke`). ([PR #977](https://github.com/lightspeedwp/.github/pull/977) — _feat(mermaid): WCAG 2.2 AA colour contrast validation and updated diagram standards_, [#976](https://github.com/lightspeedwp/.github/issues/976)) +- **Mermaid WCAG 2.2 AA colour contrast validation** — Added `scripts/validation/validate-mermaid-colour-contrast.js` which checks every `style` declaration in Mermaid diagrams against a pre-verified WCAG 2.2 AA palette (minimum 4.5:1 contrast ratio). The validator supports `--changed-files` scoping for CI efficiency, flags unparseable hex values as errors, strips inline `%%` comments before parsing, and generates a dated markdown report under `.github/reports/mermaid/`. Added `npm run validate:mermaid-contrast` script. Updated `.github/workflows/validate-mermaid-pr.yml` to run all three diagram checks (syntax, accessibility, contrast) on every PR that modifies `.md`/`.mdx` files, posting a consolidated status comment. Updated `instructions/mermaid.instructions.md` with the approved seven-role WCAG AA palette and required structure. Fixed all existing diagram `style` declarations across `README.md`, `docs/AGENT_CREATION.md`, `profile/README.md`, `scripts/README.md`, `tests/README.md`, and `.github/ISSUE_TEMPLATE/README.md` to use the approved palette triples (`fill`, `color`, `stroke`). ([PR #977](https://github.com/lightspeedwp/.github/pull/977) — *feat(mermaid): WCAG 2.2 AA colour contrast validation and updated diagram standards*, [#976](https://github.com/lightspeedwp/.github/issues/976)) -- **Claude Code session-start hook** — Added `.claude/hooks/session-start.sh` and `.claude/settings.json` to install npm dependencies and auto-rename auto-generated `claude/` prefixed branches (forbidden by `CLAUDE.md`) to valid `chore/session-{hash}` names at the start of every remote session. Prevents forbidden branch names from ever reaching a commit and reduces manual cleanup overhead. ([PR #984](https://github.com/lightspeedwp/.github/pull/984) — _feat(ops): session-start hook — install deps and enforce branch naming_) +- **Claude Code session-start hook** — Added `.claude/hooks/session-start.sh` and `.claude/settings.json` to install npm dependencies and auto-rename auto-generated `claude/` prefixed branches (forbidden by `CLAUDE.md`) to valid `chore/session-{hash}` names at the start of every remote session. Prevents forbidden branch names from ever reaching a commit and reduces manual cleanup overhead. ([PR #984](https://github.com/lightspeedwp/.github/pull/984) — *feat(ops): session-start hook — install deps and enforce branch naming*) ### Fixed @@ -662,45 +664,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Mergify flaky test detection** — Added `.github/workflows/flaky-test-detection.yml` to detect flaky Jest unit tests via Mergify CI. Runs 5 parallel matrix jobs every 12 hours on weekdays and uploads JUnit XML results to Mergify using `mergifyio/gha-mergify-ci@v14` with `flaky_test_detection: true`. ([#979](https://github.com/lightspeedwp/.github/issues/979)) -- **Branch Governance Hardening** — Converts advisory branching rules into machine-enforceable controls across three layers. Added GitHub Ruleset JSON files for `develop` and `main` with application instructions (`scripts/validation/rulesets/`). Extended `scripts/validation/validate-branch-name.js` with a `--base` flag for merge-target policy enforcement (only `release/*` and `hotfix/*` may target `main`) and branch-reuse detection via git log and `CHANGELOG.md` references. Added 35-test Jest suite covering naming, base-branch policy, and reuse prevention. Updated `AGENTS.md` and `CLAUDE.md` with Branch Governance and Branch Reuse Prevention sections. ([#898](https://github.com/lightspeedwp/.github/issues/898), [#899](https://github.com/lightspeedwp/.github/issues/899), [#900](https://github.com/lightspeedwp/.github/issues/900), [#901](https://github.com/lightspeedwp/.github/issues/901), [PR #915](https://github.com/lightspeedwp/.github/pull/915) — _ops(governance): harden branch naming, merge-target and reuse enforcement_) +- **Branch Governance Hardening** — Converts advisory branching rules into machine-enforceable controls across three layers. Added GitHub Ruleset JSON files for `develop` and `main` with application instructions (`scripts/validation/rulesets/`). Extended `scripts/validation/validate-branch-name.js` with a `--base` flag for merge-target policy enforcement (only `release/*` and `hotfix/*` may target `main`) and branch-reuse detection via git log and `CHANGELOG.md` references. Added 35-test Jest suite covering naming, base-branch policy, and reuse prevention. Updated `AGENTS.md` and `CLAUDE.md` with Branch Governance and Branch Reuse Prevention sections. ([#898](https://github.com/lightspeedwp/.github/issues/898), [#899](https://github.com/lightspeedwp/.github/issues/899), [#900](https://github.com/lightspeedwp/.github/issues/900), [#901](https://github.com/lightspeedwp/.github/issues/901), [PR #915](https://github.com/lightspeedwp/.github/pull/915) — *ops(governance): harden branch naming, merge-target and reuse enforcement*) -- **Issue Template Frontmatter Policy** — Documented the decision to keep frontmatter in issue templates (no stripping). Updated `docs/ISSUE_CREATION_GUIDE.md` with frontmatter retention rationale. Fixed regex in `scripts/agents/includes/check-template-labels.js` to correctly strip bracket characters without an invalid character class. Updated `docs/AUTOMATION.md` and `.github/custom-instructions.md` with alignment notes. ([#880](https://github.com/lightspeedwp/.github/issues/880), [PR #893](https://github.com/lightspeedwp/.github/pull/893) — _docs: codify issue template frontmatter policy (#880)_) +- **Issue Template Frontmatter Policy** — Documented the decision to keep frontmatter in issue templates (no stripping). Updated `docs/ISSUE_CREATION_GUIDE.md` with frontmatter retention rationale. Fixed regex in `scripts/agents/includes/check-template-labels.js` to correctly strip bracket characters without an invalid character class. Updated `docs/AUTOMATION.md` and `.github/custom-instructions.md` with alignment notes. ([#880](https://github.com/lightspeedwp/.github/issues/880), [PR #893](https://github.com/lightspeedwp/.github/pull/893) — *docs: codify issue template frontmatter policy (#880)*) -- **Template Enforcement Governance Closeout** — Added the canonical PR routing map, refreshed the root PR router and governance guidance, updated the template-enforcement workflow and fixtures, and split the remaining organisation-admin checks into `REMOTE_ADMIN_CHECKS.md`. ([PR #955](https://github.com/lightspeedwp/.github/pull/955) — _Align template governance routing_) +- **Template Enforcement Governance Closeout** — Added the canonical PR routing map, refreshed the root PR router and governance guidance, updated the template-enforcement workflow and fixtures, and split the remaining organisation-admin checks into `REMOTE_ADMIN_CHECKS.md`. ([PR #955](https://github.com/lightspeedwp/.github/pull/955) — *Align template governance routing*) - **Test Coverage Implementation Phase 3: Linting agent coverage** — Replaced the linting agent stub with a deterministic helper surface for lint target parsing, rule selection, findings grouping, report formatting, config loading, cache isolation, and async orchestration. Added a focused Jest suite that covers parsing, selection ordering, invalid config handling, malformed findings, empty-input handling, and repository-wide lint/test validation. ([#935](https://github.com/lightspeedwp/.github/issues/935)) - **Test Coverage Implementation Phase 2: Metrics agent coverage** — Added a pure, testable metrics-agent helper module with repository-level aggregation, issue and pull request metric calculations, markdown/CSV report generation, date-range filtering, and multi-repository support. Added a focused Jest suite that covers the collection, aggregation, reporting, and error-handling paths for `scripts/agents/metrics.agent.js`. ([#934](https://github.com/lightspeedwp/.github/issues/934)) -- **Plugin Pack Waves: WordPress 10-plugin catalogue and AI readiness assessor planning set** — Expanded `.github/projects/active/plugin-pack-waves/` from legacy wave scaffolding into a complete WordPress-focused planning pack with a ten-plugin catalogue (P01-P10), issue draft files, OpenSpec strict proposal inputs, execution controls (`ISSUE_EXECUTION_PLAN.md`, `ISSUE_REGISTER.md`, `RUN_LOG.md`), and linked GitHub issues [#940](https://github.com/lightspeedwp/.github/issues/940) through [#950](https://github.com/lightspeedwp/.github/issues/950), including the new **WP AI Readiness Assessor** task for WordPress site readiness evaluation. ([PR #951](https://github.com/lightspeedwp/.github/pull/951) — _feat: expand plugin-pack-waves to 10 WordPress plugin tasks and AI readiness assessor_) +- **Plugin Pack Waves: WordPress 10-plugin catalogue and AI readiness assessor planning set** — Expanded `.github/projects/active/plugin-pack-waves/` from legacy wave scaffolding into a complete WordPress-focused planning pack with a ten-plugin catalogue (P01-P10), issue draft files, OpenSpec strict proposal inputs, execution controls (`ISSUE_EXECUTION_PLAN.md`, `ISSUE_REGISTER.md`, `RUN_LOG.md`), and linked GitHub issues [#940](https://github.com/lightspeedwp/.github/issues/940) through [#950](https://github.com/lightspeedwp/.github/issues/950), including the new **WP AI Readiness Assessor** task for WordPress site readiness evaluation. ([PR #951](https://github.com/lightspeedwp/.github/pull/951) — *feat: expand plugin-pack-waves to 10 WordPress plugin tasks and AI readiness assessor*) - **Awesome GitHub Site Phase 06: Wapuu mascot system** — Added a reusable `WapuuHero` Astro component with a canonical page-type mapping, copied the three confirmed Wapuu assets into `website/public/assets/wapuus/`, and wired the learn, cookbook, tools, and catalogue hero sections to render the correct mascot with responsive hiding at ≤860px and decorative accessibility attributes. ([#864](https://github.com/lightspeedwp/.github/issues/864)) -- **Awesome GitHub Site Phase 07: Catalogue list pages with filter bar and Wapuu hero** — Replaced the old catalogue index with a spec-aligned `/c/[cat]` route, added the shared Wapuu hero component, introduced tag-chip filtering with AND logic, and surfaced the category type note and install-action cards for all eight catalogue pages. ([#866](https://github.com/lightspeedwp/.github/issues/866), [PR #867](https://github.com/lightspeedwp/.github/pull/867) — _feat(ag-p07): 8 catalogue list pages — hero, filter bar, item grid_) +- **Awesome GitHub Site Phase 07: Catalogue list pages with filter bar and Wapuu hero** — Replaced the old catalogue index with a spec-aligned `/c/[cat]` route, added the shared Wapuu hero component, introduced tag-chip filtering with AND logic, and surfaced the category type note and install-action cards for all eight catalogue pages. ([#866](https://github.com/lightspeedwp/.github/issues/866), [PR #867](https://github.com/lightspeedwp/.github/pull/867) — *feat(ag-p07): 8 catalogue list pages — hero, filter bar, item grid*) -- **Awesome GitHub Site Phase 05: Homepage all 5 blocks wired to live data** — Rebuilt the homepage with the spec-aligned hero, live catalogue counts, feature strip, and Cook+Learn cards. Added the typed catalogue exports used by the homepage counts, copied the Wapuu assets into `website/public/assets/wapuus/`, and added an `onboarding/` alias that redirects to `getting-started/` for the primary CTA. ([#861](https://github.com/lightspeedwp/.github/issues/861), [PR #862](https://github.com/lightspeedwp/.github/pull/862) — _feat(ag-p05): homepage — all 5 blocks wired to live data_) +- **Awesome GitHub Site Phase 05: Homepage all 5 blocks wired to live data** — Rebuilt the homepage with the spec-aligned hero, live catalogue counts, feature strip, and Cook+Learn cards. Added the typed catalogue exports used by the homepage counts, copied the Wapuu assets into `website/public/assets/wapuus/`, and added an `onboarding/` alias that redirects to `getting-started/` for the primary CTA. ([#861](https://github.com/lightspeedwp/.github/issues/861), [PR #862](https://github.com/lightspeedwp/.github/pull/862) — *feat(ag-p05): homepage — all 5 blocks wired to live data*) - **Awesome GitHub Site Phase 13: Search command palette** — Site-wide ⌘K search palette (`website/src/components/SearchPalette.astro` + `website/src/scripts/search.js`) added to `AwesomeGithubLayout`. Build-time JSON index of all catalogue items serialised into a `data-items` attribute. Empty query shows 7 Popular items; typed query uses multi-word AND substring matching across name, description, tags, and category (capped at 12 results). Keyboard navigation (↑↓ arrow keys, Enter to open result, Escape to close, Tab focus-trap between input, close button, and result items). Fully accessible: `role="dialog"`, `aria-modal`, `aria-selected`, focus returns to trigger on dismiss. ([#764](https://github.com/lightspeedwp/.github/issues/764), [#889](https://github.com/lightspeedwp/.github/pull/889)) -- **Awesome GitHub Site Phase 11: Tools page + Phosphor Icons sitewide** — Standalone `/c/tools.astro` with astropuu Wapuu hero, section nav pills (AI Defaults, Scripts, Schemas, Config & Setup), and a build-time Phosphor icon loader (`website/src/lib/phosphor.ts`) using `createRequire` for robust package resolution. Updated `Icon.astro` with `ph:` prefix routing to load any Phosphor icon at SSG time. Migrated item card and type badge styles into `global.css` for reuse across catalogue and tools pages. Updated all category, nav, learn, and home icons to Phosphor equivalents sitewide. ([#885](https://github.com/lightspeedwp/.github/issues/885), [PR #886](https://github.com/lightspeedwp/.github/pull/886) — _feat(ag-p11): tools page — hero, section nav pills, grouped tool sections_) +- **Awesome GitHub Site Phase 11: Tools page + Phosphor Icons sitewide** — Standalone `/c/tools.astro` with astropuu Wapuu hero, section nav pills (AI Defaults, Scripts, Schemas, Config & Setup), and a build-time Phosphor icon loader (`website/src/lib/phosphor.ts`) using `createRequire` for robust package resolution. Updated `Icon.astro` with `ph:` prefix routing to load any Phosphor icon at SSG time. Migrated item card and type badge styles into `global.css` for reuse across catalogue and tools pages. Updated all category, nav, learn, and home icons to Phosphor equivalents sitewide. ([#885](https://github.com/lightspeedwp/.github/issues/885), [PR #886](https://github.com/lightspeedwp/.github/pull/886) — *feat(ag-p11): tools page — hero, section nav pills, grouped tool sections*) -- **Awesome GitHub Site Phase 02: CSS Token Layer + Global Styles** — Established the complete CSS foundation for the Awesome GitHub site. Added `website/src/styles/site-tokens.css` with app-specific surface tokens (`--panel`, `--panel-2`, `--hair`), font stacks, radius/shadow/transition scales, and `color-scheme` declarations for both light and dark themes. Added `website/src/styles/global.css` with container system (`.wrap` 1320px, `.wrap-prose` 820px), section rhythm via `clamp()`, button system (`.btn-primary`, `.btn-ghost`, `.btn-soft`, `.icon-btn`) with `:focus-visible` rings and browser-compat fallbacks, breadcrumb, kbd chip, burger breakpoints, `.md` prose styles, and scroll-motion accessibility guard. Fixed `BaseLayout.astro` CSS import order and corrected `localStorage` theme key to `ag-theme`. ([#852](https://github.com/lightspeedwp/.github/issues/852), [PR #853](https://github.com/lightspeedwp/.github/pull/853) — _feat(ag-p02): CSS token layer, global styles, and theme key alignment_) +- **Awesome GitHub Site Phase 02: CSS Token Layer + Global Styles** — Established the complete CSS foundation for the Awesome GitHub site. Added `website/src/styles/site-tokens.css` with app-specific surface tokens (`--panel`, `--panel-2`, `--hair`), font stacks, radius/shadow/transition scales, and `color-scheme` declarations for both light and dark themes. Added `website/src/styles/global.css` with container system (`.wrap` 1320px, `.wrap-prose` 820px), section rhythm via `clamp()`, button system (`.btn-primary`, `.btn-ghost`, `.btn-soft`, `.icon-btn`) with `:focus-visible` rings and browser-compat fallbacks, breadcrumb, kbd chip, burger breakpoints, `.md` prose styles, and scroll-motion accessibility guard. Fixed `BaseLayout.astro` CSS import order and corrected `localStorage` theme key to `ag-theme`. ([#852](https://github.com/lightspeedwp/.github/issues/852), [PR #853](https://github.com/lightspeedwp/.github/pull/853) — *feat(ag-p02): CSS token layer, global styles, and theme key alignment*) -- **Awesome GitHub Site: UI Redesign — Dark Mode, Navigation & Responsive Layout** — Complete navigation and UI overhaul. Added desktop Browse mega-dropdown with 4-column category grid (hover/click open, keyboard Escape dismiss, focus-out close). Added full-height mobile drawer sliding from right with backdrop overlay, scroll-lock, and `inert` guard. Fixed dark-mode nav header (was showing light background). Added fluid responsive CSS tokens for spacing and font sizes across breakpoints. Improved accessibility: Disclosure pattern (`aria-expanded`/`aria-controls`, no `role="menu"`), `aria-pressed` on theme toggle buttons, `inert` on closed drawer, nav z-index raised above drawer so hamburger stays accessible. ([#847](https://github.com/lightspeedwp/.github/issues/847), [PR #845](https://github.com/lightspeedwp/.github/pull/845) — _feat(website): UI redesign — dark mode, mega menu, mobile drawer, responsive layout_) +- **Awesome GitHub Site: UI Redesign — Dark Mode, Navigation & Responsive Layout** — Complete navigation and UI overhaul. Added desktop Browse mega-dropdown with 4-column category grid (hover/click open, keyboard Escape dismiss, focus-out close). Added full-height mobile drawer sliding from right with backdrop overlay, scroll-lock, and `inert` guard. Fixed dark-mode nav header (was showing light background). Added fluid responsive CSS tokens for spacing and font sizes across breakpoints. Improved accessibility: Disclosure pattern (`aria-expanded`/`aria-controls`, no `role="menu"`), `aria-pressed` on theme toggle buttons, `inert` on closed drawer, nav z-index raised above drawer so hamburger stays accessible. ([#847](https://github.com/lightspeedwp/.github/issues/847), [PR #845](https://github.com/lightspeedwp/.github/pull/845) — *feat(website): UI redesign — dark mode, mega menu, mobile drawer, responsive layout*) -- **Awesome GitHub Site: Phosphor Icon System** — Replaced all emoji icons (🤖 📖 💬 ✨ 🛡️ ⚙️ 🧩 🔧 🗺️ ✅ 📚 🍳 ☀️ 🌙) with Phosphor SVG icons via a new `Icon.astro` component that reads from `@phosphor-icons/core` at build time — zero runtime JS. Covers catalogue type icons, learning track icons, getting-started cards, cookbook placeholder, and theme toggle buttons. ([#844](https://github.com/lightspeedwp/.github/issues/844), [PR #843](https://github.com/lightspeedwp/.github/pull/843) — _feat(website): Phosphor icon system + mobile nav fix_) +- **Awesome GitHub Site: Phosphor Icon System** — Replaced all emoji icons (🤖 📖 💬 ✨ 🛡️ ⚙️ 🧩 🔧 🗺️ ✅ 📚 🍳 ☀️ 🌙) with Phosphor SVG icons via a new `Icon.astro` component that reads from `@phosphor-icons/core` at build time — zero runtime JS. Covers catalogue type icons, learning track icons, getting-started cards, cookbook placeholder, and theme toggle buttons. ([#844](https://github.com/lightspeedwp/.github/issues/844), [PR #843](https://github.com/lightspeedwp/.github/pull/843) — *feat(website): Phosphor icon system + mobile nav fix*) ### Changed ### Fixed -- **Issue-Field Write Boundary Verification Docs (`#879`)** — Clarified the live project-meta-sync write boundary, documented that `Status`/`Priority`/`Type` are label-mapped while `Effort` and `Start date` are derived from canonical defaults/runtime context, and added a dedicated verification audit record for private-project issue-field support decisions. ([PR #884](https://github.com/lightspeedwp/.github/pull/884) — _docs: verify private-project issue-field write boundary (#879)_) +- **Issue-Field Write Boundary Verification Docs (`#879`)** — Clarified the live project-meta-sync write boundary, documented that `Status`/`Priority`/`Type` are label-mapped while `Effort` and `Start date` are derived from canonical defaults/runtime context, and added a dedicated verification audit record for private-project issue-field support decisions. ([PR #884](https://github.com/lightspeedwp/.github/pull/884) — *docs: verify private-project issue-field write boundary (#879)*) -- **Awesome GitHub Site: Mobile Nav Menu** — Fixed `z-index` on the fixed-position mobile menu so it renders above page content; added body scroll-lock (`overflow: hidden`) while the menu is open to prevent background scroll. ([#844](https://github.com/lightspeedwp/.github/issues/844), [PR #843](https://github.com/lightspeedwp/.github/pull/843) — _feat(website): Phosphor icon system + mobile nav fix_) +- **Awesome GitHub Site: Mobile Nav Menu** — Fixed `z-index` on the fixed-position mobile menu so it renders above page content; added body scroll-lock (`overflow: hidden`) while the menu is open to prevent background scroll. ([#844](https://github.com/lightspeedwp/.github/issues/844), [PR #843](https://github.com/lightspeedwp/.github/pull/843) — *feat(website): Phosphor icon system + mobile nav fix*) - **Awesome GitHub Site: Phase 01 Scaffold Merge Hardening (`#851`)** — Finalised the Phase 01 scaffold branch for merge by replacing stale `/talk/*` links with valid `/wceu-2026/slides/*` routes on agent pages and aligning key CI workflows to Node `22.22.1` so Astro 6/lint-staged engine checks pass in PR validation. -- **Awesome GitHub Site: Complete Astro Rebuild** — Rebuilt the Awesome GitHub site from a React/Babel prototype into a production-ready Astro 5 static site. Includes: a fully-typed TypeScript data layer (`catalogue.ts`, `learn.ts`, `glossary.ts`) porting 110+ catalogue items across 8 categories; Svelte `SearchPalette` component with Cmd/Ctrl+K activation; 252 statically-generated pages (catalogue, learn tracks, glossary, cookbook, getting-started, why); detail pages with Install-in-VS-Code, copy-URL, copy-file, and View-on-GitHub action buttons; mobile hamburger navigation with animated X icon and keyboard Escape support; expanded footer with brand and navigation columns; and a `why.astro` editorial page. ([PR #841](https://github.com/lightspeedwp/.github/pull/841) — _fix(website): nav contrast, accessibility, and colour consistency_, [#842](https://github.com/lightspeedwp/.github/issues/842)) +- **Awesome GitHub Site: Complete Astro Rebuild** — Rebuilt the Awesome GitHub site from a React/Babel prototype into a production-ready Astro 5 static site. Includes: a fully-typed TypeScript data layer (`catalogue.ts`, `learn.ts`, `glossary.ts`) porting 110+ catalogue items across 8 categories; Svelte `SearchPalette` component with Cmd/Ctrl+K activation; 252 statically-generated pages (catalogue, learn tracks, glossary, cookbook, getting-started, why); detail pages with Install-in-VS-Code, copy-URL, copy-file, and View-on-GitHub action buttons; mobile hamburger navigation with animated X icon and keyboard Escape support; expanded footer with brand and navigation columns; and a `why.astro` editorial page. ([PR #841](https://github.com/lightspeedwp/.github/pull/841) — *fix(website): nav contrast, accessibility, and colour consistency*, [#842](https://github.com/lightspeedwp/.github/issues/842)) - **LightSpeedWP Agency Homepage: Complete Component System** — Built a production-ready homepage for the LightSpeedWP Agency website with 9 modular Astro components (Nav, HeroPlanner, TrustStrip, SolutionPaths, WhyLightSpeed, FAQ, FinalCTA, Footer, ContactOverlay). Features include sticky navigation with scroll detection, mobile drawer with theme toggle, AI project planner with form validation, responsive stats grid, 4 solution path cards with highlighting, single-open accordion FAQ with 7 questions, contact modal with success state, and comprehensive design system with light/dark mode support across 9 responsive breakpoints. All components include WCAG 2.2 AA accessibility attributes, semantic HTML5, and CSS variable theming for consistent branding. @@ -762,7 +764,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `wceu-2026/SLIDES_INDEX.md` — Quick-reference index of all 24 slides organised by section (Hook/Architecture/Implementation/Adoption) with layout, timing, key message, accent colour assignments, and build checklist - - All three files validated for WCAG 2.2 AA accessibility compliance and ready for Phase 3 Google Slides implementation ([PR #640](https://github.com/lightspeedwp/.github/pull/640) — _Phase 2 refinement: Complete 24-slide speaker notes and visual design specs_) + - All three files validated for WCAG 2.2 AA accessibility compliance and ready for Phase 3 Google Slides implementation ([PR #640](https://github.com/lightspeedwp/.github/pull/640) — *Phase 2 refinement: Complete 24-slide speaker notes and visual design specs*) - **Documentation Consolidation & Repository Structure Refinement** — Streamlined documentation by consolidating redundant files and clarifying scope boundaries: @@ -914,7 +916,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated `scripts/README.md` with usage examples and feature documentation ([#13](https://github.com/lightspeedwp/.github/issues/13), [#16](https://github.com/lightspeedwp/.github/issues/16)) -- **Release Automation Framework Phase 2: Semantic Versioning & Release Notes Generation** — Implemented core semantic versioning detection and release notes formatting modules enabling automated version bumping and changelog generation ([PR #598](https://github.com/lightspeedwp/.github/pull/598) — _feat(#592): Release Workflow - Add post-release changelog validation_): +- **Release Automation Framework Phase 2: Semantic Versioning & Release Notes Generation** — Implemented core semantic versioning detection and release notes formatting modules enabling automated version bumping and changelog generation ([PR #598](https://github.com/lightspeedwp/.github/pull/598) — *feat(#592): Release Workflow - Add post-release changelog validation*): - `scripts/agents/includes/versionDetector.js` — Semantic version bump detection from changelog entries with Conventional Commits integration. Analyzes breaking changes, feature additions, deprecations, and removals to determine patch/minor/major version bumps per Semantic Versioning 2.0.0. Functions: parseVersion, formatVersion, compareVersions, determineBumpType, calculateNextVersion, detectBump, suggestNextVersion @@ -950,7 +952,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **5 Developer Experience & Strategy Prompts**: Getting started, best practices, troubleshooting/debugging, roadmap/vision, and case studies/success stories - - Each prompt includes system overview, key components, integration points, 3+ use cases, 12-15 slide structure, evidence anchors linking to repository files, design notes for visual consistency, and quality bars for validation. Enables presentation creation with NotebookLM, Figma, and other design tools. ([PR #549](https://github.com/lightspeedwp/.github/pull/549) — _docs: add comprehensive 25-slide-deck prompt suite_) + - Each prompt includes system overview, key components, integration points, 3+ use cases, 12-15 slide structure, evidence anchors linking to repository files, design notes for visual consistency, and quality bars for validation. Enables presentation creation with NotebookLM, Figma, and other design tools. ([PR #549](https://github.com/lightspeedwp/.github/pull/549) — *docs: add comprehensive 25-slide-deck prompt suite*) - **Design Markdown Agent: P3 Shell Script Modernization** — Completed migration of PDF tooling dependency installation from Bash to JavaScript: @@ -962,7 +964,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Code review refinements: proper error wrapping, fast-path optimization, comprehensive mock-based testing, silent npm install flag validation - - Returns structured result object with success/installed/directory properties for programmatic integration ([#616](https://github.com/lightspeedwp/.github/issues/616), [PR #639](https://github.com/lightspeedwp/.github/pull/639) — _feat: Migrate install\_deps.sh to JavaScript (installDeps.js)_) + - Returns structured result object with success/installed/directory properties for programmatic integration ([#616](https://github.com/lightspeedwp/.github/issues/616), [PR #639](https://github.com/lightspeedwp/.github/pull/639) — *feat: Migrate install\_deps.sh to JavaScript (installDeps.js)*) - **Consolidated Branding Agent Module** — Unified `scripts/agents/branding.agent.js` consolidates header, footer, and badge logic from previously scattered modules: @@ -986,7 +988,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Audit reporting at `.github/reports/mermaid-audit/update-report.md` - - Integrated into Release Agent post-release phase ([PR #536](https://github.com/lightspeedwp/.github/pull/536) — _Wave 3C: README Update Workflow & Wave 4 Specification_) + - Integrated into Release Agent post-release phase ([PR #536](https://github.com/lightspeedwp/.github/pull/536) — *Wave 3C: README Update Workflow & Wave 4 Specification*) - **Wave 4 Specification** — Added `.github/projects/active/wave-4-continuous-monitoring.md` comprehensive specification for continuous README and Mermaid diagram monitoring: @@ -1000,7 +1002,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CI/CD integration patterns and metric collection - - Foundation for long-term automation roadmap (Waves 5-7) ([PR #536](https://github.com/lightspeedwp/.github/pull/536) — _Wave 3C: README Update Workflow & Wave 4 Specification_) + - Foundation for long-term automation roadmap (Waves 5-7) ([PR #536](https://github.com/lightspeedwp/.github/pull/536) — *Wave 3C: README Update Workflow & Wave 4 Specification*) - **Comprehensive Documentation Index** — Created `docs/README.md` with complete documentation hub and navigation guide: @@ -1020,7 +1022,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed prohibited `references` fields from README files per CLAUDE.md governance rules - - Closes Issue [#19](https://github.com/lightspeedwp/.github/issues/19) ([PR #552](https://github.com/lightspeedwp/.github/pull/552) — _Docs: Create comprehensive documentation index and quickstart_) + - Closes Issue [#19](https://github.com/lightspeedwp/.github/issues/19) ([PR #552](https://github.com/lightspeedwp/.github/pull/552) — *Docs: Create comprehensive documentation index and quickstart*) - **Wave 4C: Current-State Audit & Remediation Plan** — Completed comprehensive audit of 932 markdown files with detailed remediation strategy: @@ -1042,7 +1044,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Added guarded Dependabot security auto-merge automation for `develop` by introducing Mergify conditions tied to Dependabot author, dependency/security labels, conflict/draft guards, and successful required checks. Added and wired a Dependabot security labelling workflow and aligned labels to canonical naming (`meta:dependabot-security`) to satisfy label governance and enable controlled auto-merge behaviour. ([PR #563](https://github.com/lightspeedwp/.github/pull/563) — _feat(ci): add guarded Mergify auto-merge for Dependabot security PRs_) +- Added guarded Dependabot security auto-merge automation for `develop` by introducing Mergify conditions tied to Dependabot author, dependency/security labels, conflict/draft guards, and successful required checks. Added and wired a Dependabot security labelling workflow and aligned labels to canonical naming (`meta:dependabot-security`) to satisfy label governance and enable controlled auto-merge behaviour. ([PR #563](https://github.com/lightspeedwp/.github/pull/563) — *feat(ci): add guarded Mergify auto-merge for Dependabot security PRs*) - **Release Agent Integration** — Updated `agents/release.agent.md` (v2.2 → v2.3) with post-release `readme-update.yml` invocation: @@ -1050,7 +1052,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added to orchestration algorithm as non-blocking post-release action - - Conditional execution based on README maintenance requirements ([PR #536](https://github.com/lightspeedwp/.github/pull/536) — _Wave 3C: README Update Workflow & Wave 4 Specification_) + - Conditional execution based on README maintenance requirements ([PR #536](https://github.com/lightspeedwp/.github/pull/536) — *Wave 3C: README Update Workflow & Wave 4 Specification*) - **Workflow Coordination Documentation** — Updated `.github/docs/workflow-coordination.md` (v1.0.0 → v1.1.0): @@ -1058,7 +1060,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added comprehensive specification with inputs, outputs, and integration points - - Clarified Release Agent orchestration pattern for post-release actions ([PR #536](https://github.com/lightspeedwp/.github/pull/536) — _Wave 3C: README Update Workflow & Wave 4 Specification_) + - Clarified Release Agent orchestration pattern for post-release actions ([PR #536](https://github.com/lightspeedwp/.github/pull/536) — *Wave 3C: README Update Workflow & Wave 4 Specification*) - **Plugin Structure Migration** — Migrated `instructions/plugin-structure.instructions.md` from `.github/instructions/` to top-level `instructions/` folder: @@ -1066,7 +1068,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated references across `.github/README.md` and `CLAUDE.md` - - Clarified repository structure: GitHub-native files in `.github/`, portable assets in top-level folders ([PR #536](https://github.com/lightspeedwp/.github/pull/536) — _Wave 3C: README Update Workflow & Wave 4 Specification_) + - Clarified repository structure: GitHub-native files in `.github/`, portable assets in top-level folders ([PR #536](https://github.com/lightspeedwp/.github/pull/536) — *Wave 3C: README Update Workflow & Wave 4 Specification*) ### Fixed @@ -1108,7 +1110,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **v0.5.0 Readiness: CI gate verification and frontmatter blocker remediation** — Verified CI hard-blocker chain (`#642`, `#643`, `#644`) against live `develop` with passing `npm test`, `npm run validate:agents`, and `npm run validate:workflows`. Resolved release-gate regression in `validate:frontmatter` by fixing invalid frontmatter in 13 active prompt-migration artefacts under `.github/projects/active/refactor-migrate-prompts/`, restoring `npm run validate:frontmatter` to zero errors. -- **Mermaid Accessibility Compliance (Issue #669)** — Added missing `accTitle` and `accDescr` accessibility attributes to 7 non-compliant Mermaid diagrams across `.github/`, `.vscode/`, and root-level README files. All 24 diagrams now achieve 100% WCAG 2.2 AA compliance. Also improved validation scripts with cross-platform line ending support and enhanced diagram type detection ([PR #696](https://github.com/lightspeedwp/.github/pull/696) — _Wave 5: Mermaid Accessibility Compliance + README Frontmatter Updates_) +- **Mermaid Accessibility Compliance (Issue #669)** — Added missing `accTitle` and `accDescr` accessibility attributes to 7 non-compliant Mermaid diagrams across `.github/`, `.vscode/`, and root-level README files. All 24 diagrams now achieve 100% WCAG 2.2 AA compliance. Also improved validation scripts with cross-platform line ending support and enhanced diagram type detection ([PR #696](https://github.com/lightspeedwp/.github/pull/696) — *Wave 5: Mermaid Accessibility Compliance + README Frontmatter Updates*) - **Wave 5: Label Color Consistency Audit** — Comprehensive audit of all 160 canonical labels in `.github/labels.yml` against documented 8-family colour strategy with findings, recommendations, and migration roadmap: @@ -1132,7 +1134,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Wave 5 Audit #654: Template Inventory & Standardisation Findings** — Completed comprehensive audit of 26 issue templates in `.github/ISSUE_TEMPLATE/` documenting 100% frontmatter compliance, identifying 1 critical duplicate numbering issue (07-improvement + 07-user-experience-feedback), 2 type mapping gaps (Chore, User Experience Feedback, Help/Support), and providing recommendations for Phase 1 (numbering fixes), Phase 2 (type clarification), and Phase 3 (documentation updates). Created audit findings document at `.github/projects/active/wave-5-documentation-audit/findings/654-template-inventory-findings.md` with complete inventory metrics, critical/medium/low issue analysis, and phased remediation roadmap ([#654](https://github.com/lightspeedwp/.github/issues/654)) -- **WCEU 2026 Documentation: Merge Conflict Resolution and Frontmatter Corrections** — Resolved merge conflict marker in CHANGELOG.md, corrected `file_type: documentation` in ROLLOUT_PLAN_60_DAYS.md (was `rollout_plan`), added missing frontmatter fields (`stability: stable`, `domain: governance`), and fixed UK English spelling (`finalize`→`finalise`, `customize`→`customise`, `customizations`→`customisations`) ([PR #676](https://github.com/lightspeedwp/.github/pull/676) — _fix: WCEU 2026 validation scripts and governance rollout documentation_) +- **WCEU 2026 Documentation: Merge Conflict Resolution and Frontmatter Corrections** — Resolved merge conflict marker in CHANGELOG.md, corrected `file_type: documentation` in ROLLOUT_PLAN_60_DAYS.md (was `rollout_plan`), added missing frontmatter fields (`stability: stable`, `domain: governance`), and fixed UK English spelling (`finalize`→`finalise`, `customize`→`customise`, `customizations`→`customisations`) ([PR #676](https://github.com/lightspeedwp/.github/pull/676) — *fix: WCEU 2026 validation scripts and governance rollout documentation*) - **Planner & Reviewer Agents: Code Review Fixes** — Fixed six critical issues from CodeRabbit review: dryRun option precedence, CLI entry point execution, null-safe comment body checks, extended dependency file detection (package.json, composer.json), improved rollback migration detection (.down.sql), and prevented crashes from null comment bodies ([#603](https://github.com/lightspeedwp/.github/issues/603), [#604](https://github.com/lightspeedwp/.github/issues/604), [#605](https://github.com/lightspeedwp/.github/issues/605), [#606](https://github.com/lightspeedwp/.github/issues/606), [#607](https://github.com/lightspeedwp/.github/issues/607)) @@ -1285,13 +1287,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Plugin Structure Instructions Frontmatter** — Added missing `title` and `category` fields to `instructions/plugin-structure.instructions.md` to meet frontmatter schema requirements ([#535](https://github.com/lightspeedwp/.github/pull/547)) -- Remediated duplicate and multiple footer blocks across 664 Markdown files using schema-driven validation. Created footer configuration schema (`.schemas/footer-config.schema.json`), centralized footer library with 15 document categories (`config/footers.config.yaml`), and automated validation/remediation script (`.github/scripts/validate-footers.js`). Violations fixed: 51 duplicate footer files → 0, 613 multiple-footer files → 0. Published remediation guide (`.github/FOOTER_REMEDIATION_GUIDE.md`). Related to branding meta agent planning (#33, #46, #48, #49). ([PR #534](https://github.com/lightspeedwp/.github/pull/534) — _fix/plan: footer remediation + branding meta agent planning_) +- Remediated duplicate and multiple footer blocks across 664 Markdown files using schema-driven validation. Created footer configuration schema (`.schemas/footer-config.schema.json`), centralized footer library with 15 document categories (`config/footers.config.yaml`), and automated validation/remediation script (`.github/scripts/validate-footers.js`). Violations fixed: 51 duplicate footer files → 0, 613 multiple-footer files → 0. Published remediation guide (`.github/FOOTER_REMEDIATION_GUIDE.md`). Related to branding meta agent planning (#33, #46, #48, #49). ([PR #534](https://github.com/lightspeedwp/.github/pull/534) — *fix/plan: footer remediation + branding meta agent planning*) -- Removed prohibited `references:` frontmatter field from 9 README files (`README.md`, `.github/README.md`, `.github/agents/README.md`, `.github/instructions/README.md`, `.github/metrics/README.md`, `.github/schemas/README.md`, `.schemas/README.md`, `profile/README.md`, `scripts/README.md`) per CLAUDE.md governance rule. Added missing required frontmatter fields (`owners`, `status`, `stability`, `domain`) to affected files. Removed 13 duplicate footer blocks from root `README.md`. Added `.lycheeignore` excluding social-platform and LightSpeed external domains from CI link checking. Added `docs/MIGRATION.md` portable AI plugin restructure migration maps (completed and pending migrations, file placement quick-reference). Related to [#18](https://github.com/lightspeedwp/.github/issues/18). ([PR #527](https://github.com/lightspeedwp/.github/pull/527) — _fix(docs): remove prohibited references: frontmatter field from 9 READMEs_) +- Removed prohibited `references:` frontmatter field from 9 README files (`README.md`, `.github/README.md`, `.github/agents/README.md`, `.github/instructions/README.md`, `.github/metrics/README.md`, `.github/schemas/README.md`, `.schemas/README.md`, `profile/README.md`, `scripts/README.md`) per CLAUDE.md governance rule. Added missing required frontmatter fields (`owners`, `status`, `stability`, `domain`) to affected files. Removed 13 duplicate footer blocks from root `README.md`. Added `.lycheeignore` excluding social-platform and LightSpeed external domains from CI link checking. Added `docs/MIGRATION.md` portable AI plugin restructure migration maps (completed and pending migrations, file placement quick-reference). Related to [#18](https://github.com/lightspeedwp/.github/issues/18). ([PR #527](https://github.com/lightspeedwp/.github/pull/527) — *fix(docs): remove prohibited references: frontmatter field from 9 READMEs*) -- Replaced deprecated MCP tool references (`create_issue`, `update_issue`, `get_issue`) with current equivalents (`issue_write`, `issue_read`) across agent specs and prompt files. Closes [#52](https://github.com/lightspeedwp/.github/issues/52). ([PR #455](https://github.com/lightspeedwp/.github/pull/455) — _fix: replace deprecated MCP tool refs (create\_issue → issue\_write, get\_issue → issue\_read)_) +- Replaced deprecated MCP tool references (`create_issue`, `update_issue`, `get_issue`) with current equivalents (`issue_write`, `issue_read`) across agent specs and prompt files. Closes [#52](https://github.com/lightspeedwp/.github/issues/52). ([PR #455](https://github.com/lightspeedwp/.github/pull/455) — *fix: replace deprecated MCP tool refs (create\_issue → issue\_write, get\_issue → issue\_read)*) -- Expanded issue template DoD checklists with discrete accessibility (WCAG 2.2 AA), security (OWASP Top 10), and performance items, aligning issue templates with the PR template. Closes [#21](https://github.com/lightspeedwp/.github/issues/21). ([PR #460](https://github.com/lightspeedwp/.github/pull/460) — _fix: add explicit accessibility and security DoD checklist items to issue templates_) +- Expanded issue template DoD checklists with discrete accessibility (WCAG 2.2 AA), security (OWASP Top 10), and performance items, aligning issue templates with the PR template. Closes [#21](https://github.com/lightspeedwp/.github/issues/21). ([PR #460](https://github.com/lightspeedwp/.github/pull/460) — *fix: add explicit accessibility and security DoD checklist items to issue templates*) - Updated `.coderabbit.yml`: corrected schema URL to `docs.coderabbit.ai/.schemas/schema.v2.json`; added `language: en-GB`, `inheritance: true`, `chat.auto_reply: true`; added `reviews.profile: chill` and `reviews.review_details: true`; hardened workflow path instructions with security guidance (least-privilege permissions, secret injection prevention, action pinning); added `instructions/**` and `CHANGELOG.md` path instructions; expanded path filters. Closes [#23](https://github.com/lightspeedwp/.github/issues/23). @@ -1299,21 +1301,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Confirmed GitHub Copilot continuation in `.github/projects/active/next-issues-execution-plan.md` (v2.1.2) for the remaining Wave 2A issues (`#476`, `#480`, `#482`) and Wave 2C issues (`#488`, `#490`), explicitly requiring execution to continue until implementation, validation, and PR-ready merge state for `develop` are reached. -- Added comprehensive WCEU 2026 talk asset pack audit and NotebookLM hardening plan (Issue #529): `.github/wceu-2026/WCEU_2026_AUDIT_AND_READINESS_PLAN.md` (500+ lines). Audits folder structure, identifies critical gaps in NotebookLM prompts, provides hardening roadmap with explicit develop-branch URLs. Hardened `wceu-2026/notebooklm/deep-research-prompt.md` (450+ lines) with 14 canonical approved sources, source ingestion order, analysis objectives, and constraints. Created comprehensive `wceu-2026/notebooklm/source-ingestion-checklist.md` (350+ lines) with repo-only source policy, validation checklist, prohibited sources list, and enforcement rules. Ensures NotebookLM analysis is grounded in authoritative internal sources only. ([PR #543](https://github.com/lightspeedwp/.github/pull/543) — _feat(wceu-2026): Complete NotebookLM pipeline hardening and audit report_) +- Added comprehensive WCEU 2026 talk asset pack audit and NotebookLM hardening plan (Issue #529): `.github/wceu-2026/WCEU_2026_AUDIT_AND_READINESS_PLAN.md` (500+ lines). Audits folder structure, identifies critical gaps in NotebookLM prompts, provides hardening roadmap with explicit develop-branch URLs. Hardened `wceu-2026/notebooklm/deep-research-prompt.md` (450+ lines) with 14 canonical approved sources, source ingestion order, analysis objectives, and constraints. Created comprehensive `wceu-2026/notebooklm/source-ingestion-checklist.md` (350+ lines) with repo-only source policy, validation checklist, prohibited sources list, and enforcement rules. Ensures NotebookLM analysis is grounded in authoritative internal sources only. ([PR #543](https://github.com/lightspeedwp/.github/pull/543) — *feat(wceu-2026): Complete NotebookLM pipeline hardening and audit report*) - Clarified mandatory execution ownership in `.github/projects/active/next-issues-execution-plan.md` (v2.1.1), explicitly splitting task streams between **GitHub Copilot** and **Claude Code** with a dedicated ownership matrix, updated wave labels, and explicit no-cross-execution policy language for exclusive workstreams. - Updated `.github/projects/active/next-issues-execution-plan.md` to v2.1.0 and synchronised the Active Project Files Inventory with all current artefacts in `.github/projects/active/`, including Wave 3B/3C specs, Wave 4 branding specifications (`ISSUE_33`, `ISSUE_46`, `ISSUE_48`, `ISSUE_49`), planning summaries, plugin-pack wave task lists, and continuous monitoring planning assets. -- Added comprehensive current-state audit specification for unified branding agent (Issue #48): `.github/projects/active/ISSUE_48_CURRENT_STATE_AUDIT.md` (489 lines). Audits existing branding implementations against new .schemas/config standards, inventories frontmatter completeness (90.6% compliant, 70 files missing required fields), category mapping accuracy (98%+ correct), header/footer patterns (84.5% missing footers), badge usage (1.9% adoption), and WCAG AA accessibility (95%+ compliance). Documents gap analysis, remediation priorities with effort estimates (16–23 hours), remediation scripts needed, risk assessment, and success criteria. Provides baseline for planning agent rollout and documentation updates. ([PR #541](https://github.com/lightspeedwp/.github/pull/541) — _Issue #48: Current-state audit specification for branding agent_) +- Added comprehensive current-state audit specification for unified branding agent (Issue #48): `.github/projects/active/ISSUE_48_CURRENT_STATE_AUDIT.md` (489 lines). Audits existing branding implementations against new .schemas/config standards, inventories frontmatter completeness (90.6% compliant, 70 files missing required fields), category mapping accuracy (98%+ correct), header/footer patterns (84.5% missing footers), badge usage (1.9% adoption), and WCAG AA accessibility (95%+ compliance). Documents gap analysis, remediation priorities with effort estimates (16–23 hours), remediation scripts needed, risk assessment, and success criteria. Provides baseline for planning agent rollout and documentation updates. ([PR #541](https://github.com/lightspeedwp/.github/pull/541) — *Issue #48: Current-state audit specification for branding agent*) -- Added comprehensive schema and config implementation specification for unified branding agent (Issue #49): `.github/projects/active/ISSUE_49_SCHEMA_CONFIG_IMPLEMENTATION.md` (800+ lines). Specification defines YAML + JSON Schema approach for configuration, documents all 16 document categories with metadata, specifies 4 required and 7 optional frontmatter fields, details path-based category inference with priority rules, documents badge types and category-specific placement rules, defines header/footer template reference structure, specifies validation rules and safe failure behaviour, includes complete example configuration, and establishes dependency relationships with Issues #33 and #46. Unblocks current-state audit (Issue #48) and agent implementation. ([PR #539](https://github.com/lightspeedwp/.github/pull/539) — _docs: add comprehensive slide deck prompt suite (25 total)_) +- Added comprehensive schema and config implementation specification for unified branding agent (Issue #49): `.github/projects/active/ISSUE_49_SCHEMA_CONFIG_IMPLEMENTATION.md` (800+ lines). Specification defines YAML + JSON Schema approach for configuration, documents all 16 document categories with metadata, specifies 4 required and 7 optional frontmatter fields, details path-based category inference with priority rules, documents badge types and category-specific placement rules, defines header/footer template reference structure, specifies validation rules and safe failure behaviour, includes complete example configuration, and establishes dependency relationships with Issues #33 and #46. Unblocks current-state audit (Issue #48) and agent implementation. ([PR #539](https://github.com/lightspeedwp/.github/pull/539) — *docs: add comprehensive slide deck prompt suite (25 total)*) -- Added comprehensive template design specification for unified branding agent (Issue #46): `.github/projects/active/ISSUE_46_TEMPLATE_DESIGN.md` (950+ lines). Specification defines header templates for all 16 document categories, footer variants (5 each for 6 key categories: Docs, Agents, Instructions, Schemas, Prompts, Governance; 1 each for 10 other categories), badge templates (Status, Category, Version, Review Status), accessibility constraints (WCAG AA compliance, contrast ratios, alt text), readability guidelines (line length, nesting, bullet lists), and YAML configuration structure for `config/templates.config.yaml`. Unblocks .schemas/config implementation (Issue #49) and current-state audit (Issue #48). Depends on Issue #33 parent specification. ([PR #538](https://github.com/lightspeedwp/.github/pull/538) — _Issue #46: Template design specification for branding agent_) +- Added comprehensive template design specification for unified branding agent (Issue #46): `.github/projects/active/ISSUE_46_TEMPLATE_DESIGN.md` (950+ lines). Specification defines header templates for all 16 document categories, footer variants (5 each for 6 key categories: Docs, Agents, Instructions, Schemas, Prompts, Governance; 1 each for 10 other categories), badge templates (Status, Category, Version, Review Status), accessibility constraints (WCAG AA compliance, contrast ratios, alt text), readability guidelines (line length, nesting, bullet lists), and YAML configuration structure for `config/templates.config.yaml`. Unblocks .schemas/config implementation (Issue #49) and current-state audit (Issue #48). Depends on Issue #33 parent specification. ([PR #538](https://github.com/lightspeedwp/.github/pull/538) — *Issue #46: Template design specification for branding agent*) -- Added comprehensive parent specification for unified branding agent (Issue #33): `.github/projects/active/ISSUE_33_BRANDING_AGENT_PARENT_SPEC.md` (1,100+ lines). Specification locks down category taxonomy (16 document categories), header/footer requirements with 5 variants each for 6 key categories, badge system, .schemas/config model (YAML + JSON Schema), frontmatter standards, and 4-phase delivery roadmap. Unblocks child issues #46 (template design), #49 (schema implementation), and #48 (agent development). ([PR #537](https://github.com/lightspeedwp/.github/pull/537) — _spec: Issue #33 - Comprehensive parent specification for unified branding agent_) +- Added comprehensive parent specification for unified branding agent (Issue #33): `.github/projects/active/ISSUE_33_BRANDING_AGENT_PARENT_SPEC.md` (1,100+ lines). Specification locks down category taxonomy (16 document categories), header/footer requirements with 5 variants each for 6 key categories, badge system, .schemas/config model (YAML + JSON Schema), frontmatter standards, and 4-phase delivery roadmap. Unblocks child issues #46 (template design), #49 (schema implementation), and #48 (agent development). ([PR #537](https://github.com/lightspeedwp/.github/pull/537) — *spec: Issue #33 - Comprehensive parent specification for unified branding agent*) -- Added WCAG 2.2 AA accessibility attributes (`accTitle` and `accDescr`) to all Mermaid diagrams across `profile/README.md`, `scripts/README.md`, `.github/README.md`, and supporting files. Closes [#513](https://github.com/lightspeedwp/.github/issues/513). ([PR #526](https://github.com/lightspeedwp/.github/pull/526) — _docs(a11y): add accTitle/accDescr to all Mermaid diagrams — Wave 3B (#513)_) +- Added WCAG 2.2 AA accessibility attributes (`accTitle` and `accDescr`) to all Mermaid diagrams across `profile/README.md`, `scripts/README.md`, `.github/README.md`, and supporting files. Closes [#513](https://github.com/lightspeedwp/.github/issues/513). ([PR #526](https://github.com/lightspeedwp/.github/pull/526) — *docs(a11y): add accTitle/accDescr to all Mermaid diagrams — Wave 3B (#513)*) - **Comprehensive 25-Slide-Deck Prompt Suite** — Complete NotebookLM and design-tool integration documentation with 25 production-ready slide deck prompts: @@ -1327,11 +1329,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Structure**: Each prompt includes overview, capabilities, integration points, use cases, slide structure, evidence anchors, design notes, quality bar - - Enables complete NotebookLM knowledge base generation and design system documentation ([PR #539](https://github.com/lightspeedwp/.github/pull/539) — _docs: add comprehensive slide deck prompt suite (25 total)_) + - Enables complete NotebookLM knowledge base generation and design system documentation ([PR #539](https://github.com/lightspeedwp/.github/pull/539) — *docs: add comprehensive slide deck prompt suite (25 total)*) -- Added comprehensive branding meta agent planning documentation: `branding-meta-agent-planning-2026-05-28.md` (2,100 lines with 6-phase implementation roadmap), `PLANNING_SUMMARY_2026-05-28.md` (359 lines executive summary), and `SLIDES_GENERATION_PROMPT.md` (789 lines for WCEU 2026 20-slide generation). Updated `next-issues-execution-plan.md` with Wave 4 (branding meta agent, Claude-exclusive) and Wave 3D (WCEU 2026 talk planning). Hardened NotebookLM source prompts with explicit develop-branch URLs. Related to issues #33, #46, #48, #49, #529. ([PR #534](https://github.com/lightspeedwp/.github/pull/534) — _fix/plan: footer remediation + branding meta agent planning_) +- Added comprehensive branding meta agent planning documentation: `branding-meta-agent-planning-2026-05-28.md` (2,100 lines with 6-phase implementation roadmap), `PLANNING_SUMMARY_2026-05-28.md` (359 lines executive summary), and `SLIDES_GENERATION_PROMPT.md` (789 lines for WCEU 2026 20-slide generation). Updated `next-issues-execution-plan.md` with Wave 4 (branding meta agent, Claude-exclusive) and Wave 3D (WCEU 2026 talk planning). Hardened NotebookLM source prompts with explicit develop-branch URLs. Related to issues #33, #46, #48, #49, #529. ([PR #534](https://github.com/lightspeedwp/.github/pull/534) — *fix/plan: footer remediation + branding meta agent planning*) -- Added plugin-pack specialised skill rollout updates across active packs with per-platform manifest parity, expanded `SKILL_REGISTRY` scope coverage (`batch6PlatformYamlScope`), and refreshed rollout task tracking documentation. Closes [#524](https://github.com/lightspeedwp/.github/issues/524). ([PR #525](https://github.com/lightspeedwp/.github/pull/525) — _feat: roll out plugin-pack specialised skills and manifest parity_) +- Added plugin-pack specialised skill rollout updates across active packs with per-platform manifest parity, expanded `SKILL_REGISTRY` scope coverage (`batch6PlatformYamlScope`), and refreshed rollout task tracking documentation. Closes [#524](https://github.com/lightspeedwp/.github/issues/524). ([PR #525](https://github.com/lightspeedwp/.github/pull/525) — *feat: roll out plugin-pack specialised skills and manifest parity*) - Added `accTitle` and `accDescr` accessibility attributes to all 15 Mermaid diagrams across 8 README files (`.github/README.md`, `profile/README.md`, `scripts/README.md`, `scripts/validation/README.md`, `.github/ISSUE_TEMPLATE/README.md`, `.github/projects/README.md`, `.vscode/README.md`, `tests/README.md`), bringing WCAG 2.2 AA compliance to 100%. Added Wave 3A/3B audit report, findings CSV, and repair log to `.github/reports/mermaid-audit/`. Closes [#513](https://github.com/lightspeedwp/.github/issues/513). @@ -1339,7 +1341,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added universal issue-field governance for `Priority`, `Start date`, -- CONTRIBUTING.md: removed forbidden `references` frontmatter field, corrected stale body date, and applied UK English consistency. Closes [#18](https://github.com/lightspeedwp/.github/issues/18). ([PR #457](https://github.com/lightspeedwp/.github/pull/457) — _docs: CONTRIBUTING.md — remove references frontmatter, npm ci, date fix_) +- CONTRIBUTING.md: removed forbidden `references` frontmatter field, corrected stale body date, and applied UK English consistency. Closes [#18](https://github.com/lightspeedwp/.github/issues/18). ([PR #457](https://github.com/lightspeedwp/.github/pull/457) — *docs: CONTRIBUTING.md — remove references frontmatter, npm ci, date fix*) - Clarified frontmatter version governance to use SemVer-aligned change @@ -1349,7 +1351,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added spec-only agent issue conversion tracking under `#61`, including -- Added Husky pre-push hook (`.husky/pre-push`) and updated `DEVELOPMENT.md` to document the enforced pre-push test gate (`npm run test:js`, `npm run test:bash`). Closes [#62](https://github.com/lightspeedwp/.github/issues/62). ([PR #458](https://github.com/lightspeedwp/.github/pull/458) — _ci: add Husky pre-push test gate and docs (#62)_) +- Added Husky pre-push hook (`.husky/pre-push`) and updated `DEVELOPMENT.md` to document the enforced pre-push test gate (`npm run test:js`, `npm run test:bash`). Closes [#62](https://github.com/lightspeedwp/.github/issues/62). ([PR #458](https://github.com/lightspeedwp/.github/pull/458) — *ci: add Husky pre-push test gate and docs (#62)*) - Expanded issue field governance to an organisation-level v2 model aligned to @@ -1367,19 +1369,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Closed Wave 2A tracking issue `#469` (`metrics.agent`) by confirming -- Upgraded `agents/mode-demonstrate-understanding.agent.md` to v1.1: added complete frontmatter fields (`version`, `last_updated`, `owners`, `tags`, `file_type`, `status`, `domain`, `stability`, `permissions`), Implementation Status gap-analysis table, Dependencies section, and Changelog; confirmed no workflow needed (conversational mode agent). Closes [#470](https://github.com/lightspeedwp/.github/issues/470). ([PR #515](https://github.com/lightspeedwp/.github/pull/515) — _feat(agents): upgrade mode-demonstrate-understanding spec to v1.1_) +- Upgraded `agents/mode-demonstrate-understanding.agent.md` to v1.1: added complete frontmatter fields (`version`, `last_updated`, `owners`, `tags`, `file_type`, `status`, `domain`, `stability`, `permissions`), Implementation Status gap-analysis table, Dependencies section, and Changelog; confirmed no workflow needed (conversational mode agent). Closes [#470](https://github.com/lightspeedwp/.github/issues/470). ([PR #515](https://github.com/lightspeedwp/.github/pull/515) — *feat(agents): upgrade mode-demonstrate-understanding spec to v1.1*) -- Upgraded `agents/mode-document-reviewer.agent.md` to v1.1: added complete frontmatter fields (`version`, `last_updated`, `owners`, `tags`, `file_type`, `status`, `domain`, `stability`, `permissions`), Implementation Status gap-analysis table, Dependencies section, and Changelog; confirmed no workflow needed (conversational mode agent). Closes [#471](https://github.com/lightspeedwp/.github/issues/471). ([PR #516](https://github.com/lightspeedwp/.github/pull/516) — _feat(agents): upgrade mode-document-reviewer spec to v1.1_) +- Upgraded `agents/mode-document-reviewer.agent.md` to v1.1: added complete frontmatter fields (`version`, `last_updated`, `owners`, `tags`, `file_type`, `status`, `domain`, `stability`, `permissions`), Implementation Status gap-analysis table, Dependencies section, and Changelog; confirmed no workflow needed (conversational mode agent). Closes [#471](https://github.com/lightspeedwp/.github/issues/471). ([PR #516](https://github.com/lightspeedwp/.github/pull/516) — *feat(agents): upgrade mode-document-reviewer spec to v1.1*) -- Upgraded `agents/mode-prd.agent.md` to v1.1: added complete frontmatter fields (`version`, `last_updated`, `owners`, `tags`, `file_type`, `status`, `domain`, `stability`, `permissions`), Implementation Status gap-analysis table, Dependencies section, and Changelog; confirmed no workflow needed (conversational mode agent with inline `issue_write`). Closes [#473](https://github.com/lightspeedwp/.github/issues/473). ([PR #517](https://github.com/lightspeedwp/.github/pull/517) — _feat(agents): upgrade mode-prd spec to v1.1_) +- Upgraded `agents/mode-prd.agent.md` to v1.1: added complete frontmatter fields (`version`, `last_updated`, `owners`, `tags`, `file_type`, `status`, `domain`, `stability`, `permissions`), Implementation Status gap-analysis table, Dependencies section, and Changelog; confirmed no workflow needed (conversational mode agent with inline `issue_write`). Closes [#473](https://github.com/lightspeedwp/.github/issues/473). ([PR #517](https://github.com/lightspeedwp/.github/pull/517) — *feat(agents): upgrade mode-prd spec to v1.1*) -- Upgraded `agents/mode-thinking.agent.md` to v2.1: added missing frontmatter fields (`owners`, `tags`, `domain`, `stability`), Implementation Status gap-analysis table, Dependencies section, and Changelog; confirmed no workflow needed (conversational mode agent). Closes [#475](https://github.com/lightspeedwp/.github/issues/475). ([PR #518](https://github.com/lightspeedwp/.github/pull/518) — _feat(agents): upgrade mode-thinking spec to v2.1_) +- Upgraded `agents/mode-thinking.agent.md` to v2.1: added missing frontmatter fields (`owners`, `tags`, `domain`, `stability`), Implementation Status gap-analysis table, Dependencies section, and Changelog; confirmed no workflow needed (conversational mode agent). Closes [#475](https://github.com/lightspeedwp/.github/issues/475). ([PR #518](https://github.com/lightspeedwp/.github/pull/518) — *feat(agents): upgrade mode-thinking spec to v2.1*) -- Upgraded `agents/prompt-engineer.agent.md` to v2.1: added missing frontmatter fields (`domain`, `stability`), Implementation Status gap-analysis table (including prompt-tester handoff gap), Dependencies section, and Changelog. Closes [#478](https://github.com/lightspeedwp/.github/issues/478). ([PR #519](https://github.com/lightspeedwp/.github/pull/519) — _feat(agents): upgrade prompt-engineer spec to v2.1_) +- Upgraded `agents/prompt-engineer.agent.md` to v2.1: added missing frontmatter fields (`domain`, `stability`), Implementation Status gap-analysis table (including prompt-tester handoff gap), Dependencies section, and Changelog. Closes [#478](https://github.com/lightspeedwp/.github/issues/478). ([PR #519](https://github.com/lightspeedwp/.github/pull/519) — *feat(agents): upgrade prompt-engineer spec to v2.1*) -- Upgraded `agents/task-planner.agent.md` to v3.1: added Implementation Status gap-analysis table confirming spec/workflow parity with `planner.yml`, and Changelog. Closes [#484](https://github.com/lightspeedwp/.github/issues/484). ([PR #520](https://github.com/lightspeedwp/.github/pull/520) — _feat(agents): upgrade task-planner spec to v3.1_) +- Upgraded `agents/task-planner.agent.md` to v3.1: added Implementation Status gap-analysis table confirming spec/workflow parity with `planner.yml`, and Changelog. Closes [#484](https://github.com/lightspeedwp/.github/issues/484). ([PR #520](https://github.com/lightspeedwp/.github/pull/520) — *feat(agents): upgrade task-planner spec to v3.1*) -- Upgraded `agents/task-researcher.agent.md` to v1.1: added complete frontmatter, full spec body (was an empty stub in v1.0), Implementation Status gap-analysis table, Dependencies section, and Changelog. Closes [#486](https://github.com/lightspeedwp/.github/issues/486). ([PR #521](https://github.com/lightspeedwp/.github/pull/521) — _feat(agents): upgrade task-researcher spec to v1.1_) +- Upgraded `agents/task-researcher.agent.md` to v1.1: added complete frontmatter, full spec body (was an empty stub in v1.0), Implementation Status gap-analysis table, Dependencies section, and Changelog. Closes [#486](https://github.com/lightspeedwp/.github/issues/486). ([PR #521](https://github.com/lightspeedwp/.github/pull/521) — *feat(agents): upgrade task-researcher spec to v1.1*) ## [0.4.0] - 2026-05-27 @@ -1607,4 +1609,4 @@ Thank you to everyone who contributed to these improvements! --- -_Built by 🧱 LightSpeedWP with ☕, 🚀, and open-source spirit!_ +*Built by 🧱 LightSpeedWP with ☕, 🚀, and open-source spirit!* From cb0e27048d02ce1f599e72495da593b9232b1b6e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 01:54:10 +0000 Subject: [PATCH 03/22] feat(label-governance): count approvals only on tagged change-request issues The locked-files guard now accepts an approval only on an issue whose title carries [LABEL-UPDATE-REQUEST], [ISSUE-TYPE-UPDATE-REQUEST] or [TEMPLATE-UPDATE-REQUEST] (spec 008 clarification, 2026-10-02), so an approval given on an epic or another issue cannot unlock a locked file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011cim45iF9naViRzx5vUysK --- .github/workflows/locked-files-guard.yml | 22 +++++++++- .../check-locked-file-approval.test.js | 44 +++++++++++++++++++ .../validation/check-locked-file-approval.cjs | 28 ++++++++++-- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/.github/workflows/locked-files-guard.yml b/.github/workflows/locked-files-guard.yml index 60a5e8bd4a..15074dcb6c 100644 --- a/.github/workflows/locked-files-guard.yml +++ b/.github/workflows/locked-files-guard.yml @@ -2,7 +2,8 @@ # # Constitution Principle II: LOCKED files change only after an approved change # request. This check fails a pull request that changes a locked file unless -# its body links a change-request issue that is approved, either recorded as +# its body links a change-request issue (title tagged [LABEL-UPDATE-REQUEST], +# [ISSUE-TYPE-UPDATE-REQUEST] or [TEMPLATE-UPDATE-REQUEST]) that is approved, either recorded as # `approved` in the spec 008 change-request register or carrying an # "Approved…" comment by @ashleyshaw (spec 008, task T080). # @@ -78,10 +79,21 @@ jobs: } const commentsByIssue = {}; + const titlesByIssue = {}; const issues = guard.referencedIssues(pr.body || '').slice(0, 20); if (changedFiles.some(guard.isLocked)) { for (const issue of issues) { try { + const { data } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue, + }); + // Only change-request issues count; skip PRs and other issues. + if (data.pull_request || !guard.isChangeRequestTitle(data.title)) { + continue; + } + titlesByIssue[issue] = data.title; commentsByIssue[issue] = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, repo: context.repo.repo, @@ -94,7 +106,13 @@ jobs: } } - const result = guard.evaluate({ changedFiles, body: pr.body || '', register, commentsByIssue }); + const result = guard.evaluate({ + changedFiles, + body: pr.body || '', + register, + commentsByIssue, + titlesByIssue, + }); await core.summary.addHeading('Locked Files Guard', 3).addRaw(result.message).write(); if (result.ok) { core.info(result.message); diff --git a/scripts/validation/__tests__/check-locked-file-approval.test.js b/scripts/validation/__tests__/check-locked-file-approval.test.js index 321e8dd7b0..6a6a6b5fe8 100644 --- a/scripts/validation/__tests__/check-locked-file-approval.test.js +++ b/scripts/validation/__tests__/check-locked-file-approval.test.js @@ -7,6 +7,7 @@ const { approvedInRegister, evaluate, isApprovalComment, + isChangeRequestTitle, isLocked, referencedIssues, } = require('../check-locked-file-approval.cjs'); @@ -18,6 +19,13 @@ const register = { ], }; +const titlesByIssue = { + 3556: '[ISSUE-TYPE-UPDATE-REQUEST] Replace Question with Decision', + 3557: '[TEMPLATE-UPDATE-REQUEST] Decision issue template', + 4000: 'task: label-consolidation - Six PR templates [TEMPLATE-UPDATE-REQUEST]', + 449: 'epic: label-governance - Stabilise canonical labels', +}; + describe('check-locked-file-approval', () => { test.each([ '.github/labels.yml', @@ -75,6 +83,7 @@ describe('check-locked-file-approval', () => { changedFiles: ['.github/labels.yml', 'docs/LABEL_STRATEGY.md'], body: 'Relates to #3557', register, + titlesByIssue, }); expect(result.ok).toBe(false); expect(result.locked).toEqual(['.github/labels.yml']); @@ -86,6 +95,7 @@ describe('check-locked-file-approval', () => { changedFiles: ['.github/issue-types.yml'], body: 'Closes #3556', register, + titlesByIssue, }); expect(result.ok).toBe(true); expect(result.approvedBy).toEqual({ 3556: 'change-request register' }); @@ -96,6 +106,7 @@ describe('check-locked-file-approval', () => { changedFiles: ['.github/PULL_REQUEST_TEMPLATE/pr_docs.md'], body: 'Closes #4000', register, + titlesByIssue, commentsByIssue: { 4000: [{ user: { login: APPROVER }, body: 'Approved, ship it' }] }, }); expect(result.ok).toBe(true); @@ -107,8 +118,41 @@ describe('check-locked-file-approval', () => { changedFiles: ['.github/branch-labels.yml'], body: 'Closes #4000', register, + titlesByIssue, commentsByIssue: { 4000: [{ user: { login: 'teammate' }, body: 'Approved' }] }, }); expect(result.ok).toBe(false); }); + + test.each([ + ['[LABEL-UPDATE-REQUEST] Import five labels', true], + ['[ISSUE-TYPE-UPDATE-REQUEST] Decision type', true], + ['task: six PR templates [TEMPLATE-UPDATE-REQUEST]', true], + ['epic: label-governance - Stabilise canonical labels', false], + ['LABEL-UPDATE-REQUEST without brackets', false], + [undefined, false], + ])('isChangeRequestTitle(%p) is %p', (title, expected) => { + expect(isChangeRequestTitle(title)).toBe(expected); + }); + + test('does not accept the approver’s comment on an issue that is not a change request', () => { + const result = evaluate({ + changedFiles: ['.github/labels.yml'], + body: 'Part of #449', + register, + titlesByIssue, + commentsByIssue: { 449: [{ user: { login: APPROVER }, body: 'Approved' }] }, + }); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/without one of those tags/); + }); + + test('does not accept a register approval when the issue title is unknown', () => { + const result = evaluate({ + changedFiles: ['.github/issue-types.yml'], + body: 'Closes #3556', + register, + }); + expect(result.ok).toBe(false); + }); }); diff --git a/scripts/validation/check-locked-file-approval.cjs b/scripts/validation/check-locked-file-approval.cjs index 9e639164c7..a7ae0b1e4f 100644 --- a/scripts/validation/check-locked-file-approval.cjs +++ b/scripts/validation/check-locked-file-approval.cjs @@ -6,7 +6,9 @@ * A pull request that changes a LOCKED file may merge only after its change * request is approved. The PR body must link the change-request issue, and * that issue must either be recorded as `approved` in the spec 008 - * change-request register or carry an approval comment by the approver. + * change-request register or carry an approval comment by the approver. Only + * issues whose title carries a change-request tag count, so an approval given + * on any other issue (an epic, for example) cannot unlock a locked file. * * The workflow `.github/workflows/locked-files-guard.yml` runs this module * from the base branch, and reads the register from the base branch too, so @@ -31,6 +33,9 @@ const LOCKED_PATTERNS = [ /** The GitHub login whose approval counts (constitution: Approval Authority). */ const APPROVER = 'ashleyshaw'; +/** Title tags that mark a change-request issue (constitution Principle II). */ +const CHANGE_REQUEST_TAG = /\[(?:LABEL|ISSUE-TYPE|TEMPLATE)-UPDATE-REQUEST\]/; + /** Path of the spec 008 change-request register. */ const REGISTER_PATH = '.github/reports/audits/2026-09-14-label-audit/evidence/change-requests.json'; @@ -65,6 +70,15 @@ function referencedIssues(body, repo = 'lightspeedwp/.github') { return [...new Set(numbers)]; } +/** + * True when an issue title marks a change request. + * @param {string} title Issue title. + * @returns {boolean} + */ +function isChangeRequestTitle(title) { + return CHANGE_REQUEST_TAG.test(String(title || '')); +} + /** * Issue numbers recorded as approved in the change-request register. * @param {object|null} register Parsed register (`{ requests: [...] }`). @@ -101,9 +115,11 @@ function isApprovalComment(comment) { * @param {string} input.body Pull request body. * @param {object|null} input.register Change-request register from the base branch. * @param {Record>} [input.commentsByIssue] Comments on each referenced issue. + * @param {Record} [input.titlesByIssue] Title of each referenced issue; an issue + * with no known title is not treated as a change request. * @returns {{locked: string[], issues: number[], approvedBy: Record, ok: boolean, message: string}} */ -function evaluate({ changedFiles, body, register, commentsByIssue = {} }) { +function evaluate({ changedFiles, body, register, commentsByIssue = {}, titlesByIssue = {} }) { const locked = changedFiles.filter(isLocked); const issues = referencedIssues(body); if (locked.length === 0) { @@ -113,6 +129,9 @@ function evaluate({ changedFiles, body, register, commentsByIssue = {} }) { const approved = approvedInRegister(register); const approvedBy = {}; for (const issue of issues) { + if (!isChangeRequestTitle(titlesByIssue[issue])) { + continue; + } if (approved.has(issue)) { approvedBy[issue] = 'change-request register'; } else if ((commentsByIssue[issue] || []).some(isApprovalComment)) { @@ -128,17 +147,20 @@ function evaluate({ changedFiles, body, register, commentsByIssue = {} }) { .join(', ')}.` : `Locked files changed (${list}), but no linked change request is approved. ` + `Link the [LABEL-UPDATE-REQUEST], [ISSUE-TYPE-UPDATE-REQUEST] or [TEMPLATE-UPDATE-REQUEST] issue ` + - `in the PR body, and get it approved by @${APPROVER} (constitution Principle II).`; + `in the PR body, and get it approved by @${APPROVER} (constitution Principle II). ` + + `Approvals on issues without one of those tags in the title do not count.`; return { locked, issues, approvedBy, ok, message }; } module.exports = { APPROVER, + CHANGE_REQUEST_TAG, LOCKED_PATTERNS, REGISTER_PATH, approvedInRegister, evaluate, isApprovalComment, + isChangeRequestTitle, isLocked, referencedIssues, }; From f3e3e87b0eb3e20c2022a3fc77fbaa0094d68334 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 02:00:43 +0000 Subject: [PATCH 04/22] docs(changelog): reword the inventory token entry for the changelog validator (#3734) The validator read the variable name as an unexplained abbreviation, a new failure on this PR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011cim45iF9naViRzx5vUysK --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5446c2c8..e2770e8ae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Label inventory token** — The organisation label inventory now reads `LABEL_INVENTORY_TOKEN`, refuses the Actions token and fails when private repositories are missing. (#3734) +- **Label inventory token** — The organisation label inventory now needs its own dedicated token, refuses the GitHub Actions token and fails when private repositories are missing. (#3734) - **Local Scans No Longer Dirty the Repository** — Semgrep's settings and log files are ignored, so a security scan leaves no untracked files to commit by accident. (#3713) - **Jest Config Lint Error Fixed** — A redundant escape in the transform ignore pattern is gone and the file is formatted, clearing the only lint error it carried. (#3713) - **Task-Issue Creation Gated** — Bulk issue creation is now opt-in per spec with stale-path checks, and the skill file is free of bot footer spam. ([#3540](https://github.com/lightspeedwp/.github/issues/3540)) From 35884df73c9555a40eeba98f8a231ac1e04c9134 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:53:08 +0000 Subject: [PATCH 05/22] fix(label-governance): reject unverifiable private repository inventories and restrict approval controls to @ashleyshaw --- CODEOWNERS | 6 ++++ .../__tests__/label-inventory.test.js | 29 +++++++++++++++++-- scripts/automation/label-inventory.js | 4 ++- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 2cac18f46d..4a2e8dc762 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -48,3 +48,9 @@ # Schemas (JSON validation) /.schemas/ @ashleyshaw @lightspeedwp/lightspeed + +# Locked-file approval controls: only @ashleyshaw approves. +/.github/workflows/locked-files-guard.yml @ashleyshaw +/scripts/validation/check-locked-file-approval.cjs @ashleyshaw +/.github/reports/audits/2026-09-14-label-audit/evidence/change-requests.json @ashleyshaw +/CODEOWNERS @ashleyshaw diff --git a/scripts/automation/__tests__/label-inventory.test.js b/scripts/automation/__tests__/label-inventory.test.js index 54cac1bb21..19581ad02f 100644 --- a/scripts/automation/__tests__/label-inventory.test.js +++ b/scripts/automation/__tests__/label-inventory.test.js @@ -115,10 +115,35 @@ describe('label-inventory', () => { ); }); - it('skips the private check when the organisation count is not readable', async () => { + it('fails when the organisation count is not readable', async () => { const inv = await buildInventory(pagedClient([{ name: 'a' }], { a: 1 }), 'lightspeedwp'); expect(inv.reported_private_repository_count).toBeNull(); - expect(privateRepositoryGap(inv)).toBeNull(); + expect(privateRepositoryGap(inv)).toMatch( + /total_private_repos.*organisation-owner token is required/ + ); + }); + + it.each([null, undefined])( + 'requires organisation-owner permission when the reported count is %s', + (reported) => { + expect( + privateRepositoryGap({ + organisation: 'lightspeedwp', + private_repository_count: 0, + reported_private_repository_count: reported, + }) + ).toMatch(/total_private_repos.*organisation-owner token is required/); + } + ); + + it('accepts an organisation that reports zero private repositories', () => { + expect( + privateRepositoryGap({ + organisation: 'lightspeedwp', + private_repository_count: 0, + reported_private_repository_count: 0, + }) + ).toBeNull(); }); }); }); diff --git a/scripts/automation/label-inventory.js b/scripts/automation/label-inventory.js index 6d61e2e82b..38a1365d86 100644 --- a/scripts/automation/label-inventory.js +++ b/scripts/automation/label-inventory.js @@ -142,7 +142,9 @@ export function incompleteRepositories(inventory) { */ export function privateRepositoryGap(inventory) { const reported = inventory.reported_private_repository_count; - if (reported === null || reported === undefined) return null; + if (reported === null || reported === undefined) { + return `cannot read total_private_repos for ${inventory.organisation}; an organisation-owner token is required to verify private repository completeness`; + } if (inventory.private_repository_count < reported) { return `listed ${inventory.private_repository_count} private repositories but ${inventory.organisation} reports ${reported}; the token cannot see them all`; } From e2616502bef2e6af54b4a3377501e19737e5f654 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 2 Oct 2026 20:30:45 +0200 Subject: [PATCH 06/22] fix(guard): fail closed on truncated file lists and untruncated issue references --- .github/workflows/locked-files-guard.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/locked-files-guard.yml b/.github/workflows/locked-files-guard.yml index 15074dcb6c..68a25de667 100644 --- a/.github/workflows/locked-files-guard.yml +++ b/.github/workflows/locked-files-guard.yml @@ -67,6 +67,16 @@ jobs: pull_number: pr.number, per_page: 100, }); + // The files API caps results at 3,000 entries: a locked file + // beyond the cap would otherwise pass silently, so refuse an + // incomplete file list instead of evaluating it. + if (typeof pr.changed_files === 'number' && pr.changed_files > files.length) { + core.setFailed( + `Locked Files Guard cannot verify: the pull request touches ${pr.changed_files} files ` + + `but the API returned ${files.length}; refusing to pass an incomplete file list.` + ); + return; + } // A rename moves a locked file too, so check both paths. const changedFiles = files.flatMap((file) => file.previous_filename ? [file.filename, file.previous_filename] : [file.filename] @@ -80,7 +90,11 @@ jobs: const commentsByIssue = {}; const titlesByIssue = {}; - const issues = guard.referencedIssues(pr.body || '').slice(0, 20); + // Fetch every referenced issue: guard.evaluate processes all of + // them, and truncating here would fail a valid approval linked + // after other references. Fetching happens only when locked files + // changed; the job timeout bounds pathological bodies, failing closed. + const issues = guard.referencedIssues(pr.body || ''); if (changedFiles.some(guard.isLocked)) { for (const issue of issues) { try { From 5a81f8e0934afdc533954ebf3e8d502d203cb862 Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Sun, 4 Oct 2026 18:07:44 +0200 Subject: [PATCH 07/22] Keep private repositories out of the public label inventory, drift issue, artifact and logs --- .../dry-run-and-drift-report-schema.md | 2 +- .../008-label-audit-consolidation/plan.md | 2 +- .../008-label-audit-consolidation/spec.md | 4 + .../008-label-audit-consolidation/tasks.md | 4 +- .github/workflows/label-drift-check.yml | 5 + .gitignore | 3 + CHANGELOG.md | 1 + .../__tests__/label-drift-check.test.js | 133 ++++++++++ .../__tests__/label-inventory.test.js | 104 ++++++++ scripts/automation/label-drift-check.js | 241 ++++++++++++++++-- scripts/automation/label-inventory.js | 101 +++++++- 11 files changed, 573 insertions(+), 27 deletions(-) diff --git a/.github/specs/008-label-audit-consolidation/contracts/dry-run-and-drift-report-schema.md b/.github/specs/008-label-audit-consolidation/contracts/dry-run-and-drift-report-schema.md index c766d5ff56..d4fb9d906a 100644 --- a/.github/specs/008-label-audit-consolidation/contracts/dry-run-and-drift-report-schema.md +++ b/.github/specs/008-label-audit-consolidation/contracts/dry-run-and-drift-report-schema.md @@ -4,7 +4,7 @@ Defines the per-repository deletion dry run that @ashleyshaw approves before any ## Per-repository dry run -Saved as `evidence/dry-run/{repo}.json` and summarised in a comment on the gate issue that replaces #95. +Saved as `evidence/dry-run/{repo}.json` and summarised in a comment on the gate issue that replaces #95. For a private repository the file stays under `.private-evidence/dry-run/` and is neither committed nor summarised in the public gate issue (decision of 2026-10-04). ```json { diff --git a/.github/specs/008-label-audit-consolidation/plan.md b/.github/specs/008-label-audit-consolidation/plan.md index 0c84931985..290a7cc269 100644 --- a/.github/specs/008-label-audit-consolidation/plan.md +++ b/.github/specs/008-label-audit-consolidation/plan.md @@ -256,7 +256,7 @@ Each stage starts only when the previous stage's exit check passes. Validation s | 0a. Issue types and colours (configuration done in #3534; T040c to do) | PR updating `issue-types.yml` (25 entries with descriptions, Decision replaces Question) and `type:*` label colours in `labels.yml` from `contracts/issue-types-org-settings.md`, together with the Decision template and `issue-fields.yml`; then a manual update of the organisation's issue types page in the order given in the contract | for #3534, which merged on 2026-09-25 before approval, T040n's dated sign-off on #3556 and #3557 (or a revert of any rejected part); for every later locked-file PR, approval before merge (FR-009); configuration validation and `npm test` pass; settings page matches the contract (Test 10b) | FR-009, FR-014, FR-019, FR-020 | | 0b. Labelling agent guard (done in #3564) | Separate fix PR (`fix/` branch): the agent stops removing labels for being outside `labels.yml`, honours `DRY_RUN`, and applies only `type:*` labels that exist in `labels.yml` | Test 16 passes; the agent's own tests pass | FR-017, FR-022 | | 0c. Label names on the audit branch (done in #3362) | On `audit/label-consolidation`: replace `ai-ops`/`type:ai-ops` label names with `aiops`/`type:aiops` and `openspec:` with `spec:` in the spec 008 artefacts, the audit's analysis reports and recommendation files, `docs/LABEL_INVENTORY.md` and `docs/LABELING_FAQ.md`; add `target_name` to the four snapshot files (`canonical-labels.json`, `label-families.json`, `label-inventory.json`, `label-inventory.csv`) and a note in `evidence/README.md`; leave `renamed-label-references.json` unchanged. No locked file, script, workflow or test changes | Test 17 passes; the evidence test still passes | FR-011 | -| 0. Evidence | Paginated label inventory for every repository (after the T075 token guard); `evidence/linear-labels.json` with issue counts and proposed mappings (`contracts/label-mapping-schema.md`) | Mapping validation rules pass (Test 9); the inventory lists private repositories | FR-006, FR-010, FR-012, FR-018 | +| 0. Evidence | Paginated label inventory for every repository (after the T075 token guard); `evidence/linear-labels.json` with issue counts and proposed mappings (`contracts/label-mapping-schema.md`) | Mapping validation rules pass (Test 9); the inventory counts every private repository (names withheld, 2026-10-04) | FR-006, FR-010, FR-012, FR-018 | | 1. Approve | Raise `[LABEL-UPDATE-REQUEST]` (mapping table, referencing #3554 for its five imports), `[ISSUE-TYPE-UPDATE-REQUEST]` and `[TEMPLATE-UPDATE-REQUEST]` (Question → Decision), a `[LABEL-UPDATE-REQUEST]` for the 16 FR-011 prefix renames and the `spec:NNN` → `spec-id:NNN` move, a `[TEMPLATE-UPDATE-REQUEST]` for the six PR templates (`type:ai-ops` → `type:aiops`, `type:documentation` → `type:docs`, `meta:needs-review` removed) that also covers the routing files `branch-labels.yml` (T074) and `PULL_REQUEST_TEMPLATE/config.yml` (T076), locked since constitution v1.4.0, a migration issue for OpenSpec paths, and a new gate issue replacing #95; each waiting request carries `meta:needs-approval` once the label exists | @ashleyshaw approves all requests | FR-009, FR-011, FR-013, FR-014, FR-016, FR-021 | | 2. Configuration PR | One PR: `labels.yml` (renames, imports including the five #3554 labels with the FR-012 colour rule, the `area:*` → `aiops:*` AI merges), `label-governance-policy.yml` (new gate issue, `enabled: false`), `labeler.yml`, `branch-labels.yml` (one canonical `type:*` label per branch type, matching its routed template: T074), `PULL_REQUEST_TEMPLATE/config.yml` (mirrors `branch-types.yml`: T076), scripts, workflows (including `orchestrate-phase-progression.yml`), validators (`validate-labeling-configs.cjs` prefix list) and tests referencing `ai-ops:` or `openspec:` labels, `pr_aiops.md`, the `spec:NNN` → `spec-id:NNN` move (`prd-combined-agent` plan, `bulk-label-issues.sh` and its test), the six docs files, and the FR-021 policy in `docs/LABEL_STRATEGY.md`. The `type:question` removal, `issue-types.yml`, `06-decision.md` and `issue-fields.yml` belong to Stage 0a; the never-delete list clean-up shipped in #3362 (T055 stays open only for `gated_by_issue`, which waits for the new gate issue) | Merges only after #3564 (Stage 0b), and its label renames are applied in GitHub (Stage 3) in the same session, so no item loses an `ai-ops:*` or `openspec:*` label. CI green; Test 10 and Test 15 pass | FR-011, FR-012, FR-014, FR-021 | | 2b. Spec Kit rename PR | Separate PR for the OpenSpec → Spec Kit rename in live files and paths, with links updated | Test 11 passes | FR-013 | diff --git a/.github/specs/008-label-audit-consolidation/spec.md b/.github/specs/008-label-audit-consolidation/spec.md index faf72d9b9a..6aa6b96f47 100644 --- a/.github/specs/008-label-audit-consolidation/spec.md +++ b/.github/specs/008-label-audit-consolidation/spec.md @@ -324,5 +324,9 @@ The audit itself makes no changes to the production label configuration. The app - Q: FR-018 requires the drift check to authenticate with an App holding only Issues (read/write) and Metadata (read). May the existing `BOT_PR_APP_*` App be reused unchanged? → A: Yes, by decision of Chris on 2026-10-02 (recorded in `evidence/change-requests.json` as `APP-CREDENTIAL-VARIANCE`). The App is shared by `documentation.yml`, `keep-pr-current.yml` and `branch-validation-metrics-aggregator.yml`, so narrowing it would break those; no new App and no new secret values were created. **This is a documented variance from FR-018 and T071a: the App's installed permission set is broader than "only Issues (read/write) and Metadata (read)", and the limit is applied per token instead.** **Amended 2026-10-02 after a failed live dry run:** `BOT_PR_APP_*` has no Issues permission and cannot serve the drift check; the check now uses the installed `lightspeed-project-automation` App (permissions verified and recorded) with downscoped tokens. `LINEAR_API_KEY` was confirmed by Chris and stays repository-level. - Q: What does the `label-drift-check` GitHub environment actually protect, given it holds no secrets? → A: Branch gating only. The environment exists (created 2026-10-02, admin bypass off, deployment branch policy `develop` only) and the drift job binds to it, so a `workflow_dispatch` from another branch cannot deploy that job. It provides **no secret isolation**: the environment stores no secrets, so every credential remains reachable at repository level by any workflow that runs in the repository. Secret isolation would require moving the three secrets into the environment, which has not been done. The job is therefore gated by branch, not by secret scope. +### Session 2026-10-04 + +- Q: This repository is public and the organisation has private repositories, some of which may identify clients. Should the label inventory and the drift report name them? → A: No. By decision of Chris on 2026-10-04, the clean-up and the drift check still cover private repositories, but nothing committed to or published in `lightspeedwp/.github` (evidence files, the drift issue, its artifact, Actions logs) names a private repository or lists its labels. The tracked inventory keeps each private repository as a numbered placeholder with its counts (`label_count`, `pages_read`), the drift issue counts them, and the full detail is kept outside this repository: a local `.private-evidence/` directory for the inventory and the per-repository dry runs, and a private report repository for the drift detail (`PRIVATE_REPORT_REPO`). This supersedes the earlier assumption that the inventory and per-repository dry runs are committed in full. Recorded in FR-006, T041, T065a and the dry-run contract. + *This page brought to you by the 🦄 Magic Automation Unicorns of LightSpeedWP.* [Automation Docs](https://github.com/lightspeedwp/.github/tree/main/instructions) diff --git a/.github/specs/008-label-audit-consolidation/tasks.md b/.github/specs/008-label-audit-consolidation/tasks.md index e447d9d208..37f75e2dfd 100644 --- a/.github/specs/008-label-audit-consolidation/tasks.md +++ b/.github/specs/008-label-audit-consolidation/tasks.md @@ -405,7 +405,7 @@ The active labelling agent removes labels outside `labels.yml` today, so this ru ### Stage 0: Evidence (FR-006, FR-010, FR-012) -- [ ] T041 [US4] (FR-006, FR-010, FR-018, SC-001; after T075) Run `scripts/automation/label-inventory.js` (already written, commit 7d799e60) from @ashleyshaw's session with a fine-grained personal access token (all `lightspeedwp` repositories, Issues and Metadata read-only, expiring in 7 days or less, revoked after the run) in `LABEL_INVENTORY_TOKEN` (the variable the script reads once T075 has merged in #3734; T041 does not run before that, because today the script reads `GITHUB_TOKEN`), never `GITHUB_TOKEN` (spec clarification 2026-10-02). The script lists every `lightspeedwp` repository and pages through each repository's labels with `per_page=100` until no `next` link remains; save the result to `.github/reports/audits/2026-09-14-label-audit/evidence/github-api-labels.json` (replacing the empty inventory) with, per repository, `label_count` and `pages_read` where "`pages_read × 100 ≥ label_count`" +- [ ] T041 [US4] (FR-006, FR-010, FR-018, SC-001; after T075) Run `scripts/automation/label-inventory.js` (already written, commit 7d799e60) from @ashleyshaw's session with a fine-grained personal access token (all `lightspeedwp` repositories, Issues and Metadata read-only, expiring in 7 days or less, revoked after the run) in `LABEL_INVENTORY_TOKEN` (the variable the script reads once T075 has merged in #3734; T041 does not run before that, because today the script reads `GITHUB_TOKEN`), never `GITHUB_TOKEN` (spec clarification 2026-10-02). The script lists every `lightspeedwp` repository and pages through each repository's labels with `per_page=100` until no `next` link remains; save the result to `.github/reports/audits/2026-09-14-label-audit/evidence/github-api-labels.json` (replacing the empty inventory) with, per repository, `label_count` and `pages_read` where "`pages_read × 100 ≥ label_count`" **Private repositories (decision of 2026-10-04): run it with `--full-output .private-evidence/label-inventory-full.json`, so the tracked file gets the redacted inventory (each private repository a numbered placeholder with its counts, no name and no labels) and the full inventory stays local; the script refuses a full-output path under `.github/`. The completeness checks run on the full inventory first.** - [ ] T041a [P] [US4] List the `lightspeedwp` organisation's native issue types and count the issues using each (especially Maintenance, Story and Integration) across all repositories; save to `.github/reports/audits/2026-09-14-label-audit/evidence/native-issue-types.json` (FR-019) - [x] T042 [P] [US4] Export all Linear workspace and team labels with per-label issue counts (exact label ID filter, archived issues included) into the `sources` block of `.github/reports/audits/2026-09-14-label-audit/evidence/linear-labels.json`, following `contracts/label-mapping-schema.md` - [ ] T043 [US4] Fill `mappings[]` in `.github/reports/audits/2026-09-14-label-audit/evidence/linear-labels.json` from spec FR-011 (renames), FR-012 (imports, merges, retirements, team-scope), FR-014 (swap) and FR-015 (re-prefix), with each entry's `action` one of "`rename`, `import`, `merge`, `re-prefix`, `retire`, `team-scope`, `swap`"; include the five #3554 imports (`area:builds`, `area:monitoring`, `area:observability`, `area:workflows`, `meta:needs-approval`) with the colours and descriptions set in FR-012, and no `area:observability` → `area:monitoring` merge; fill `color`, `description` and `change_request` for every import as `contracts/label-mapping-schema.md` requires (a `meta:*` import uses `57606A`); map `area:agents`, `area:instructions` and `area:prompts` to their `aiops:*` labels and decide `area:skills`; check validation rules 1 to 7 of the schema, including "No mapping leaves an issue with zero or two `type:*` labels" (depends on T041, T042) @@ -462,7 +462,7 @@ The active labelling agent removes labels outside `labels.yml` today, so this ru - [ ] T064c [US4] Gate: before any deletion task (T065 onwards) or Linear clean-up (T069 onwards) starts, @ashleyshaw reviews every item in `checklists/destructive-changes.md` (CHK001–CHK038) and ticks it; stop if any item is unchecked or answered "no". Also confirm T063 has finished for every repository (FR-014, FR-016) - [ ] T065 [US4] Extend `scripts/automation/label-consolidate.js` with a deletion dry run that fills `to_delete` in `.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json` with a full snapshot per label (name, colour, description, and the kind and number of each open and closed item) and a `migrate_to` for every label on open items, per `contracts/dry-run-and-drift-report-schema.md`; it leaves the Stage 3 `executed_at` as the record of Stage 3 and requires the Stage 4 `executed_at` to be null (depends on T064, T064c) -- [ ] T065a [US4] In `scripts/automation/label-consolidate.js`, re-read each repository just before deleting: if its labels, or the items carrying any `to_delete` label, differ from `.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json` once this run's own completed `done` records are applied to it (so a resumed run does not treat its own earlier deletions as drift, and only differences beyond them are stale), or `labels.yml` on `develop` differs from `approved_set_commit`, mark the dry run stale, skip the repository, post the reason on the gate issue, and require a new dry run and approval (FR-023 points 3 and 4; dry-run rules 6 and 7 in `contracts/dry-run-and-drift-report-schema.md`). Add the stale-dry-run case and the resumed-run case (own `done` records applied, no false stale) to `scripts/automation/__tests__/label-consolidate.test.js` (depends on T065, T062c) +- [ ] T065a [US4] In `scripts/automation/label-consolidate.js`, re-read each repository just before deleting: if its labels, or the items carrying any `to_delete` label, differ from `.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json` once this run's own completed `done` records are applied to it (so a resumed run does not treat its own earlier deletions as drift, and only differences beyond them are stale), or `labels.yml` on `develop` differs from `approved_set_commit`, mark the dry run stale, skip the repository, post the reason on the gate issue, and require a new dry run and approval (FR-023 points 3 and 4; dry-run rules 6 and 7 in `contracts/dry-run-and-drift-report-schema.md`). Add the stale-dry-run case and the resumed-run case (own `done` records applied, no false stale) to `scripts/automation/__tests__/label-consolidate.test.js` (depends on T065, T062c) **Private repositories (decision of 2026-10-04): the dry-run file and its gate-issue comment for a private repository are not committed or posted in this public repository; keep it under `.private-evidence/dry-run/` and report it only in a private repository.** - [ ] T066 [US4] Post each repository's dry-run summary on the gate issue and record @ashleyshaw's per-repository approval (the comment author must be the `ashleyshaw` login, FR-016) in the `approval` block of `.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json`; repositories without approval are marked `skipped` (depends on T065) - [ ] T067 [US4] From @ashleyshaw's session, run `scripts/automation/label-consolidate.js --apply --confirm-gate `, which migrates open issues, PRs and Discussions, migrates closed items for labels that have a mapping target (FR-016), and deletes the listed labels only in repositories whose `evidence/dry-run/{repo}.json` approval is `approved` and refuses otherwise; it sets the Stage 4 `executed_at` as each repository finishes; `destructive_cleanup.enabled` in `.github/label-governance-policy.yml` stays `false`; then run quickstart Test 12 (depends on T066, T065a) - [ ] T067a [US4] Verify SC-011 and SC-012 after T067: every issue and PR that had a `type:*` label in the T063 pre-run snapshot has exactly one now, and every deleted label has a snapshot entry with name, colour, description and item kinds and numbers in its `.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json`; record both results in `.github/reports/audits/2026-09-14-label-audit/evidence/type-label-check.json` and on the gate issue (the SC-011 and SC-012 checks in quickstart Test 12) (depends on T067) diff --git a/.github/workflows/label-drift-check.yml b/.github/workflows/label-drift-check.yml index c6d15830a1..4009ec3baa 100644 --- a/.github/workflows/label-drift-check.yml +++ b/.github/workflows/label-drift-check.yml @@ -95,6 +95,11 @@ jobs: GITHUB_TOKEN: ${{ steps.app-token-read.outputs.token }} GITHUB_WRITE_TOKEN: ${{ steps.app-token-write.outputs.token }} LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + # Optional. Private repository detail is written only to this private + # repository; without both, it is withheld. Never name a private + # repository in this public repository. + PRIVATE_REPORT_REPO: ${{ vars.PRIVATE_REPORT_REPO }} + PRIVATE_REPORT_TOKEN: ${{ secrets.PRIVATE_REPORT_TOKEN }} DRY_RUN: ${{ inputs.dry_run }} run: | args="" diff --git a/.gitignore b/.gitignore index d13e59f4e7..6e49404f19 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,6 @@ node_modules/ .claude/.settings*.json /graft/ /graphify-out/ + +# Local evidence that names private repositories; never commit (spec 008, 2026-10-04) +/.private-evidence/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b53026229b..45cfec9305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Label inventory token** — The organisation label inventory now needs its own dedicated token, refuses the GitHub Actions token and fails when private repositories are missing. (#3734) +- **Private Repositories Stay Out Of Public Reports** — The label inventory and the weekly drift report now count private repositories without naming them or listing their labels. Detail goes to a private repository when one is set. (#3734) - **Local Scans No Longer Dirty the Repository** — Semgrep's settings and log files are ignored, so a security scan leaves no untracked files to commit by accident. (#3713) - **Jest Config Lint Error Fixed** — A redundant escape in the transform ignore pattern is gone and the file is formatted, clearing the only lint error it carried. (#3713) - **Label Drift Check Dry Run Recorded** — The label audit evidence now records the passing dry run of the weekly drift check and marks the App setup task complete. (#3760) diff --git a/scripts/automation/__tests__/label-drift-check.test.js b/scripts/automation/__tests__/label-drift-check.test.js index b761b92765..e6175ffa40 100644 --- a/scripts/automation/__tests__/label-drift-check.test.js +++ b/scripts/automation/__tests__/label-drift-check.test.js @@ -29,6 +29,13 @@ import { buildFirstSeenBlock, renderReport, upsertDriftIssue, + PRIVATE_DRIFT_ISSUE_TITLE, + summarisePrivate, + renderPrivateSummary, + renderPrivateReport, + maskCommands, + assertPrivateRepository, + resolvePrivateSink, } from '../label-drift-check.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -399,4 +406,130 @@ describe('label-drift-check', () => { { owner: 'o', repo: 'r', issue_number: 7, body: 'body two' }, ]); }); + + // This repository is public, so a private repository is counted and never + // named, in the issue, the artifact or the log (decision of 2026-10-04). + describe('private repositories', () => { + const privateRepos = [ + { repository: 'lightspeedwp/acme-client-site', private: true, labels: [] }, + { repository: 'lightspeedwp/other-client-site', private: true, labels: [] }, + ]; + const privateRows = [ + { location: 'lightspeedwp/acme-client-site', label: 'acme-launch', difference: 'unapproved' }, + { + location: 'lightspeedwp/acme-client-site', + label: 'type:bug', + difference: 'colour mismatch', + }, + { location: 'lightspeedwp/other-client-site', label: 'area:core', difference: 'missing' }, + ]; + + it('counts private drift without naming a repository or a label', () => { + const summary = summarisePrivate(privateRepos, privateRows); + expect(summary).toEqual({ + checked: 2, + withDifferences: 2, + differences: 3, + byKind: { unapproved: 1, 'colour mismatch': 1, missing: 1 }, + }); + const text = renderPrivateSummary(summary); + for (const leaked of ['acme', 'other-client', 'acme-launch', 'area:core']) { + expect(text).not.toContain(leaked); + } + expect(text).toContain('2 private repositories were checked'); + expect(text).toContain('3 difference(s)'); + }); + + it('says so plainly when no private repository has drift', () => { + expect(renderPrivateSummary(summarisePrivate(privateRepos, []))).toContain( + 'None of them has drift.' + ); + }); + + it('puts the summary in the public report and counts it in the total', () => { + const body = renderReport({ + generatedAt: '2026-10-04T00:00:00.000Z', + canonicalCommit: 'abc123', + githubRows: [], + linearRows: [], + allowed: [], + skippedRepos: [], + org: 'lightspeedwp', + firstSeen: new Map(), + privateSummary: summarisePrivate(privateRepos, privateRows), + }); + expect(body).toContain('## Private repositories'); + expect(body).toContain('3 difference(s) across GitHub and Linear.'); + expect(body).toContain('Private repository differences (names withheld): 3'); + for (const leaked of ['acme-client-site', 'other-client-site', 'acme-launch']) { + expect(body).not.toContain(leaked); + } + }); + + it('writes the detail only into the private report', () => { + const body = renderPrivateReport({ + generatedAt: '2026-10-04T00:00:00.000Z', + canonicalCommit: 'abc123', + rows: privateRows, + org: 'lightspeedwp', + }); + expect(body).toContain('lightspeedwp/acme-client-site'); + expect(body).toContain('3 difference(s)'); + }); + + it('registers every private name as a log mask inside GitHub Actions only', () => { + const inventory = { + repositories: [{ repository: 'lightspeedwp/public-tool', private: false }, ...privateRepos], + }; + expect(maskCommands(inventory, { GITHUB_ACTIONS: 'true' })).toEqual([ + '::add-mask::lightspeedwp/acme-client-site', + '::add-mask::acme-client-site', + '::add-mask::lightspeedwp/other-client-site', + '::add-mask::other-client-site', + ]); + expect(maskCommands(inventory, {})).toEqual([]); + }); + + it('refuses to write the private report to a repository that is not private', async () => { + const publicClient = { rest: { repos: { get: async () => ({ data: { private: false } }) } } }; + await expect(assertPrivateRepository(publicClient, 'o', 'r')).rejects.toThrow( + /not a private repository/ + ); + const privateClient = { rest: { repos: { get: async () => ({ data: { private: true } }) } } }; + await expect(assertPrivateRepository(privateClient, 'o', 'r')).resolves.toBeUndefined(); + }); + + it('has no private sink unless one is configured, and needs its token and a valid name', async () => { + await expect(resolvePrivateSink({})).resolves.toBeNull(); + await expect(resolvePrivateSink({ PRIVATE_REPORT_REPO: 'bad' })).rejects.toThrow( + /owner\/repository/ + ); + await expect(resolvePrivateSink({ PRIVATE_REPORT_REPO: 'o/r' })).rejects.toThrow( + /PRIVATE_REPORT_TOKEN/ + ); + const client = { rest: { repos: { get: async () => ({ data: { private: true } }) } } }; + const sink = await resolvePrivateSink( + { PRIVATE_REPORT_REPO: 'o/r', PRIVATE_REPORT_TOKEN: 't' }, + () => client + ); + expect(sink).toEqual({ client, owner: 'o', repo: 'r' }); + }); + + it('upserts the private report under its own title', async () => { + const creates = []; + const client = { + rest: { + search: { issuesAndPullRequests: async () => ({ data: { items: [] } }) }, + issues: { + create: async (params) => { + creates.push(params); + return { data: { number: 1 } }; + }, + }, + }, + }; + await upsertDriftIssue(client, client, 'o', 'r', 'b', PRIVATE_DRIFT_ISSUE_TITLE); + expect(creates[0].title).toBe('Label drift report (private repositories)'); + }); + }); }); diff --git a/scripts/automation/__tests__/label-inventory.test.js b/scripts/automation/__tests__/label-inventory.test.js index 19581ad02f..9b7890af77 100644 --- a/scripts/automation/__tests__/label-inventory.test.js +++ b/scripts/automation/__tests__/label-inventory.test.js @@ -9,7 +9,11 @@ import { collectPages, buildInventory, incompleteRepositories, + incompleteRepositoryLabels, + isUnderTrackedGithubDirectory, privateRepositoryGap, + publicRepositoryName, + redactInventory, resolveToken, } from '../label-inventory.js'; @@ -146,4 +150,104 @@ describe('label-inventory', () => { ).toBeNull(); }); }); + + // This repository is public, so the tracked evidence must not name a private + // repository or list its labels (decision of 2026-10-04), while completeness is + // still judged on every repository. + describe('redacting private repositories', () => { + const inventory = { + generated_at: '2026-10-04T00:00:00.000Z', + organisation: 'lightspeedwp', + repository_count: 3, + private_repository_count: 2, + reported_private_repository_count: 2, + label_total: 12, + repositories: [ + { + repository: 'lightspeedwp/public-tool', + archived: false, + fork: false, + private: false, + label_count: 2, + pages_read: 1, + labels: [{ name: 'type:bug', color: 'AAAAAA', description: 'x' }], + }, + { + repository: 'lightspeedwp/acme-client-site', + archived: false, + fork: false, + private: true, + label_count: 7, + pages_read: 1, + labels: [{ name: 'acme-launch', color: 'BBBBBB', description: 'secret' }], + }, + { + repository: 'lightspeedwp/other-client-site', + archived: true, + fork: false, + private: true, + label_count: 3, + pages_read: 1, + labels: [], + }, + ], + }; + + it('names no private repository and lists none of its labels', () => { + const text = JSON.stringify(redactInventory(inventory)); + for (const leaked of ['acme-client-site', 'other-client-site', 'acme-launch', 'secret']) { + expect(text).not.toContain(leaked); + } + }); + + it('keeps public repositories whole and private ones as counts under placeholders', () => { + const redacted = redactInventory(inventory); + expect(redacted.repositories[0]).toEqual(inventory.repositories[0]); + expect(redacted.repositories[1]).toEqual({ + repository: 'lightspeedwp/private-repository-001', + archived: false, + fork: false, + private: true, + label_count: 7, + pages_read: 1, + }); + expect(redacted.repositories[2].repository).toBe('lightspeedwp/private-repository-002'); + expect(redacted.redacted).toBe(true); + expect(redacted.private_repository_count).toBe(2); + expect(redacted.label_total).toBe(12); + }); + + it('leaves the full inventory untouched', () => { + redactInventory(inventory); + expect(inventory.repositories[1].repository).toBe('lightspeedwp/acme-client-site'); + expect(inventory.repositories[1].labels).toHaveLength(1); + }); + + it('still passes the completeness checks, because they ran on the full inventory', () => { + const redacted = redactInventory(inventory); + expect(incompleteRepositories(redacted)).toEqual([]); + expect(privateRepositoryGap(redacted)).toBeNull(); + }); + + it('says a private repository in a message, not its name', () => { + expect(publicRepositoryName(inventory.repositories[0])).toBe('lightspeedwp/public-tool'); + expect(publicRepositoryName(inventory.repositories[1])).toBe('a private repository'); + const broken = { + ...inventory, + repositories: inventory.repositories.map((repo) => ({ ...repo, pages_read: 0 })), + }; + const labels = incompleteRepositoryLabels(broken); + expect(labels).toContain('lightspeedwp/public-tool'); + expect(labels.join(' ')).not.toContain('client-site'); + }); + + it('refuses a full-output path inside the tracked .github directory', () => { + const cwd = '/repo'; + expect(isUnderTrackedGithubDirectory('.github/reports/full.json', cwd)).toBe(true); + expect(isUnderTrackedGithubDirectory('.github', cwd)).toBe(true); + expect(isUnderTrackedGithubDirectory('.private-evidence/full.json', cwd)).toBe(false); + expect(isUnderTrackedGithubDirectory('/tmp/full.json', cwd)).toBe(false); + expect(isUnderTrackedGithubDirectory('.githubx/full.json', cwd)).toBe(false); + }); + }); }); diff --git a/scripts/automation/label-drift-check.js b/scripts/automation/label-drift-check.js index 6579edceac..df92c1c0c4 100644 --- a/scripts/automation/label-drift-check.js +++ b/scripts/automation/label-drift-check.js @@ -12,6 +12,13 @@ * reads, issue search, and create/update of the one report issue. It never * creates, edits or deletes labels (FR-017 rule 2, FR-018). * + * Private repositories (decision of 2026-10-04): this repository is public, so + * the issue, the JSON artifact and the log count private repositories but never + * name one or list its labels. Their detail goes only to a private repository, + * when `PRIVATE_REPORT_REPO` (owner/name) and `PRIVATE_REPORT_TOKEN` (issues + * write on that repository) are set; the repository is confirmed private before + * anything is written, and without them the detail is withheld. + * * Authentication (FR-018): GitHub via an organisation-wide App installation * token passed as `GITHUB_TOKEN` (manual step T071a) for reads, plus a * report-scoped token as `GITHUB_WRITE_TOKEN` (issues write on the report @@ -25,10 +32,11 @@ import fs from 'fs'; import path from 'path'; import * as yaml from 'js-yaml'; -import { buildInventory, incompleteRepositories } from './label-inventory.js'; +import { buildInventory, incompleteRepositoryLabels } from './label-inventory.js'; import { buildLinearInventory } from './linear-label-inventory.js'; export const DRIFT_ISSUE_TITLE = 'Label drift report'; +export const PRIVATE_DRIFT_ISSUE_TITLE = 'Label drift report (private repositories)'; export const DRIFT_ISSUE_LABELS = ['type:audit', 'area:labels', 'status:needs-triage']; // Team-scoped Linear labels documented in spec 008 FR-012 as project labels @@ -324,6 +332,7 @@ export function renderReport({ uncountedRows = 0, maxTableRows = MAX_TABLE_ROWS, maxFirstSeenBytes = MAX_FIRST_SEEN_BYTES, + privateSummary = null, }) { const today = generatedAt.slice(0, 10); const rows = [...githubRows, ...linearRows].sort((a, b) => @@ -339,9 +348,9 @@ export function renderReport({ const lines = [ '## Summary', '', - withDates.length === 0 + withDates.length + (privateSummary?.differences ?? 0) === 0 ? 'No drift.' - : `${withDates.length} difference(s) across GitHub and Linear.`, + : `${withDates.length + (privateSummary?.differences ?? 0)} difference(s) across GitHub and Linear.`, '', '## GitHub repositories', '', @@ -360,6 +369,11 @@ export function renderReport({ `| … | _Showing ${maxTableRows} of ${githubOnly.length}; see the run artifact for all rows._ | | | |` ); } + if (privateSummary) { + // This repository is public. A private repository is counted, never named, + // and its labels are not listed (decision of 2026-10-04). + lines.push('', '## Private repositories', '', renderPrivateSummary(privateSummary)); + } lines.push( '', '## Linear', @@ -398,6 +412,9 @@ export function renderReport({ `- Organisation: ${org}`, `- Approved set: .github/labels.yml at ${canonicalCommit}`, `- GitHub differences: ${githubRows.length}`, + ...(privateSummary + ? [`- Private repository differences (names withheld): ${privateSummary.differences}`] + : []), `- Linear differences: ${linearRows.length}`, `- Allowed team-scoped exceptions: ${allowed.length}`, uncountedRows === 0 @@ -414,6 +431,82 @@ export function renderReport({ return lines.join('\n'); } +/** + * Counts for the private repositories, safe for a public report: no name and no + * label appears in it. + * @param {Array} privateRepos Private inventory entries. + * @param {Array} privateRows Drift rows for those repositories. + * @returns {{checked: number, withDifferences: number, differences: number, byKind: Record}} + */ +export function summarisePrivate(privateRepos, privateRows) { + const byKind = {}; + for (const row of privateRows) byKind[row.difference] = (byKind[row.difference] ?? 0) + 1; + return { + checked: privateRepos.length, + withDifferences: new Set(privateRows.map((row) => row.location)).size, + differences: privateRows.length, + byKind, + }; +} + +/** + * The paragraph for the public report. + * @param {ReturnType} summary + * @returns {string} + */ +export function renderPrivateSummary(summary) { + const kinds = Object.entries(summary.byKind) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([kind, count]) => `${count} ${kind}`) + .join(', '); + return [ + `${summary.checked} private repositories were checked. Their names and labels are withheld from this public repository.`, + summary.differences === 0 + ? 'None of them has drift.' + : `${summary.withDifferences} of them have drift: ${summary.differences} difference(s) (${kinds}). The detail is in the private report, when one is configured.`, + ].join('\n\n'); +} + +/** + * The report for a private repository, which names the repositories and so must + * only ever be written to a private repository. + * @param {object} options + * @returns {string} + */ +export function renderPrivateReport({ generatedAt, canonicalCommit, rows, org, maxRows = 400 }) { + const sorted = sortRows(rows); + const lines = [ + '## Summary', + '', + sorted.length === 0 + ? 'No drift in private repositories.' + : `${sorted.length} difference(s) across ${org}'s private repositories.`, + '', + '## Private repositories', + '', + '| Location | Label | Difference | Items |', + '| --- | --- | --- | --- |', + ]; + if (sorted.length === 0) lines.push('| — | — | — | — |'); + for (const row of sorted.slice(0, maxRows)) { + lines.push( + `| ${escapeCell(row.location)} | ${escapeCell(row.label)} | ${row.difference} | ${row.items ?? ''} |` + ); + } + if (sorted.length > maxRows) { + lines.push(`| … | _Showing ${maxRows} of ${sorted.length}._ | | |`); + } + lines.push( + '', + '## Run details', + '', + `- Generated at: ${generatedAt}`, + `- Organisation: ${org}`, + `- Approved set: .github/labels.yml at ${canonicalCommit}` + ); + return lines.join('\n'); +} + function sortRows(rows) { return [...rows].sort((a, b) => firstSeenKey(a.location, a.label, a.difference).localeCompare( @@ -437,12 +530,29 @@ const SEARCH_PACING_MS = 2200; * Find the open drift report issue by exact title. * @returns {Promise} Issue or null. */ -export async function findDriftIssue(client, owner, repo) { +export async function findDriftIssue(client, owner, repo, title = DRIFT_ISSUE_TITLE) { const { data } = await client.rest.search.issuesAndPullRequests({ - q: `repo:${owner}/${repo} "${DRIFT_ISSUE_TITLE}" in:title type:issue state:open`, + q: `repo:${owner}/${repo} "${title}" in:title type:issue state:open`, per_page: 10, }); - return data.items.find((item) => item.title === DRIFT_ISSUE_TITLE) ?? null; + return data.items.find((item) => item.title === title) ?? null; +} + +/** + * Refuse to name private repositories anywhere that is not private. The private + * report is only written to a repository this call has confirmed is private. + * @param {object} client Octokit-style client with read access to the repository. + * @param {string} owner Repository owner. + * @param {string} repo Repository name. + * @returns {Promise} + */ +export async function assertPrivateRepository(client, owner, repo) { + const { data } = await client.rest.repos.get({ owner, repo }); + if (data.private !== true) { + throw new Error( + 'PRIVATE_REPORT_REPO is not a private repository, so the private report is not written: it names private repositories' + ); + } } /** @@ -451,13 +561,20 @@ export async function findDriftIssue(client, owner, repo) { * Lookup runs on the read client; the create/update runs on the * report-scoped write client, so a read-only run cannot write at all. */ -export async function upsertDriftIssue(readClient, writeClient, owner, repo, body) { - const existing = await findDriftIssue(readClient, owner, repo); +export async function upsertDriftIssue( + readClient, + writeClient, + owner, + repo, + body, + title = DRIFT_ISSUE_TITLE +) { + const existing = await findDriftIssue(readClient, owner, repo, title); if (!existing) { const { data } = await writeClient.rest.issues.create({ owner, repo, - title: DRIFT_ISSUE_TITLE, + title, body, labels: DRIFT_ISSUE_LABELS, }); @@ -502,6 +619,53 @@ export async function enrichGithubRows(client, rows, options = {}) { return { counted, uncounted }; } +/** + * Actions workflow commands that mask every private repository name in the log. + * `add-mask` only covers the log, so it is a backstop and not the control: the + * control is that no name is ever written to the issue, the artifact or the log. + * @param {object} inventory Result of buildInventory. + * @param {Record} [env] Environment. + * @returns {string[]} Lines to print, empty outside GitHub Actions. + */ +export function maskCommands(inventory, env = process.env) { + if (env.GITHUB_ACTIONS !== 'true') return []; + const lines = []; + for (const repo of inventory.repositories) { + if (!repo.private) continue; + lines.push(`::add-mask::${repo.repository}`); + const bare = repo.repository.split('/').pop(); + if (bare) lines.push(`::add-mask::${bare}`); + } + return lines; +} + +/** + * The private report target, when one is configured. + * @param {Record} env Environment. + * @param {(options: object) => object} [makeClient] Builds an Octokit-style client. + * @returns {Promise<{client: object, owner: string, repo: string}|null>} Null when + * PRIVATE_REPORT_REPO is not set. + */ +export async function resolvePrivateSink(env, makeClient = null) { + const target = env.PRIVATE_REPORT_REPO; + if (!target) return null; + const [owner, repo] = target.split('/'); + if (!owner || !repo) { + throw new Error('PRIVATE_REPORT_REPO must be owner/repository'); + } + const token = env.PRIVATE_REPORT_TOKEN; + if (!token) { + throw new Error( + 'PRIVATE_REPORT_TOKEN is required when PRIVATE_REPORT_REPO is set (issues write on that private repository only)' + ); + } + const client = makeClient + ? makeClient({ auth: token }) + : new (await import('octokit')).Octokit({ auth: token }); + await assertPrivateRepository(client, owner, repo); + return { client, owner, repo }; +} + function parseArgs(argv) { const args = { org: 'lightspeedwp', @@ -540,48 +704,68 @@ async function main() { const canonical = await loadCanonicalLabels(path.join(process.cwd(), '.github', 'labels.yml')); const inventory = await buildInventory(client, org); - const incomplete = incompleteRepositories(inventory); + // Defence in depth for the Actions log: the names below are never printed, but + // registering them as masks means an accidental print is replaced by asterisks. + for (const line of maskCommands(inventory)) console.log(line); + const incomplete = incompleteRepositoryLabels(inventory); if (incomplete.length > 0) { throw new Error(`Incomplete GitHub pagination for: ${incomplete.join(', ')}`); } - const skippedRepos = inventory.repositories - .filter((r) => r.archived || r.fork) + // This repository is public, so a private repository is never named in the + // issue, the artifact or the log (decision of 2026-10-04). It is still checked. + const publicSkipped = inventory.repositories + .filter((r) => !r.private && (r.archived || r.fork)) .map((r) => r.repository); const activeRepos = inventory.repositories.filter((r) => !r.archived && !r.fork); + const activePublic = activeRepos.filter((r) => !r.private); + const activePrivate = activeRepos.filter((r) => r.private); const linear = await buildLinearInventory(); const githubRows = []; - for (const repoEntry of activeRepos) { + for (const repoEntry of activePublic) { githubRows.push(...diffGithubRepo(repoEntry, canonical)); } + const privateRows = []; + for (const repoEntry of activePrivate) { + privateRows.push(...diffGithubRepo(repoEntry, canonical)); + } const { rows: linearRows, allowed } = diffLinearLabels(linear.labels, canonical); + // Where the private detail goes, if anywhere: a private repository, confirmed + // private before anything is written to it. + const privateSink = await resolvePrivateSink(process.env); + // Enrich GitHub rows with issue/PR counts, paced for the search rate limit. const { uncounted: uncountedRows } = await enrichGithubRows(client, githubRows); + if (privateSink) await enrichGithubRows(client, privateRows); let firstSeen = new Map(); if (!dryRun) { const existing = await findDriftIssue(client, owner, repoName); if (existing) firstSeen = parseFirstSeen(existing.body); } + const privateSummary = summarisePrivate(activePrivate, privateRows); + const canonicalCommit = process.env.GITHUB_SHA ?? 'local run'; const body = renderReport({ generatedAt, - canonicalCommit: process.env.GITHUB_SHA ?? 'local run', + canonicalCommit, githubRows: sortRows(githubRows), linearRows: sortRows(linearRows), allowed, - skippedRepos, + skippedRepos: publicSkipped, org, firstSeen, uncountedRows, + privateSummary, }); const report = { generated_at: generatedAt, organisation: org, canonical_labels: canonical.size, - github_repositories: activeRepos.length, - skipped_repositories: skippedRepos, + github_repositories: activePublic.length, + private_repositories: privateSummary, + skipped_repositories: publicSkipped, linear_labels: linear.labels.length, differences: sortRows([...githubRows, ...linearRows]), allowed_exceptions: allowed.map((label) => ({ @@ -602,6 +786,29 @@ async function main() { console.log( `✅ ${report.differences.length} difference(s), ${allowed.length} allowed exception(s) → ${created ? 'created' : 'updated'} issue #${issue.number}` ); + if (privateSink) { + const privateBody = renderPrivateReport({ + generatedAt, + canonicalCommit, + rows: privateRows, + org, + }); + const written = await upsertDriftIssue( + privateSink.client, + privateSink.client, + privateSink.owner, + privateSink.repo, + privateBody, + PRIVATE_DRIFT_ISSUE_TITLE + ); + console.log( + `✅ private repositories: ${privateSummary.differences} difference(s) → ${written.created ? 'created' : 'updated'} the private report (names withheld here)` + ); + } else if (privateSummary.differences > 0) { + console.log( + `ℹ️ ${privateSummary.differences} private repository difference(s) found; the detail is withheld because PRIVATE_REPORT_REPO is not set` + ); + } } if (process.argv[1] && path.basename(process.argv[1]) === 'label-drift-check.js') { diff --git a/scripts/automation/label-inventory.js b/scripts/automation/label-inventory.js index 38a1365d86..1bd41f3df7 100644 --- a/scripts/automation/label-inventory.js +++ b/scripts/automation/label-inventory.js @@ -15,8 +15,14 @@ * public repositories in other organisation repos, so the script refuses to * run inside GitHub Actions and fails when private repositories are missing. * + * Private repositories (decision of 2026-10-04): they are inventoried, so the + * clean-up covers them, but this repository is public, so the file committed to + * it never names one. `--output` (the tracked evidence file) is always the + * redacted inventory; `--full-output` writes the complete one and must be + * outside `.github/`, for example under `.private-evidence/` (ignored by git). + * * Usage: - * LABEL_INVENTORY_TOKEN=... node scripts/automation/label-inventory.js [--org lightspeedwp] [--output path] + * LABEL_INVENTORY_TOKEN=... node scripts/automation/label-inventory.js [--org lightspeedwp] [--output path] [--full-output path] */ import fs from 'fs'; @@ -172,11 +178,79 @@ export function resolveToken(env) { return { token }; } +/** + * Withhold what identifies a private repository, keeping what completeness + * needs. This repository is public, so the tracked evidence must not name a + * private repository or list its labels (decision of 2026-10-04): a private + * entry keeps its counts and flags under a numbered placeholder, and its + * label list is dropped. Public repositories are unchanged. Completeness is + * judged on the full inventory before this runs. + * @param {object} inventory Result of buildInventory. + * @returns {object} A copy that is safe to commit to a public repository. + */ +export function redactInventory(inventory) { + let number = 0; + const repositories = inventory.repositories.map((repo) => { + if (!repo.private) return repo; + number += 1; + return { + repository: `${inventory.organisation}/private-repository-${String(number).padStart(3, '0')}`, + archived: repo.archived, + fork: repo.fork, + private: true, + label_count: repo.label_count, + pages_read: repo.pages_read, + }; + }); + return { + ...inventory, + redacted: true, + redaction_note: + 'Private repository names and labels are withheld from this public repository; the counts are kept so completeness can be checked.', + repositories, + }; +} + +/** + * A repository's name for a message or a public report: its own name when it + * is public, and a generic phrase when it is private. + * @param {object} repo An inventory entry. + * @returns {string} + */ +export function publicRepositoryName(repo) { + return repo.private ? 'a private repository' : repo.repository; +} + +/** + * The repositories that fail the pagination rule, named only where public. + * @param {object} inventory Result of buildInventory. + * @returns {string[]} + */ +export function incompleteRepositoryLabels(inventory) { + const failing = new Set(incompleteRepositories(inventory)); + return inventory.repositories + .filter((repo) => failing.has(repo.repository)) + .map((repo) => publicRepositoryName(repo)); +} + +/** + * Whether a path is inside the tracked `.github` directory of this repository, + * where nothing that names a private repository may be written. + * @param {string} target Output path. + * @param {string} [cwd] Working directory. + * @returns {boolean} + */ +export function isUnderTrackedGithubDirectory(target, cwd = process.cwd()) { + const relative = path.relative(path.resolve(cwd, '.github'), path.resolve(cwd, target)); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + function parseArgs(argv) { - const args = { org: 'lightspeedwp', output: DEFAULT_OUTPUT }; + const args = { org: 'lightspeedwp', output: DEFAULT_OUTPUT, fullOutput: null }; for (let i = 0; i < argv.length; i += 1) { if (argv[i] === '--org') args.org = argv[(i += 1)]; else if (argv[i] === '--output') args.output = argv[(i += 1)]; + else if (argv[i] === '--full-output') args.fullOutput = argv[(i += 1)]; } return args; } @@ -189,10 +263,20 @@ async function main() { } const { Octokit } = await import('octokit'); const client = new Octokit({ auth: token }); - const { org, output } = parseArgs(process.argv.slice(2)); + const { org, output, fullOutput } = parseArgs(process.argv.slice(2)); + + // The full inventory may name private repositories, so it is only ever written + // outside the tracked `.github` directory (this repository is public). Checked + // before any API call. + if (fullOutput && isUnderTrackedGithubDirectory(fullOutput)) { + console.error( + '❌ --full-output must be outside .github/, because the full inventory names private repositories and this repository is public' + ); + process.exit(1); + } const inventory = await buildInventory(client, org); - const incomplete = incompleteRepositories(inventory); + const incomplete = incompleteRepositoryLabels(inventory); if (incomplete.length > 0) { console.error(`❌ Incomplete pagination for: ${incomplete.join(', ')}`); process.exit(1); @@ -203,10 +287,15 @@ async function main() { process.exit(1); } + if (fullOutput) { + fs.mkdirSync(path.dirname(fullOutput), { recursive: true }); + fs.writeFileSync(fullOutput, `${JSON.stringify(inventory, null, 2)}\n`); + } + const publicInventory = redactInventory(inventory); fs.mkdirSync(path.dirname(output), { recursive: true }); - fs.writeFileSync(output, `${JSON.stringify(inventory, null, 2)}\n`); + fs.writeFileSync(output, `${JSON.stringify(publicInventory, null, 2)}\n`); console.log( - `✅ ${inventory.repository_count} repositories, ${inventory.label_total} labels → ${output}` + `✅ ${inventory.repository_count} repositories (${inventory.private_repository_count} private, names withheld), ${inventory.label_total} labels → ${output}${fullOutput ? ` (full inventory: ${fullOutput})` : ''}` ); } From fa95244c3e4c36101768b869f2982b6b8d1a8237 Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Sun, 4 Oct 2026 18:26:08 +0200 Subject: [PATCH 08/22] Mint the private report token in the workflow from the installed App instead of a stored secret --- .github/workflows/label-drift-check.yml | 31 ++++++++++++++++--- .../label-drift-check-config.test.js | 20 +++++++++++- scripts/automation/label-drift-check.js | 5 +-- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/.github/workflows/label-drift-check.yml b/.github/workflows/label-drift-check.yml index 4009ec3baa..7de0336481 100644 --- a/.github/workflows/label-drift-check.yml +++ b/.github/workflows/label-drift-check.yml @@ -90,16 +90,39 @@ jobs: repositories: .github permission-issues: write + - name: Resolve the private report repository + # Optional. The variable PRIVATE_REPORT_REPO (owner/name) names a PRIVATE + # repository that receives the detail for private repositories. This + # public repository never names a private one, so with the variable unset + # that detail is withheld. + id: private-report + env: + PRIVATE_REPORT_REPO: ${{ vars.PRIVATE_REPORT_REPO }} + run: | + echo "name=${PRIVATE_REPORT_REPO#*/}" >> "$GITHUB_OUTPUT" + + - name: Mint private report App token + # Write-scoped to the private report repository only, using the same + # installed App as the tokens above, so no extra secret is stored. + if: steps.private-report.outputs.name != '' + id: app-token-private + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.LS_APP_ID }} + private-key: ${{ secrets.LS_APP_PRIVATE_KEY }} + owner: lightspeedwp + repositories: ${{ steps.private-report.outputs.name }} + permission-issues: write + - name: Run label drift check env: GITHUB_TOKEN: ${{ steps.app-token-read.outputs.token }} GITHUB_WRITE_TOKEN: ${{ steps.app-token-write.outputs.token }} LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} - # Optional. Private repository detail is written only to this private - # repository; without both, it is withheld. Never name a private - # repository in this public repository. + # Private repository detail is written only to this private repository, + # and only after the script confirms it is private. PRIVATE_REPORT_REPO: ${{ vars.PRIVATE_REPORT_REPO }} - PRIVATE_REPORT_TOKEN: ${{ secrets.PRIVATE_REPORT_TOKEN }} + PRIVATE_REPORT_TOKEN: ${{ steps.app-token-private.outputs.token }} DRY_RUN: ${{ inputs.dry_run }} run: | args="" diff --git a/scripts/automation/__tests__/label-drift-check-config.test.js b/scripts/automation/__tests__/label-drift-check-config.test.js index 3dbe043527..c2b8bf4e4e 100644 --- a/scripts/automation/__tests__/label-drift-check-config.test.js +++ b/scripts/automation/__tests__/label-drift-check-config.test.js @@ -84,7 +84,7 @@ describe('label drift check wiring', () => { (step) => typeof step.uses === 'string' && step.uses.startsWith('actions/create-github-app-token@') ); - expect(mintSteps).toHaveLength(2); + expect(mintSteps).toHaveLength(3); const byId = Object.fromEntries(mintSteps.map((step) => [step.id, step])); // Read token: organisation-wide (no repositories key), read-only. expect(byId['app-token-read']).toBeDefined(); @@ -106,6 +106,24 @@ describe('label drift check wiring', () => { expect(byId['app-token-write']).toBeDefined(); expect(String(byId['app-token-write'].with.repositories)).toContain('.github'); expect(String(byId['app-token-write'].with['permission-issues'])).toBe('write'); + // Private report token: issues write on the private report repository only, + // minted only when that repository is configured, and never named in this + // public file: the name comes from a variable. + const privateMint = byId['app-token-private']; + expect(privateMint).toBeDefined(); + expect(privateMint.if).toBe("steps.private-report.outputs.name != ''"); + expect(String(privateMint.with.repositories)).toBe('${{ steps.private-report.outputs.name }}'); + expect(String(privateMint.with['permission-issues'])).toBe('write'); + expect(Object.keys(privateMint.with).filter((key) => key.startsWith('permission-'))).toEqual([ + 'permission-issues', + ]); + }); + + it('takes the private report repository from a variable and stores no token for it', () => { + const raw = workflowText(); + expect(raw).toContain('vars.PRIVATE_REPORT_REPO'); + expect(raw).toContain('steps.app-token-private.outputs.token'); + expect(raw).not.toMatch(/secrets\.PRIVATE_REPORT_TOKEN/); }); it('only invokes npm scripts that package.json declares', () => { diff --git a/scripts/automation/label-drift-check.js b/scripts/automation/label-drift-check.js index df92c1c0c4..8b4d6b11a5 100644 --- a/scripts/automation/label-drift-check.js +++ b/scripts/automation/label-drift-check.js @@ -16,8 +16,9 @@ * the issue, the JSON artifact and the log count private repositories but never * name one or list its labels. Their detail goes only to a private repository, * when `PRIVATE_REPORT_REPO` (owner/name) and `PRIVATE_REPORT_TOKEN` (issues - * write on that repository) are set; the repository is confirmed private before - * anything is written, and without them the detail is withheld. + * write on that repository, minted by the workflow from the installed App) are + * set; the repository is confirmed private before anything is written, and + * without them the detail is withheld. * * Authentication (FR-018): GitHub via an organisation-wide App installation * token passed as `GITHUB_TOKEN` (manual step T071a) for reads, plus a From e46de68b5e36af0b84253ef67dc3c75761b5c2b8 Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Mon, 5 Oct 2026 11:47:23 +0200 Subject: [PATCH 09/22] Remove the changelog entries duplicated by the develop merge --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31a71f6ef9..f22adcb320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,8 +58,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Label inventory token** — The organisation label inventory now needs its own dedicated token, refuses the GitHub Actions token and fails when private repositories are missing. (#3734) - **Private Repositories Stay Out Of Public Reports** — The label inventory and the weekly drift report now count private repositories without naming them or listing their labels. Detail goes to a private repository when one is set. (#3734) -- **Local Scans No Longer Dirty the Repository** — Semgrep's settings and log files are ignored, so a security scan leaves no untracked files to commit by accident. (#3713) -- **Jest Config Lint Error Fixed** — A redundant escape in the transform ignore pattern is gone and the file is formatted, clearing the only lint error it carried. (#3713) - **Dependabot Merge Queue Fixed** — Dependabot pull requests no longer fail the Mergify queue check, because the queue now tests each pull request in place. ([PR #3817](https://github.com/lightspeedwp/.github/pull/3817)) - **Branch Cleanup Specs Closed Out** — The cloud environment and branch cleanup specs now show the merged audit and how a maintainer reviews `claude/*` branches before deletion. ([PR #3813](https://github.com/lightspeedwp/.github/pull/3813)) - **Cloud Spec Decisions Recorded** — Spec 018 now records seven clarified rules, including that empty `claude/*` branches are not auto-deleted yet, and the guard tests also cover the setup script's Node install. (#3726) From db6e31e526aadc4cb39fe266b236feb426e5f050 Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Mon, 5 Oct 2026 12:09:56 +0200 Subject: [PATCH 10/22] Run the locked files guard from the default branch definition --- .github/workflows/locked-files-guard.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/locked-files-guard.yml b/.github/workflows/locked-files-guard.yml index 68a25de667..13319f011a 100644 --- a/.github/workflows/locked-files-guard.yml +++ b/.github/workflows/locked-files-guard.yml @@ -10,11 +10,20 @@ # The guard script and the register are read from the BASE branch, so a pull # request cannot approve itself by editing them. Make this check required in # the branch protection rules for `develop` and `main`. +# +# The trigger is `pull_request_target`, so GitHub loads this workflow from the +# default branch rather than from the pull request's merge commit; a pull +# request that changes both a locked file and this workflow therefore cannot +# swap the guard step. That is safe only because the job never checks out or +# runs pull request code: it checks out the base commit and reads the changed +# files through the API. Keep it that way, and keep this workflow on `develop` +# before requiring the check. It does not run on the pull request that first +# adds it, and it does not replace Code Owner review. name: Locked Files Guard on: - pull_request: + pull_request_target: branches: [develop, main] types: [opened, synchronize, reopened, edited, ready_for_review] From e246073deb35a991a860bcccc3b4c8d782ebcd7f Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Mon, 5 Oct 2026 13:14:44 +0200 Subject: [PATCH 11/22] Record the pull_request_target Actions policy requirement for the locked files guard --- .github/specs/008-label-audit-consolidation/tasks.md | 2 +- .github/workflows/locked-files-guard.yml | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/specs/008-label-audit-consolidation/tasks.md b/.github/specs/008-label-audit-consolidation/tasks.md index 37f75e2dfd..aaa94ffc41 100644 --- a/.github/specs/008-label-audit-consolidation/tasks.md +++ b/.github/specs/008-label-audit-consolidation/tasks.md @@ -591,7 +591,7 @@ The active labelling agent removes labels outside `labels.yml` today, so this ru ## Phase 10: Convergence - [ ] T080 Add a required pull request check, `.github/workflows/locked-files-guard.yml` running a new `scripts/validation/check-locked-file-approval.cjs` (with tests in `scripts/validation/__tests__/`), that fails when a PR changes a constitution v1.4.0 LOCKED file (`.github/labels.yml`, `.github/issue-types.yml`, `.github/ISSUE_TEMPLATE/*.md`, `.github/PULL_REQUEST_TEMPLATE/*.md`, `.github/PULL_REQUEST_TEMPLATE/config.yml`, `.github/branch-types.yml`, `.github/branch-labels.yml`) unless the PR body links a change-request issue whose entry in `.github/reports/audits/2026-09-14-label-audit/evidence/change-requests.json` (or, outside spec 008, a dated approval comment by `ashleyshaw` on that issue) shows `status: "approved"`; also give those paths explicit `CODEOWNERS` lines owned by `@ashleyshaw` only, so a team member's approval cannot stand in for the approver's (the gap that let #3534 merge) per Constitution II, plan: Constitution Check remediation (partial) — implemented in #3734, which is still open (guard, tests and `CODEOWNERS`; approvals count only on issues tagged `[LABEL-UPDATE-REQUEST]`, `[ISSUE-TYPE-UPDATE-REQUEST]` or `[TEMPLATE-UPDATE-REQUEST]`, per the 2026-10-02 clarification); it checks only that an approved request is linked and does not compare which locked files that request covers, which T084 adds; the branch-protection settings that make it enforce are T080a -- [ ] T080a (@ashleyshaw, manual; after #3734 merges) In the branch protection rules for `develop` and `main`, make "Locked files need an approved change request" a required check and turn on "Require review from Code Owners"; then open a test PR that changes `.github/labels.yml` with no linked change request, confirm the check fails and the PR cannot merge, close it, and record the settings on #3733; must be done before the Stage 2 configuration PR (T059) per Constitution II, FR-009 (partial) +- [ ] T080a (@ashleyshaw, manual; after #3734 merges) In the branch protection rules for `develop` and `main`, make "Locked files need an approved change request" a required check and turn on "Require review from Code Owners"; then open a test PR that changes `.github/labels.yml` with no linked change request, confirm the check fails and the PR cannot merge, close it, and record the settings on #3733; also, before 2 November 2026, allow `pull_request_target` for `.github/workflows/locked-files-guard.yml` in an Actions event policy (repository Settings → Actions → Policies, or `POST /repos/lightspeedwp/.github/actions/policies`): from that date GitHub blocks `pull_request_target` by default in public repositories that have no event policy, and this repository has none today (`GET /repos/lightspeedwp/.github/actions/policies` returns zero), so the required check would never report; must be done before the Stage 2 configuration PR (T059) per Constitution II, FR-009 (partial) - [ ] T081 Extend `scripts/validation/validate-labeling-configs.cjs` (and its tests) to enforce the constitution v1.4.0 routing rules: every branch type in `.github/branch-types.yml` has exactly one `type:*` label in `.github/branch-labels.yml` `default_labels`, equal to the canonical type label in its routed template's frontmatter (mapping `type:documentation` → `type:docs` and `type:ai-ops` → `type:aiops` until T046b is applied), and every route in `.github/PULL_REQUEST_TEMPLATE/config.yml` matches `branch-types.yml`; run it after T074 and T076 so it starts green (depends on T074, T076; #3732 has merged) per Constitution v1.4.0 routing, plan: R23 (partial) - [ ] T084 Extend the T080 guard so an approval authorises only the locked files its request names: add a `covers` list (paths or patterns) to each request entry in `.github/reports/audits/2026-09-14-label-audit/evidence/change-requests.json`, and make `scripts/validation/check-locked-file-approval.cjs` fail when any locked file the PR changes is not matched by the `covers` list of an approved linked request; add tests in `scripts/validation/__tests__/check-locked-file-approval.test.js` showing that an approval covering file A does not pass a PR that changes file B, that a PR changing A and B passes only when both are covered, and that the existing passing cases still pass; whether a change within a covered file matches what was approved stays the approver's review through the `CODEOWNERS` line (depends on T080) per FR-009, Constitution II - [ ] T082 [US4] Before T043 fills `mappings[]`, re-read every Linear label with the Linear MCP integration (`list_issue_labels` for the workspace and for every team, archived included) and record in `.github/reports/audits/2026-09-14-label-audit/evidence/linear-labels.json` how many `migrate:*` labels remain active, with their scope and issue counts; exclude every `migrate:*` label from the FR-012 import, the mapping and any sync back to GitHub; list any that are still active (on 2026-10-02 the GIT team still listed about 30) on the gate issue for @ashleyshaw to retire; and check each valid Linear label against `.github/labels.yml` on `develop`, so the T043 import list holds only labels that are used or required by automation and are not yet in `labels.yml` per FR-012, FR-006 (partial) diff --git a/.github/workflows/locked-files-guard.yml b/.github/workflows/locked-files-guard.yml index 13319f011a..d251b84bec 100644 --- a/.github/workflows/locked-files-guard.yml +++ b/.github/workflows/locked-files-guard.yml @@ -19,6 +19,13 @@ # files through the API. Keep it that way, and keep this workflow on `develop` # before requiring the check. It does not run on the pull request that first # adds it, and it does not replace Code Owner review. +# +# From 2 November 2026 GitHub blocks `pull_request_target` by default in public +# repositories that have no Actions event policy +# (https://github.blog/changelog/2026-09-17-workflow-execution-protections-in-github-actions-generally-available/). +# This repository has none, so an Actions event policy must allow this event for +# this workflow before then (spec 008 task T080a); the policy is a repository +# setting, not a file, so it is not created here. name: Locked Files Guard From daf5cc51a5d2dd61df445bd72be7c54b36ed802c Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Mon, 5 Oct 2026 14:16:38 +0200 Subject: [PATCH 12/22] Route private-repository dry-run records and approvals through the private path in T065 to T067a --- .github/specs/008-label-audit-consolidation/tasks.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/specs/008-label-audit-consolidation/tasks.md b/.github/specs/008-label-audit-consolidation/tasks.md index aaa94ffc41..f057104e5f 100644 --- a/.github/specs/008-label-audit-consolidation/tasks.md +++ b/.github/specs/008-label-audit-consolidation/tasks.md @@ -461,11 +461,11 @@ The active labelling agent removes labels outside `labels.yml` today, so this ru ### Stage 4: Gated GitHub Deletion (FR-016) - [ ] T064c [US4] Gate: before any deletion task (T065 onwards) or Linear clean-up (T069 onwards) starts, @ashleyshaw reviews every item in `checklists/destructive-changes.md` (CHK001–CHK038) and ticks it; stop if any item is unchecked or answered "no". Also confirm T063 has finished for every repository (FR-014, FR-016) -- [ ] T065 [US4] Extend `scripts/automation/label-consolidate.js` with a deletion dry run that fills `to_delete` in `.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json` with a full snapshot per label (name, colour, description, and the kind and number of each open and closed item) and a `migrate_to` for every label on open items, per `contracts/dry-run-and-drift-report-schema.md`; it leaves the Stage 3 `executed_at` as the record of Stage 3 and requires the Stage 4 `executed_at` to be null (depends on T064, T064c) +- [ ] T065 [US4] Extend `scripts/automation/label-consolidate.js` with a deletion dry run that fills `to_delete` in `.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json` with a full snapshot per label (name, colour, description, and the kind and number of each open and closed item) and a `migrate_to` for every label on open items, per `contracts/dry-run-and-drift-report-schema.md`; it leaves the Stage 3 `executed_at` as the record of Stage 3 and requires the Stage 4 `executed_at` to be null. For a private repository (decision of 2026-10-04) the file lives under `.private-evidence/dry-run/{repo}.json`, which is never committed, and its summary, approval and gate comment go to the private report repository only, never to this public repository or its gate issue. (depends on T064, T064c) - [ ] T065a [US4] In `scripts/automation/label-consolidate.js`, re-read each repository just before deleting: if its labels, or the items carrying any `to_delete` label, differ from `.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json` once this run's own completed `done` records are applied to it (so a resumed run does not treat its own earlier deletions as drift, and only differences beyond them are stale), or `labels.yml` on `develop` differs from `approved_set_commit`, mark the dry run stale, skip the repository, post the reason on the gate issue, and require a new dry run and approval (FR-023 points 3 and 4; dry-run rules 6 and 7 in `contracts/dry-run-and-drift-report-schema.md`). Add the stale-dry-run case and the resumed-run case (own `done` records applied, no false stale) to `scripts/automation/__tests__/label-consolidate.test.js` (depends on T065, T062c) **Private repositories (decision of 2026-10-04): the dry-run file and its gate-issue comment for a private repository are not committed or posted in this public repository; keep it under `.private-evidence/dry-run/` and report it only in a private repository.** -- [ ] T066 [US4] Post each repository's dry-run summary on the gate issue and record @ashleyshaw's per-repository approval (the comment author must be the `ashleyshaw` login, FR-016) in the `approval` block of `.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json`; repositories without approval are marked `skipped` (depends on T065) -- [ ] T067 [US4] From @ashleyshaw's session, run `scripts/automation/label-consolidate.js --apply --confirm-gate `, which migrates open issues, PRs and Discussions, migrates closed items for labels that have a mapping target (FR-016), and deletes the listed labels only in repositories whose `evidence/dry-run/{repo}.json` approval is `approved` and refuses otherwise; it sets the Stage 4 `executed_at` as each repository finishes; `destructive_cleanup.enabled` in `.github/label-governance-policy.yml` stays `false`; then run quickstart Test 12 (depends on T066, T065a) -- [ ] T067a [US4] Verify SC-011 and SC-012 after T067: every issue and PR that had a `type:*` label in the T063 pre-run snapshot has exactly one now, and every deleted label has a snapshot entry with name, colour, description and item kinds and numbers in its `.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json`; record both results in `.github/reports/audits/2026-09-14-label-audit/evidence/type-label-check.json` and on the gate issue (the SC-011 and SC-012 checks in quickstart Test 12) (depends on T067) +- [ ] T066 [US4] For each public repository, post its dry-run summary on the gate issue and record @ashleyshaw's per-repository approval (the comment author must be the `ashleyshaw` login, FR-016) in the `approval` block of `.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json`. For each private repository, post the summary and take the approval in the private report repository (`PRIVATE_REPORT_REPO`) instead and record it in the `approval` block of `.private-evidence/dry-run/{repo}.json`; nothing about a private repository is posted on the public gate issue or committed here. Repositories without approval are marked `skipped` (depends on T065) +- [ ] T067 [US4] From @ashleyshaw's session, run `scripts/automation/label-consolidate.js --apply --confirm-gate `, which migrates open issues, PRs and Discussions, migrates closed items for labels that have a mapping target (FR-016), and deletes the listed labels only in repositories whose approval is `approved` and refuses otherwise (public repositories read `evidence/dry-run/{repo}.json`, private repositories read `.private-evidence/dry-run/{repo}.json`, and a private repository with no such file is refused, never read from the public path); it sets the Stage 4 `executed_at` as each repository finishes; `destructive_cleanup.enabled` in `.github/label-governance-policy.yml` stays `false`; then run quickstart Test 12 (depends on T066, T065a) +- [ ] T067a [US4] Verify SC-011 and SC-012 after T067: every issue and PR that had a `type:*` label in the T063 pre-run snapshot has exactly one now, and every deleted label has a snapshot entry with name, colour, description and item kinds and numbers in its dry-run file (`.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json` for a public repository, `.private-evidence/dry-run/{repo}.json` for a private one, which is checked and reported privately only); record both results in `.github/reports/audits/2026-09-14-label-audit/evidence/type-label-check.json` and on the gate issue (the SC-011 and SC-012 checks in quickstart Test 12) (depends on T067) - [ ] T068 [P] [US4] Update the organisation's default repository labels to match `.github/labels.yml` (API if available, otherwise organisation settings), recording the method used on the gate issue and in `.github/reports/audits/2026-09-14-label-audit/evidence/change-requests.json` (research R9) (depends on T059) ### Stage 5: Linear Clean-up (FR-012, FR-015, FR-017) From e586b8327c96169c0e00ec1992369237230db184 Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Mon, 5 Oct 2026 15:13:46 +0200 Subject: [PATCH 13/22] Fail the locked files guard closed and read it from the default branch; share the enrichment budget; keep issue references in body order --- .github/workflows/locked-files-guard.yml | 18 ++++++++++++------ scripts/automation/label-drift-check.js | 15 +++++++++++++-- .../check-locked-file-approval.test.js | 2 +- .../validation/check-locked-file-approval.cjs | 11 +++++++---- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/.github/workflows/locked-files-guard.yml b/.github/workflows/locked-files-guard.yml index d251b84bec..0bc192e51d 100644 --- a/.github/workflows/locked-files-guard.yml +++ b/.github/workflows/locked-files-guard.yml @@ -7,7 +7,7 @@ # `approved` in the spec 008 change-request register or carrying an # "Approved…" comment by @ashleyshaw (spec 008, task T080). # -# The guard script and the register are read from the BASE branch, so a pull +# The guard script and the register are read from the DEFAULT branch, so a pull # request cannot approve itself by editing them. Make this check required in # the branch protection rules for `develop` and `main`. # @@ -15,8 +15,8 @@ # default branch rather than from the pull request's merge commit; a pull # request that changes both a locked file and this workflow therefore cannot # swap the guard step. That is safe only because the job never checks out or -# runs pull request code: it checks out the base commit and reads the changed -# files through the API. Keep it that way, and keep this workflow on `develop` +# runs pull request code: it checks out only the guard and register from the +# default branch and reads the changed files through the API. Keep it that way, and keep this workflow on `develop` # before requiring the check. It does not run on the pull request that first # adds it, and it does not replace Code Owner review. # @@ -52,10 +52,13 @@ jobs: pull-requests: read issues: read steps: - - name: Check out the base branch (guard and register only) + # The guard and register come from the default branch, the same place this + # workflow definition was loaded from, so a pull request to `main` is + # checked by the guard on `develop` even before `main` has a copy of it. + - name: Check out the default branch (guard and register only) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.pull_request.base.sha }} + ref: ${{ github.event.repository.default_branch }} fetch-depth: 1 persist-credentials: false sparse-checkout: | @@ -70,8 +73,11 @@ jobs: const fs = require('fs'); const path = require('path'); const guardPath = path.join(process.env.GITHUB_WORKSPACE, 'scripts/validation/check-locked-file-approval.cjs'); + // This workflow is only ever loaded from the default branch, which + // carries the guard in the same change, so a missing guard means + // it was removed. Fail closed rather than let every pull request pass. if (!fs.existsSync(guardPath)) { - core.notice('The locked-file guard is not on the base branch yet; skipping this run.'); + core.setFailed('The locked-file guard script is missing from the default branch; refusing to pass without checking.'); return; } const guard = require(guardPath); diff --git a/scripts/automation/label-drift-check.js b/scripts/automation/label-drift-check.js index 8b4d6b11a5..1c95c464a8 100644 --- a/scripts/automation/label-drift-check.js +++ b/scripts/automation/label-drift-check.js @@ -737,8 +737,19 @@ async function main() { const privateSink = await resolvePrivateSink(process.env); // Enrich GitHub rows with issue/PR counts, paced for the search rate limit. - const { uncounted: uncountedRows } = await enrichGithubRows(client, githubRows); - if (privateSink) await enrichGithubRows(client, privateRows); + // One search budget covers both passes: each paced search takes about 2.2 + // seconds, so a second full budget could overrun the job timeout and lose + // both reports. A dry run never writes the private report, so it skips the + // private pass entirely. + const { counted: countedRows, uncounted: uncountedRows } = await enrichGithubRows( + client, + githubRows + ); + if (privateSink && !dryRun) { + await enrichGithubRows(client, privateRows, { + maxRows: Math.max(MAX_COUNTED_ROWS - countedRows, 0), + }); + } let firstSeen = new Map(); if (!dryRun) { diff --git a/scripts/validation/__tests__/check-locked-file-approval.test.js b/scripts/validation/__tests__/check-locked-file-approval.test.js index 6a6a6b5fe8..ccb476b4e1 100644 --- a/scripts/validation/__tests__/check-locked-file-approval.test.js +++ b/scripts/validation/__tests__/check-locked-file-approval.test.js @@ -56,7 +56,7 @@ describe('check-locked-file-approval', () => { 'Not this: `#999`, , https://github.com/other/repo/issues/997, a#1', 'Part of #449 and #3729 again', ].join('\n'); - expect(referencedIssues(body)).toEqual([3729, 449, 3556]); + expect(referencedIssues(body)).toEqual([3729, 3556, 449]); }); test('reads approved requests from the register and tolerates a missing one', () => { diff --git a/scripts/validation/check-locked-file-approval.cjs b/scripts/validation/check-locked-file-approval.cjs index a7ae0b1e4f..3ca02089b0 100644 --- a/scripts/validation/check-locked-file-approval.cjs +++ b/scripts/validation/check-locked-file-approval.cjs @@ -58,16 +58,19 @@ function referencedIssues(body, repo = 'lightspeedwp/.github') { const text = String(body || '') .replace(//g, ' ') .replace(/`[^`]*`/g, ' '); - const numbers = []; + // Bare references and issue URLs are collected with their positions, so the + // result follows the order they appear in the body. + const found = []; for (const match of text.matchAll(/(?:^|[^\w&/])#(\d+)\b/g)) { - numbers.push(Number(match[1])); + found.push({ at: match.index + match[0].lastIndexOf('#'), number: Number(match[1]) }); } const escaped = repo.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const url = new RegExp(`https://github\\.com/${escaped}/issues/(\\d+)\\b`, 'g'); for (const match of text.matchAll(url)) { - numbers.push(Number(match[1])); + found.push({ at: match.index, number: Number(match[1]) }); } - return [...new Set(numbers)]; + found.sort((a, b) => a.at - b.at); + return [...new Set(found.map((entry) => entry.number))]; } /** From 32eaf03d2275f941e88862dfbca4fb3caf707d85 Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Mon, 5 Oct 2026 15:45:25 +0200 Subject: [PATCH 14/22] Extract the shared search budget into enrichAllGithubRows and test it --- .../__tests__/label-drift-check.test.js | 90 +++++++++++++++++++ scripts/automation/label-drift-check.js | 48 +++++++--- 2 files changed, 125 insertions(+), 13 deletions(-) diff --git a/scripts/automation/__tests__/label-drift-check.test.js b/scripts/automation/__tests__/label-drift-check.test.js index e6175ffa40..c05e41bc44 100644 --- a/scripts/automation/__tests__/label-drift-check.test.js +++ b/scripts/automation/__tests__/label-drift-check.test.js @@ -22,6 +22,7 @@ import { diffGithubRepo, diffLinearLabels, countGithubItems, + enrichAllGithubRows, enrichGithubRows, parseFirstSeen, parseFirstSeenBlock, @@ -301,6 +302,95 @@ describe('label-drift-check', () => { expect(await countGithubItems(client, 'o/r', 'a')).toBe(3); }); + describe('one search budget for the public and private passes', () => { + const makeClient = () => { + const queries = []; + return { + queries, + rest: { + search: { + issuesAndPullRequests: async ({ q }) => { + queries.push(q); + return { data: { total_count: 1 } }; + }, + }, + }, + }; + }; + const rowsFor = (repo, count) => + Array.from({ length: count }, (_, i) => ({ + location: repo, + label: `label-${i}`, + difference: 'unapproved', + })); + + it('gives the private pass only what the public pass left unused', async () => { + const client = makeClient(); + const publicRows = rowsFor('o/public', 3); + const privateRows = rowsFor('o/private', 5); + const result = await enrichAllGithubRows(client, publicRows, privateRows, { + includePrivate: true, + maxRows: 5, + paceMs: 0, + }); + expect(result).toEqual({ counted: 3, uncounted: 0, privateCounted: 2 }); + expect(client.queries).toHaveLength(5); + expect(privateRows.filter((row) => row.items === 1)).toHaveLength(2); + expect(privateRows.filter((row) => row.items === '')).toHaveLength(3); + }); + + it('never exceeds the shared cap across both passes', async () => { + const client = makeClient(); + await enrichAllGithubRows(client, rowsFor('o/public', 4), rowsFor('o/private', 4), { + includePrivate: true, + maxRows: 6, + paceMs: 0, + }); + expect(client.queries).toHaveLength(6); + }); + + it('makes no private search when the public pass used the whole budget', async () => { + const client = makeClient(); + const privateRows = rowsFor('o/private', 3); + const result = await enrichAllGithubRows(client, rowsFor('o/public', 4), privateRows, { + includePrivate: true, + maxRows: 4, + paceMs: 0, + }); + expect(result.privateCounted).toBe(0); + expect(client.queries.some((q) => q.includes('o/private'))).toBe(false); + expect(privateRows.every((row) => row.items === '')).toBe(true); + }); + + it('skips the private pass entirely when it is not included, as in a dry run', async () => { + const client = makeClient(); + const privateRows = rowsFor('o/private', 3); + const result = await enrichAllGithubRows(client, rowsFor('o/public', 2), privateRows, { + includePrivate: false, + maxRows: 10, + paceMs: 0, + }); + expect(result).toEqual({ counted: 2, uncounted: 0, privateCounted: 0 }); + expect(client.queries.every((q) => q.includes('o/public'))).toBe(true); + expect(privateRows.every((row) => row.items === undefined)).toBe(true); + }); + + it('does not spend budget on missing rows', async () => { + const client = makeClient(); + const publicRows = [ + ...rowsFor('o/public', 1), + { location: 'o/public', label: 'gone', difference: 'missing' }, + ]; + const result = await enrichAllGithubRows(client, publicRows, rowsFor('o/private', 2), { + includePrivate: true, + maxRows: 2, + paceMs: 0, + }); + expect(result.counted).toBe(1); + expect(result.privateCounted).toBe(1); + }); + }); + it('persists first-seen dates for rows omitted from the tables', () => { const rows = Array.from({ length: 5 }, (_, i) => ({ location: 'o/r', diff --git a/scripts/automation/label-drift-check.js b/scripts/automation/label-drift-check.js index 1c95c464a8..e900d2508d 100644 --- a/scripts/automation/label-drift-check.js +++ b/scripts/automation/label-drift-check.js @@ -590,6 +590,38 @@ export async function upsertDriftIssue( return { issue: data, created: false }; } +/** + * Counts items for both row sets under one search budget. + * + * Each paced search takes about 2.2 seconds, so two full budgets could overrun + * the job timeout and lose both reports. The private pass gets only what the + * public pass left unused, and it is skipped unless `includePrivate` is set + * (a dry run never writes the private report, so it does not need the counts). + * + * @param {object} client - GitHub client + * @param {object[]} publicRows - Public drift rows, enriched in place + * @param {object[]} privateRows - Private drift rows, enriched in place + * @param {object} [options] - `includePrivate`, `maxRows` (shared cap), `paceMs` + * @returns {Promise<{ counted: number, uncounted: number, privateCounted: number }>} Public counts and private rows counted + */ +export async function enrichAllGithubRows(client, publicRows, privateRows, options = {}) { + const maxRows = options.maxRows ?? MAX_COUNTED_ROWS; + const paceMs = options.paceMs; + const publicPass = await enrichGithubRows(client, publicRows, { maxRows, paceMs }); + if (!options.includePrivate) { + return { counted: publicPass.counted, uncounted: publicPass.uncounted, privateCounted: 0 }; + } + const privatePass = await enrichGithubRows(client, privateRows, { + maxRows: Math.max(maxRows - publicPass.counted, 0), + paceMs, + }); + return { + counted: publicPass.counted, + uncounted: publicPass.uncounted, + privateCounted: privatePass.counted, + }; +} + /** * Enrich GitHub drift rows with issue/PR counts, paced for the search rate * limit. `missing` rows carry no items; rows past MAX_COUNTED_ROWS keep an @@ -737,19 +769,9 @@ async function main() { const privateSink = await resolvePrivateSink(process.env); // Enrich GitHub rows with issue/PR counts, paced for the search rate limit. - // One search budget covers both passes: each paced search takes about 2.2 - // seconds, so a second full budget could overrun the job timeout and lose - // both reports. A dry run never writes the private report, so it skips the - // private pass entirely. - const { counted: countedRows, uncounted: uncountedRows } = await enrichGithubRows( - client, - githubRows - ); - if (privateSink && !dryRun) { - await enrichGithubRows(client, privateRows, { - maxRows: Math.max(MAX_COUNTED_ROWS - countedRows, 0), - }); - } + const { uncounted: uncountedRows } = await enrichAllGithubRows(client, githubRows, privateRows, { + includePrivate: Boolean(privateSink) && !dryRun, + }); let firstSeen = new Map(); if (!dryRun) { From 0858a3fccb364be72b7db7f37555a2b1b51424c1 Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Mon, 5 Oct 2026 15:58:34 +0200 Subject: [PATCH 15/22] Scope the private report token to the owner in PRIVATE_REPORT_REPO and reject other owners --- .github/workflows/label-drift-check.yml | 21 +++++++- .../label-drift-check-config.test.js | 51 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/.github/workflows/label-drift-check.yml b/.github/workflows/label-drift-check.yml index 7de0336481..5e61af2f4d 100644 --- a/.github/workflows/label-drift-check.yml +++ b/.github/workflows/label-drift-check.yml @@ -99,7 +99,24 @@ jobs: env: PRIVATE_REPORT_REPO: ${{ vars.PRIVATE_REPORT_REPO }} run: | - echo "name=${PRIVATE_REPORT_REPO#*/}" >> "$GITHUB_OUTPUT" + if [ -z "$PRIVATE_REPORT_REPO" ]; then + exit 0 + fi + # The App is installed on lightspeedwp only, so a repository under any + # other owner could never be reached; stop here with a clear message + # instead of failing later at the mint step or the first write. + if ! [[ "$PRIVATE_REPORT_REPO" =~ ^([A-Za-z0-9-]+)/([A-Za-z0-9._-]+)$ ]]; then + echo "::error::PRIVATE_REPORT_REPO must be owner/repository" + exit 1 + fi + if [ "${BASH_REMATCH[1]}" != "lightspeedwp" ]; then + echo "::error::PRIVATE_REPORT_REPO must be a lightspeedwp repository" + exit 1 + fi + { + echo "owner=${BASH_REMATCH[1]}" + echo "name=${BASH_REMATCH[2]}" + } >> "$GITHUB_OUTPUT" - name: Mint private report App token # Write-scoped to the private report repository only, using the same @@ -110,7 +127,7 @@ jobs: with: app-id: ${{ vars.LS_APP_ID }} private-key: ${{ secrets.LS_APP_PRIVATE_KEY }} - owner: lightspeedwp + owner: ${{ steps.private-report.outputs.owner }} repositories: ${{ steps.private-report.outputs.name }} permission-issues: write diff --git a/scripts/automation/__tests__/label-drift-check-config.test.js b/scripts/automation/__tests__/label-drift-check-config.test.js index c2b8bf4e4e..a698fa7b65 100644 --- a/scripts/automation/__tests__/label-drift-check-config.test.js +++ b/scripts/automation/__tests__/label-drift-check-config.test.js @@ -16,7 +16,9 @@ */ const fs = require('fs'); +const os = require('os'); const path = require('path'); +const { spawnSync } = require('child_process'); const yaml = require('js-yaml'); const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); @@ -113,12 +115,61 @@ describe('label drift check wiring', () => { expect(privateMint).toBeDefined(); expect(privateMint.if).toBe("steps.private-report.outputs.name != ''"); expect(String(privateMint.with.repositories)).toBe('${{ steps.private-report.outputs.name }}'); + // The owner comes from the variable too, not a hard-coded value. + expect(String(privateMint.with.owner)).toBe('${{ steps.private-report.outputs.owner }}'); expect(String(privateMint.with['permission-issues'])).toBe('write'); expect(Object.keys(privateMint.with).filter((key) => key.startsWith('permission-'))).toEqual([ 'permission-issues', ]); }); + describe('resolving the private report repository', () => { + const run = (value) => { + const step = Object.values(loadWorkflow().jobs) + .flatMap((job) => job.steps || []) + .find((candidate) => candidate.id === 'private-report'); + const output = path.join(os.tmpdir(), `drift-resolve-${process.pid}-${Math.random()}`); + fs.writeFileSync(output, ''); + const result = spawnSync('bash', ['-c', step.run], { + encoding: 'utf8', + env: { PATH: process.env.PATH, PRIVATE_REPORT_REPO: value, GITHUB_OUTPUT: output }, + }); + const written = fs.readFileSync(output, 'utf8'); + fs.rmSync(output, { force: true }); + return { status: result.status, stdout: result.stdout, written }; + }; + + it('outputs the owner and the name of a lightspeedwp repository', () => { + expect(run('lightspeedwp/private-reports')).toMatchObject({ + status: 0, + written: 'owner=lightspeedwp\nname=private-reports\n', + }); + }); + + it('does nothing when the variable is unset, so the private detail is withheld', () => { + expect(run('')).toMatchObject({ status: 0, written: '' }); + }); + + it('rejects a repository under another owner before any token is minted', () => { + const result = run('other-org/reports'); + expect(result.status).toBe(1); + expect(result.stdout).toContain('must be a lightspeedwp repository'); + expect(result.written).toBe(''); + }); + + it.each([ + 'reports', + 'lightspeedwp/', + '/reports', + 'lightspeedwp/a/b', + 'lightspeedwp/x\nowner=evil', + ])('rejects a malformed value (%j) without writing outputs', (value) => { + const result = run(value); + expect(result.status).toBe(1); + expect(result.written).toBe(''); + }); + }); + it('takes the private report repository from a variable and stores no token for it', () => { const raw = workflowText(); expect(raw).toContain('vars.PRIVATE_REPORT_REPO'); From 82be6104d8fdc0b805f7b3773ef8d58000695b88 Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Mon, 5 Oct 2026 16:18:38 +0200 Subject: [PATCH 16/22] Make T080a a repository admin step with the exact Actions policy call --- .github/specs/008-label-audit-consolidation/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/specs/008-label-audit-consolidation/tasks.md b/.github/specs/008-label-audit-consolidation/tasks.md index f057104e5f..f4461591b8 100644 --- a/.github/specs/008-label-audit-consolidation/tasks.md +++ b/.github/specs/008-label-audit-consolidation/tasks.md @@ -591,7 +591,7 @@ The active labelling agent removes labels outside `labels.yml` today, so this ru ## Phase 10: Convergence - [ ] T080 Add a required pull request check, `.github/workflows/locked-files-guard.yml` running a new `scripts/validation/check-locked-file-approval.cjs` (with tests in `scripts/validation/__tests__/`), that fails when a PR changes a constitution v1.4.0 LOCKED file (`.github/labels.yml`, `.github/issue-types.yml`, `.github/ISSUE_TEMPLATE/*.md`, `.github/PULL_REQUEST_TEMPLATE/*.md`, `.github/PULL_REQUEST_TEMPLATE/config.yml`, `.github/branch-types.yml`, `.github/branch-labels.yml`) unless the PR body links a change-request issue whose entry in `.github/reports/audits/2026-09-14-label-audit/evidence/change-requests.json` (or, outside spec 008, a dated approval comment by `ashleyshaw` on that issue) shows `status: "approved"`; also give those paths explicit `CODEOWNERS` lines owned by `@ashleyshaw` only, so a team member's approval cannot stand in for the approver's (the gap that let #3534 merge) per Constitution II, plan: Constitution Check remediation (partial) — implemented in #3734, which is still open (guard, tests and `CODEOWNERS`; approvals count only on issues tagged `[LABEL-UPDATE-REQUEST]`, `[ISSUE-TYPE-UPDATE-REQUEST]` or `[TEMPLATE-UPDATE-REQUEST]`, per the 2026-10-02 clarification); it checks only that an approved request is linked and does not compare which locked files that request covers, which T084 adds; the branch-protection settings that make it enforce are T080a -- [ ] T080a (@ashleyshaw, manual; after #3734 merges) In the branch protection rules for `develop` and `main`, make "Locked files need an approved change request" a required check and turn on "Require review from Code Owners"; then open a test PR that changes `.github/labels.yml` with no linked change request, confirm the check fails and the PR cannot merge, close it, and record the settings on #3733; also, before 2 November 2026, allow `pull_request_target` for `.github/workflows/locked-files-guard.yml` in an Actions event policy (repository Settings → Actions → Policies, or `POST /repos/lightspeedwp/.github/actions/policies`): from that date GitHub blocks `pull_request_target` by default in public repositories that have no event policy, and this repository has none today (`GET /repos/lightspeedwp/.github/actions/policies` returns zero), so the required check would never report; must be done before the Stage 2 configuration PR (T059) per Constitution II, FR-009 (partial) +- [ ] T080a (repository admin, manual; no further sign-off is needed, as the decision is recorded for 2026-10-04; after #3734 merges) In the branch protection rules for `develop` and `main`, make "Locked files need an approved change request" a required check and turn on "Require review from Code Owners"; then open a test PR that changes `.github/labels.yml` with no linked change request, confirm the check fails and the PR cannot merge, close it, and record the settings on #3733; also, before 2 November 2026, allow `pull_request_target` for `.github/workflows/locked-files-guard.yml` and for no other workflow, in a repository Actions event policy (Settings → Actions → Policies, or one API call: `gh api -X POST repos/lightspeedwp/.github/actions/policies -H "X-GitHub-Api-Version: 2026-03-10" --input policy.json` with `{"name":"Allow pull_request_target for the locked files guard","enforcement":"active","conditions":{"workflow_path":{"include":[".github/workflows/locked-files-guard.yml"],"exclude":[]}},"rules":[{"type":"restrict_action_events","parameters":{"allowed_events":["pull_request_target"]}}]}`, then check `GET /repos/lightspeedwp/.github/actions/policies` and the policy insights page for blocked runs; the request body follows the GitHub REST reference for Actions policies, and `evaluate` mode is Enterprise Cloud only): from that date GitHub blocks `pull_request_target` by default in public repositories that have no event policy, and this repository has none today (`GET /repos/lightspeedwp/.github/actions/policies` returns zero), so the required check would never report; must be done before the Stage 2 configuration PR (T059) per Constitution II, FR-009 (partial) - [ ] T081 Extend `scripts/validation/validate-labeling-configs.cjs` (and its tests) to enforce the constitution v1.4.0 routing rules: every branch type in `.github/branch-types.yml` has exactly one `type:*` label in `.github/branch-labels.yml` `default_labels`, equal to the canonical type label in its routed template's frontmatter (mapping `type:documentation` → `type:docs` and `type:ai-ops` → `type:aiops` until T046b is applied), and every route in `.github/PULL_REQUEST_TEMPLATE/config.yml` matches `branch-types.yml`; run it after T074 and T076 so it starts green (depends on T074, T076; #3732 has merged) per Constitution v1.4.0 routing, plan: R23 (partial) - [ ] T084 Extend the T080 guard so an approval authorises only the locked files its request names: add a `covers` list (paths or patterns) to each request entry in `.github/reports/audits/2026-09-14-label-audit/evidence/change-requests.json`, and make `scripts/validation/check-locked-file-approval.cjs` fail when any locked file the PR changes is not matched by the `covers` list of an approved linked request; add tests in `scripts/validation/__tests__/check-locked-file-approval.test.js` showing that an approval covering file A does not pass a PR that changes file B, that a PR changing A and B passes only when both are covered, and that the existing passing cases still pass; whether a change within a covered file matches what was approved stays the approver's review through the `CODEOWNERS` line (depends on T080) per FR-009, Constitution II - [ ] T082 [US4] Before T043 fills `mappings[]`, re-read every Linear label with the Linear MCP integration (`list_issue_labels` for the workspace and for every team, archived included) and record in `.github/reports/audits/2026-09-14-label-audit/evidence/linear-labels.json` how many `migrate:*` labels remain active, with their scope and issue counts; exclude every `migrate:*` label from the FR-012 import, the mapping and any sync back to GitHub; list any that are still active (on 2026-10-02 the GIT team still listed about 30) on the gate issue for @ashleyshaw to retire; and check each valid Linear label against `.github/labels.yml` on `develop`, so the T043 import list holds only labels that are used or required by automation and are not yet in `labels.yml` per FR-012, FR-006 (partial) From 303282073ba39c73bbc04ecc2dfaab105fc5a326 Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Mon, 5 Oct 2026 16:23:00 +0200 Subject: [PATCH 17/22] Record that the pull_request_target Actions policy exists --- .github/specs/008-label-audit-consolidation/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/specs/008-label-audit-consolidation/tasks.md b/.github/specs/008-label-audit-consolidation/tasks.md index f4461591b8..aa2c49c60e 100644 --- a/.github/specs/008-label-audit-consolidation/tasks.md +++ b/.github/specs/008-label-audit-consolidation/tasks.md @@ -591,7 +591,7 @@ The active labelling agent removes labels outside `labels.yml` today, so this ru ## Phase 10: Convergence - [ ] T080 Add a required pull request check, `.github/workflows/locked-files-guard.yml` running a new `scripts/validation/check-locked-file-approval.cjs` (with tests in `scripts/validation/__tests__/`), that fails when a PR changes a constitution v1.4.0 LOCKED file (`.github/labels.yml`, `.github/issue-types.yml`, `.github/ISSUE_TEMPLATE/*.md`, `.github/PULL_REQUEST_TEMPLATE/*.md`, `.github/PULL_REQUEST_TEMPLATE/config.yml`, `.github/branch-types.yml`, `.github/branch-labels.yml`) unless the PR body links a change-request issue whose entry in `.github/reports/audits/2026-09-14-label-audit/evidence/change-requests.json` (or, outside spec 008, a dated approval comment by `ashleyshaw` on that issue) shows `status: "approved"`; also give those paths explicit `CODEOWNERS` lines owned by `@ashleyshaw` only, so a team member's approval cannot stand in for the approver's (the gap that let #3534 merge) per Constitution II, plan: Constitution Check remediation (partial) — implemented in #3734, which is still open (guard, tests and `CODEOWNERS`; approvals count only on issues tagged `[LABEL-UPDATE-REQUEST]`, `[ISSUE-TYPE-UPDATE-REQUEST]` or `[TEMPLATE-UPDATE-REQUEST]`, per the 2026-10-02 clarification); it checks only that an approved request is linked and does not compare which locked files that request covers, which T084 adds; the branch-protection settings that make it enforce are T080a -- [ ] T080a (repository admin, manual; no further sign-off is needed, as the decision is recorded for 2026-10-04; after #3734 merges) In the branch protection rules for `develop` and `main`, make "Locked files need an approved change request" a required check and turn on "Require review from Code Owners"; then open a test PR that changes `.github/labels.yml` with no linked change request, confirm the check fails and the PR cannot merge, close it, and record the settings on #3733; also, before 2 November 2026, allow `pull_request_target` for `.github/workflows/locked-files-guard.yml` and for no other workflow, in a repository Actions event policy (Settings → Actions → Policies, or one API call: `gh api -X POST repos/lightspeedwp/.github/actions/policies -H "X-GitHub-Api-Version: 2026-03-10" --input policy.json` with `{"name":"Allow pull_request_target for the locked files guard","enforcement":"active","conditions":{"workflow_path":{"include":[".github/workflows/locked-files-guard.yml"],"exclude":[]}},"rules":[{"type":"restrict_action_events","parameters":{"allowed_events":["pull_request_target"]}}]}`, then check `GET /repos/lightspeedwp/.github/actions/policies` and the policy insights page for blocked runs; the request body follows the GitHub REST reference for Actions policies, and `evaluate` mode is Enterprise Cloud only): from that date GitHub blocks `pull_request_target` by default in public repositories that have no event policy, and this repository has none today (`GET /repos/lightspeedwp/.github/actions/policies` returns zero), so the required check would never report; must be done before the Stage 2 configuration PR (T059) per Constitution II, FR-009 (partial) +- [ ] T080a (repository admin, manual; no further sign-off is needed, as the decision is recorded for 2026-10-04; after #3734 merges) In the branch protection rules for `develop` and `main`, make "Locked files need an approved change request" a required check and turn on "Require review from Code Owners"; then open a test PR that changes `.github/labels.yml` with no linked change request, confirm the check fails and the PR cannot merge, close it, and record the settings on #3733; also, before 2 November 2026 (done 2026-10-05: policy 6525 exists, scoped to this workflow only and read back to confirm), allow `pull_request_target` for `.github/workflows/locked-files-guard.yml` and for no other workflow, in a repository Actions event policy (Settings → Actions → Policies, or one API call: `gh api -X POST repos/lightspeedwp/.github/actions/policies -H "X-GitHub-Api-Version: 2026-03-10" --input policy.json` with `{"name":"Allow pull_request_target for the locked files guard","enforcement":"active","conditions":{"workflow_path":{"include":[".github/workflows/locked-files-guard.yml"],"exclude":[]}},"rules":[{"type":"restrict_action_events","parameters":{"allowed_events":["pull_request_target"]}}]}`, then check `GET /repos/lightspeedwp/.github/actions/policies` and the policy insights page for blocked runs; the request body follows the GitHub REST reference for Actions policies, and `evaluate` mode is Enterprise Cloud only): from that date GitHub blocks `pull_request_target` by default in public repositories that have no event policy, and this repository has none today (`GET /repos/lightspeedwp/.github/actions/policies` returns zero), so the required check would never report; must be done before the Stage 2 configuration PR (T059) per Constitution II, FR-009 (partial) - [ ] T081 Extend `scripts/validation/validate-labeling-configs.cjs` (and its tests) to enforce the constitution v1.4.0 routing rules: every branch type in `.github/branch-types.yml` has exactly one `type:*` label in `.github/branch-labels.yml` `default_labels`, equal to the canonical type label in its routed template's frontmatter (mapping `type:documentation` → `type:docs` and `type:ai-ops` → `type:aiops` until T046b is applied), and every route in `.github/PULL_REQUEST_TEMPLATE/config.yml` matches `branch-types.yml`; run it after T074 and T076 so it starts green (depends on T074, T076; #3732 has merged) per Constitution v1.4.0 routing, plan: R23 (partial) - [ ] T084 Extend the T080 guard so an approval authorises only the locked files its request names: add a `covers` list (paths or patterns) to each request entry in `.github/reports/audits/2026-09-14-label-audit/evidence/change-requests.json`, and make `scripts/validation/check-locked-file-approval.cjs` fail when any locked file the PR changes is not matched by the `covers` list of an approved linked request; add tests in `scripts/validation/__tests__/check-locked-file-approval.test.js` showing that an approval covering file A does not pass a PR that changes file B, that a PR changing A and B passes only when both are covered, and that the existing passing cases still pass; whether a change within a covered file matches what was approved stays the approver's review through the `CODEOWNERS` line (depends on T080) per FR-009, Constitution II - [ ] T082 [US4] Before T043 fills `mappings[]`, re-read every Linear label with the Linear MCP integration (`list_issue_labels` for the workspace and for every team, archived included) and record in `.github/reports/audits/2026-09-14-label-audit/evidence/linear-labels.json` how many `migrate:*` labels remain active, with their scope and issue counts; exclude every `migrate:*` label from the FR-012 import, the mapping and any sync back to GitHub; list any that are still active (on 2026-10-02 the GIT team still listed about 30) on the gate issue for @ashleyshaw to retire; and check each valid Linear label against `.github/labels.yml` on `develop`, so the T043 import list holds only labels that are used or required by automation and are not yet in `labels.yml` per FR-012, FR-006 (partial) From ea1774f4f6a4baf7a3f7e0a821295ae6a9ff01b6 Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Mon, 5 Oct 2026 17:29:36 +0200 Subject: [PATCH 18/22] Name failed issue lookups in the guard failure; require exactly two segments in PRIVATE_REPORT_REPO --- .github/workflows/locked-files-guard.yml | 3 ++ .../__tests__/label-drift-check.test.js | 7 ++++ scripts/automation/label-drift-check.js | 7 +++- .../check-locked-file-approval.test.js | 37 +++++++++++++++++++ .../validation/check-locked-file-approval.cjs | 22 ++++++++++- 5 files changed, 72 insertions(+), 4 deletions(-) diff --git a/.github/workflows/locked-files-guard.yml b/.github/workflows/locked-files-guard.yml index 0bc192e51d..5cff0b6bcf 100644 --- a/.github/workflows/locked-files-guard.yml +++ b/.github/workflows/locked-files-guard.yml @@ -112,6 +112,7 @@ jobs: const commentsByIssue = {}; const titlesByIssue = {}; + const lookupFailures = []; // Fetch every referenced issue: guard.evaluate processes all of // them, and truncating here would fail a valid approval linked // after other references. Fetching happens only when locked files @@ -138,6 +139,7 @@ jobs: }); } catch (error) { core.warning(`Could not read comments on #${issue}: ${error.message}`); + lookupFailures.push({ issue, message: error.message }); } } } @@ -148,6 +150,7 @@ jobs: register, commentsByIssue, titlesByIssue, + lookupFailures, }); await core.summary.addHeading('Locked Files Guard', 3).addRaw(result.message).write(); if (result.ok) { diff --git a/scripts/automation/__tests__/label-drift-check.test.js b/scripts/automation/__tests__/label-drift-check.test.js index c05e41bc44..f6af6585c9 100644 --- a/scripts/automation/__tests__/label-drift-check.test.js +++ b/scripts/automation/__tests__/label-drift-check.test.js @@ -594,6 +594,13 @@ describe('label-drift-check', () => { await expect(resolvePrivateSink({ PRIVATE_REPORT_REPO: 'bad' })).rejects.toThrow( /owner\/repository/ ); + // Extra segments would be dropped silently, and the privacy check would run + // against a different repository than the one configured. + for (const value of ['org/repo/extra', 'org/repo/', '/repo', 'org/']) { + await expect(resolvePrivateSink({ PRIVATE_REPORT_REPO: value })).rejects.toThrow( + /owner\/repository/ + ); + } await expect(resolvePrivateSink({ PRIVATE_REPORT_REPO: 'o/r' })).rejects.toThrow( /PRIVATE_REPORT_TOKEN/ ); diff --git a/scripts/automation/label-drift-check.js b/scripts/automation/label-drift-check.js index e900d2508d..ccbbd27dc1 100644 --- a/scripts/automation/label-drift-check.js +++ b/scripts/automation/label-drift-check.js @@ -682,8 +682,11 @@ export function maskCommands(inventory, env = process.env) { export async function resolvePrivateSink(env, makeClient = null) { const target = env.PRIVATE_REPORT_REPO; if (!target) return null; - const [owner, repo] = target.split('/'); - if (!owner || !repo) { + const parts = target.split('/'); + const [owner, repo] = parts; + // Exactly two segments: extra ones would be dropped silently, and the privacy + // check would then run against a different repository than the one configured. + if (parts.length !== 2 || !owner || !repo) { throw new Error('PRIVATE_REPORT_REPO must be owner/repository'); } const token = env.PRIVATE_REPORT_TOKEN; diff --git a/scripts/validation/__tests__/check-locked-file-approval.test.js b/scripts/validation/__tests__/check-locked-file-approval.test.js index ccb476b4e1..40cabca551 100644 --- a/scripts/validation/__tests__/check-locked-file-approval.test.js +++ b/scripts/validation/__tests__/check-locked-file-approval.test.js @@ -90,6 +90,43 @@ describe('check-locked-file-approval', () => { expect(result.message).toMatch(/no linked change request is approved/); }); + test('names a failed issue lookup in the failure, still failing closed', () => { + const result = evaluate({ + changedFiles: ['.github/labels.yml'], + body: 'Closes #4000', + register, + // #4000 could not be read, so it has no title and cannot count as approved. + titlesByIssue: {}, + lookupFailures: [{ issue: 4000, message: 'HTTP 502' }], + }); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/no linked change request is approved/); + expect(result.message).toMatch(/Could not read #4000 \(HTTP 502\)/); + expect(result.message).toMatch(/re-run the check/); + }); + + test('does not mention lookup failures when the request is approved anyway', () => { + const result = evaluate({ + changedFiles: ['.github/issue-types.yml'], + body: 'Closes #3556 and #4000', + register, + titlesByIssue, + lookupFailures: [{ issue: 4000, message: 'HTTP 502' }], + }); + expect(result.ok).toBe(true); + expect(result.message).not.toMatch(/Could not read/); + }); + + test('says nothing extra for an ordinary rejection with no lookup failure', () => { + const result = evaluate({ + changedFiles: ['.github/labels.yml'], + body: 'Relates to #3557', + register, + titlesByIssue, + }); + expect(result.message).not.toMatch(/Could not read/); + }); + test('passes a locked-file change linked to a request approved in the register', () => { const result = evaluate({ changedFiles: ['.github/issue-types.yml'], diff --git a/scripts/validation/check-locked-file-approval.cjs b/scripts/validation/check-locked-file-approval.cjs index 3ca02089b0..8b85233651 100644 --- a/scripts/validation/check-locked-file-approval.cjs +++ b/scripts/validation/check-locked-file-approval.cjs @@ -122,7 +122,14 @@ function isApprovalComment(comment) { * with no known title is not treated as a change request. * @returns {{locked: string[], issues: number[], approvedBy: Record, ok: boolean, message: string}} */ -function evaluate({ changedFiles, body, register, commentsByIssue = {}, titlesByIssue = {} }) { +function evaluate({ + changedFiles, + body, + register, + commentsByIssue = {}, + titlesByIssue = {}, + lookupFailures = [], +}) { const locked = changedFiles.filter(isLocked); const issues = referencedIssues(body); if (locked.length === 0) { @@ -152,7 +159,18 @@ function evaluate({ changedFiles, body, register, commentsByIssue = {}, titlesBy `Link the [LABEL-UPDATE-REQUEST], [ISSUE-TYPE-UPDATE-REQUEST] or [TEMPLATE-UPDATE-REQUEST] issue ` + `in the PR body, and get it approved by @${APPROVER} (constitution Principle II). ` + `Approvals on issues without one of those tags in the title do not count.`; - return { locked, issues, approvedBy, ok, message }; + // A failed lookup leaves that issue without a title, so it cannot be approved + // and the check still fails closed. Name it, so the author can tell a + // transient API error from a missing approval and re-run the check. + const retry = + !ok && lookupFailures.length > 0 + ? ` Could not read ${lookupFailures + .map(({ issue, message: reason }) => `#${issue} (${reason})`) + .join( + ', ' + )}; this may be a transient GitHub API error, so re-run the check before changing the approval.` + : ''; + return { locked, issues, approvedBy, ok, message: message + retry }; } module.exports = { From 39a3147af3835033fea7e5eca79da09a16ff67b9 Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Mon, 5 Oct 2026 18:39:30 +0200 Subject: [PATCH 19/22] Ignore references in fenced code; confine full inventory output to .private-evidence; fix T080a policy wording --- .../008-label-audit-consolidation/tasks.md | 2 +- .../__tests__/label-inventory.test.js | 50 ++++++++++++++++--- scripts/automation/label-inventory.js | 45 +++++++++++++---- .../check-locked-file-approval.test.js | 47 +++++++++++++++++ .../validation/check-locked-file-approval.cjs | 39 +++++++++++++-- 5 files changed, 161 insertions(+), 22 deletions(-) diff --git a/.github/specs/008-label-audit-consolidation/tasks.md b/.github/specs/008-label-audit-consolidation/tasks.md index aa2c49c60e..66c07c18d2 100644 --- a/.github/specs/008-label-audit-consolidation/tasks.md +++ b/.github/specs/008-label-audit-consolidation/tasks.md @@ -591,7 +591,7 @@ The active labelling agent removes labels outside `labels.yml` today, so this ru ## Phase 10: Convergence - [ ] T080 Add a required pull request check, `.github/workflows/locked-files-guard.yml` running a new `scripts/validation/check-locked-file-approval.cjs` (with tests in `scripts/validation/__tests__/`), that fails when a PR changes a constitution v1.4.0 LOCKED file (`.github/labels.yml`, `.github/issue-types.yml`, `.github/ISSUE_TEMPLATE/*.md`, `.github/PULL_REQUEST_TEMPLATE/*.md`, `.github/PULL_REQUEST_TEMPLATE/config.yml`, `.github/branch-types.yml`, `.github/branch-labels.yml`) unless the PR body links a change-request issue whose entry in `.github/reports/audits/2026-09-14-label-audit/evidence/change-requests.json` (or, outside spec 008, a dated approval comment by `ashleyshaw` on that issue) shows `status: "approved"`; also give those paths explicit `CODEOWNERS` lines owned by `@ashleyshaw` only, so a team member's approval cannot stand in for the approver's (the gap that let #3534 merge) per Constitution II, plan: Constitution Check remediation (partial) — implemented in #3734, which is still open (guard, tests and `CODEOWNERS`; approvals count only on issues tagged `[LABEL-UPDATE-REQUEST]`, `[ISSUE-TYPE-UPDATE-REQUEST]` or `[TEMPLATE-UPDATE-REQUEST]`, per the 2026-10-02 clarification); it checks only that an approved request is linked and does not compare which locked files that request covers, which T084 adds; the branch-protection settings that make it enforce are T080a -- [ ] T080a (repository admin, manual; no further sign-off is needed, as the decision is recorded for 2026-10-04; after #3734 merges) In the branch protection rules for `develop` and `main`, make "Locked files need an approved change request" a required check and turn on "Require review from Code Owners"; then open a test PR that changes `.github/labels.yml` with no linked change request, confirm the check fails and the PR cannot merge, close it, and record the settings on #3733; also, before 2 November 2026 (done 2026-10-05: policy 6525 exists, scoped to this workflow only and read back to confirm), allow `pull_request_target` for `.github/workflows/locked-files-guard.yml` and for no other workflow, in a repository Actions event policy (Settings → Actions → Policies, or one API call: `gh api -X POST repos/lightspeedwp/.github/actions/policies -H "X-GitHub-Api-Version: 2026-03-10" --input policy.json` with `{"name":"Allow pull_request_target for the locked files guard","enforcement":"active","conditions":{"workflow_path":{"include":[".github/workflows/locked-files-guard.yml"],"exclude":[]}},"rules":[{"type":"restrict_action_events","parameters":{"allowed_events":["pull_request_target"]}}]}`, then check `GET /repos/lightspeedwp/.github/actions/policies` and the policy insights page for blocked runs; the request body follows the GitHub REST reference for Actions policies, and `evaluate` mode is Enterprise Cloud only): from that date GitHub blocks `pull_request_target` by default in public repositories that have no event policy, and this repository has none today (`GET /repos/lightspeedwp/.github/actions/policies` returns zero), so the required check would never report; must be done before the Stage 2 configuration PR (T059) per Constitution II, FR-009 (partial) +- [ ] T080a (repository admin, manual; no further sign-off is needed, as the decision is recorded for 2026-10-04; after #3734 merges) In the branch protection rules for `develop` and `main`, make "Locked files need an approved change request" a required check and turn on "Require review from Code Owners"; then open a test PR that changes `.github/labels.yml` with no linked change request, confirm the check fails and the PR cannot merge, close it, and record the settings on #3733; first verify the Actions event policy that lets `.github/workflows/locked-files-guard.yml` use `pull_request_target`, which already exists (policy 6525, created and read back on 2026-10-05, scoped to that one workflow): `gh api -H "X-GitHub-Api-Version: 2026-03-10" repos/lightspeedwp/.github/actions/policies/6525` must show enforcement `active`, that workflow path only, and the `pull_request_target` event, and the policy insights page must show no blocked runs for the guard. From 2 November 2026 GitHub blocks `pull_request_target` by default in public repositories without such a policy, so without it the required check would never report; must be done before the Stage 2 configuration PR (T059) per Constitution II, FR-009 (partial) - [ ] T081 Extend `scripts/validation/validate-labeling-configs.cjs` (and its tests) to enforce the constitution v1.4.0 routing rules: every branch type in `.github/branch-types.yml` has exactly one `type:*` label in `.github/branch-labels.yml` `default_labels`, equal to the canonical type label in its routed template's frontmatter (mapping `type:documentation` → `type:docs` and `type:ai-ops` → `type:aiops` until T046b is applied), and every route in `.github/PULL_REQUEST_TEMPLATE/config.yml` matches `branch-types.yml`; run it after T074 and T076 so it starts green (depends on T074, T076; #3732 has merged) per Constitution v1.4.0 routing, plan: R23 (partial) - [ ] T084 Extend the T080 guard so an approval authorises only the locked files its request names: add a `covers` list (paths or patterns) to each request entry in `.github/reports/audits/2026-09-14-label-audit/evidence/change-requests.json`, and make `scripts/validation/check-locked-file-approval.cjs` fail when any locked file the PR changes is not matched by the `covers` list of an approved linked request; add tests in `scripts/validation/__tests__/check-locked-file-approval.test.js` showing that an approval covering file A does not pass a PR that changes file B, that a PR changing A and B passes only when both are covered, and that the existing passing cases still pass; whether a change within a covered file matches what was approved stays the approver's review through the `CODEOWNERS` line (depends on T080) per FR-009, Constitution II - [ ] T082 [US4] Before T043 fills `mappings[]`, re-read every Linear label with the Linear MCP integration (`list_issue_labels` for the workspace and for every team, archived included) and record in `.github/reports/audits/2026-09-14-label-audit/evidence/linear-labels.json` how many `migrate:*` labels remain active, with their scope and issue counts; exclude every `migrate:*` label from the FR-012 import, the mapping and any sync back to GitHub; list any that are still active (on 2026-10-02 the GIT team still listed about 30) on the gate issue for @ashleyshaw to retire; and check each valid Linear label against `.github/labels.yml` on `develop`, so the T043 import list holds only labels that are used or required by automation and are not yet in `labels.yml` per FR-012, FR-006 (partial) diff --git a/scripts/automation/__tests__/label-inventory.test.js b/scripts/automation/__tests__/label-inventory.test.js index 9b7890af77..32aa99d90b 100644 --- a/scripts/automation/__tests__/label-inventory.test.js +++ b/scripts/automation/__tests__/label-inventory.test.js @@ -3,6 +3,9 @@ * @module scripts/automation/__tests__/label-inventory.test.js */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { describe, it, expect } from '@jest/globals'; import { PER_PAGE, @@ -10,7 +13,7 @@ import { buildInventory, incompleteRepositories, incompleteRepositoryLabels, - isUnderTrackedGithubDirectory, + isInsidePublicRepository, privateRepositoryGap, publicRepositoryName, redactInventory, @@ -241,13 +244,46 @@ describe('label-inventory', () => { expect(labels.join(' ')).not.toContain('client-site'); }); - it('refuses a full-output path inside the tracked .github directory', () => { + it('refuses a full-output path anywhere inside the public repository except .private-evidence', () => { const cwd = '/repo'; - expect(isUnderTrackedGithubDirectory('.github/reports/full.json', cwd)).toBe(true); - expect(isUnderTrackedGithubDirectory('.github', cwd)).toBe(true); - expect(isUnderTrackedGithubDirectory('.private-evidence/full.json', cwd)).toBe(false); - expect(isUnderTrackedGithubDirectory('/tmp/full.json', cwd)).toBe(false); - expect(isUnderTrackedGithubDirectory('.githubx/full.json', cwd)).toBe(false); + for (const refused of [ + '.github/reports/full.json', + '.github', + 'full.json', + 'docs/full.json', + 'scripts/automation/full.json', + '.githubx/full.json', + '.private-evidencex/full.json', + '.', + 'docs/../full.json', + ]) { + expect(isInsidePublicRepository(refused, cwd)).toBe(true); + } + for (const allowed of [ + '.private-evidence/full.json', + '.private-evidence/nested/full.json', + '/tmp/full.json', + '../outside/full.json', + ]) { + expect(isInsidePublicRepository(allowed, cwd)).toBe(false); + } + }); + + it('is not fooled by a path that climbs out of .private-evidence or a symlink into the repository', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'inventory-root-')); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'inventory-outside-')); + try { + fs.mkdirSync(path.join(root, '.private-evidence')); + fs.mkdirSync(path.join(root, 'docs')); + expect(isInsidePublicRepository('.private-evidence/../docs/full.json', root)).toBe(true); + fs.symlinkSync(path.join(root, 'docs'), path.join(outside, 'link')); + expect(isInsidePublicRepository(path.join(outside, 'link', 'full.json'), root)).toBe(true); + fs.symlinkSync(outside, path.join(root, '.private-evidence', 'out')); + expect(isInsidePublicRepository('.private-evidence/out/full.json', root)).toBe(false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + } }); }); }); diff --git a/scripts/automation/label-inventory.js b/scripts/automation/label-inventory.js index 1bd41f3df7..580f94b335 100644 --- a/scripts/automation/label-inventory.js +++ b/scripts/automation/label-inventory.js @@ -19,7 +19,8 @@ * clean-up covers them, but this repository is public, so the file committed to * it never names one. `--output` (the tracked evidence file) is always the * redacted inventory; `--full-output` writes the complete one and must be - * outside `.github/`, for example under `.private-evidence/` (ignored by git). + * under `.private-evidence/` (ignored by git) or outside this repository, never + * anywhere else inside it. * * Usage: * LABEL_INVENTORY_TOKEN=... node scripts/automation/label-inventory.js [--org lightspeedwp] [--output path] [--full-output path] @@ -234,15 +235,39 @@ export function incompleteRepositoryLabels(inventory) { } /** - * Whether a path is inside the tracked `.github` directory of this repository, - * where nothing that names a private repository may be written. + * Resolves a path through symbolic links as far as it exists, so a link that + * points into the repository cannot be used to slip past the check below. + * @param {string} target Absolute path, which need not exist yet. + * @returns {string} The real path, with any missing tail appended. + */ +function resolveReal(target) { + let current = target; + const missing = []; + while (!fs.existsSync(current)) { + const parent = path.dirname(current); + if (parent === current) break; + missing.unshift(path.basename(current)); + current = parent; + } + return path.join(fs.realpathSync(current), ...missing); +} + +/** + * Tells whether a path would put the file inside this public repository, other + * than under the git-ignored `.private-evidence/` directory. The complete + * inventory names private repositories, so it may be written only there or + * outside the repository. * @param {string} target Output path. - * @param {string} [cwd] Working directory. - * @returns {boolean} + * @param {string} [cwd] Repository root. + * @returns {boolean} True when the path is inside the repository and not private evidence. */ -export function isUnderTrackedGithubDirectory(target, cwd = process.cwd()) { - const relative = path.relative(path.resolve(cwd, '.github'), path.resolve(cwd, target)); - return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +export function isInsidePublicRepository(target, cwd = process.cwd()) { + const root = resolveReal(path.resolve(cwd)); + const resolved = resolveReal(path.resolve(cwd, target)); + const relative = path.relative(root, resolved); + if (relative === '') return true; + if (relative.startsWith('..') || path.isAbsolute(relative)) return false; + return relative.split(path.sep)[0] !== '.private-evidence'; } function parseArgs(argv) { @@ -268,9 +293,9 @@ async function main() { // The full inventory may name private repositories, so it is only ever written // outside the tracked `.github` directory (this repository is public). Checked // before any API call. - if (fullOutput && isUnderTrackedGithubDirectory(fullOutput)) { + if (fullOutput && isInsidePublicRepository(fullOutput)) { console.error( - '❌ --full-output must be outside .github/, because the full inventory names private repositories and this repository is public' + '❌ --full-output must be under .private-evidence/ or outside this repository, because the full inventory names private repositories and this repository is public' ); process.exit(1); } diff --git a/scripts/validation/__tests__/check-locked-file-approval.test.js b/scripts/validation/__tests__/check-locked-file-approval.test.js index 40cabca551..007497f7ff 100644 --- a/scripts/validation/__tests__/check-locked-file-approval.test.js +++ b/scripts/validation/__tests__/check-locked-file-approval.test.js @@ -90,6 +90,53 @@ describe('check-locked-file-approval', () => { expect(result.message).toMatch(/no linked change request is approved/); }); + describe('references inside code are not links', () => { + const fence = '````'; + test.each([ + ['a four-backtick fence', `Intro\n${fence}\nCloses #3556\n${fence}\n`], + ['a three-backtick fence', 'Intro\n```\nCloses #3556\n```\n'], + ['a tilde fence', 'Intro\n~~~\nCloses #3556\n~~~\n'], + ['a fence with an info string', 'Intro\n```md\nCloses #3556\n```\n'], + ['an indented fence', 'Intro\n ```\nCloses #3556\n ```\n'], + ['a fence that is never closed', 'Intro\n```\nCloses #3556\n'], + [ + 'a short fence line inside a longer fence', + `${fence}\n\`\`\`\nCloses #3556\n\`\`\`\n${fence}\n`, + ], + ['a fence with Windows line endings', 'Intro\r\n```\r\nCloses #3556\r\n```\r\n'], + ['a double-backtick span', 'Intro ``Closes #3556`` more'], + ['a single-backtick span', 'Intro `Closes #3556` more'], + [ + 'a full issue URL in a fence', + 'Intro\n```\nhttps://github.com/lightspeedwp/.github/issues/3556\n```\n', + ], + ])('ignores a reference in %s, and evaluate does not accept it', (_name, body) => { + expect(referencedIssues(body)).toEqual([]); + const result = evaluate({ + changedFiles: ['.github/labels.yml'], + body, + register, + titlesByIssue, + }); + expect(result.ok).toBe(false); + expect(result.approvedBy).toEqual({}); + }); + + test('still reads a reference outside the code, after a fence closes', () => { + const body = `${fence}\nCloses #1111\n${fence}\n\nCloses #3556\n`; + expect(referencedIssues(body)).toEqual([3556]); + expect( + evaluate({ changedFiles: ['.github/issue-types.yml'], body, register, titlesByIssue }).ok + ).toBe(true); + }); + + test('a shorter closing fence does not end a longer one', () => { + expect(referencedIssues(`${fence}\n\`\`\`\nCloses #3556\n${fence}\nCloses #4000\n`)).toEqual([ + 4000, + ]); + }); + }); + test('names a failed issue lookup in the failure, still failing closed', () => { const result = evaluate({ changedFiles: ['.github/labels.yml'], diff --git a/scripts/validation/check-locked-file-approval.cjs b/scripts/validation/check-locked-file-approval.cjs index 8b85233651..37188369a4 100644 --- a/scripts/validation/check-locked-file-approval.cjs +++ b/scripts/validation/check-locked-file-approval.cjs @@ -47,17 +47,48 @@ function isLocked(file) { return LOCKED_PATTERNS.some((pattern) => pattern.test(file)); } +/** + * Removes code from a Markdown body: fenced blocks (three or more backticks or + * tildes, closed by a fence of the same character and at least the same length, + * or running to the end if never closed, as GitHub renders them) and inline code + * spans of any backtick length. A reference inside code is not rendered as a + * link, so it must not count as one. + * @param {string} body Markdown text. + * @returns {string} Text with code replaced by blanks. + */ +function stripCode(body) { + const kept = []; + let fence = null; + for (const line of body.split(/\r?\n/)) { + if (fence) { + const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line); + if (close && close[1][0] === fence.char && close[1].length >= fence.length) { + fence = null; + } + kept.push(''); + continue; + } + const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + // A backtick fence's info string cannot contain a backtick; that is inline code. + if (open && !(open[1][0] === '`' && open[2].includes('`'))) { + fence = { char: open[1][0], length: open[1].length }; + kept.push(''); + continue; + } + kept.push(line); + } + return kept.join('\n').replace(/(`+)([\s\S]*?[^`])\1(?!`)/g, ' '); +} + /** * Issue numbers referenced in a pull request body (`#123` or an issue URL in - * this repository). Code spans and HTML comments are ignored. + * this repository). Fenced code, code spans and HTML comments are ignored. * @param {string} body Pull request body. * @param {string} [repo] `owner/name`, to match full issue URLs. * @returns {number[]} Unique issue numbers, in order of appearance. */ function referencedIssues(body, repo = 'lightspeedwp/.github') { - const text = String(body || '') - .replace(//g, ' ') - .replace(/`[^`]*`/g, ' '); + const text = stripCode(String(body || '')).replace(//g, ' '); // Bare references and issue URLs are collected with their positions, so the // result follows the order they appear in the body. const found = []; From 5042e40c254c6d158ee8830d22e7c759e5063c6e Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Mon, 5 Oct 2026 19:31:56 +0200 Subject: [PATCH 20/22] Ignore references in indented code; keep private SC-011 and SC-012 results and snapshots private --- .../008-label-audit-consolidation/tasks.md | 6 +- .../check-locked-file-approval.test.js | 45 +++++++++++++ .../validation/check-locked-file-approval.cjs | 65 +++++++++++++++++-- 3 files changed, 107 insertions(+), 9 deletions(-) diff --git a/.github/specs/008-label-audit-consolidation/tasks.md b/.github/specs/008-label-audit-consolidation/tasks.md index 66c07c18d2..242cf0b037 100644 --- a/.github/specs/008-label-audit-consolidation/tasks.md +++ b/.github/specs/008-label-audit-consolidation/tasks.md @@ -452,8 +452,8 @@ The active labelling agent removes labels outside `labels.yml` today, so this ru - [ ] T062a [P] [US4] Create `scripts/automation/includes/label-write-queue.js`, a shared helper for every mutating GitHub and Linear call in Stages 3 to 5: it takes each request as a transport-specific callback, with a GitHub adapter that wraps `githubApiRequest` from `scripts/automation/includes/github-api-optimized.js` and a Linear adapter, so one queue serves both, runs mutating requests one at a time and at least one second apart, honours `Retry-After` and `x-ratelimit-reset`, keeps Linear calls within Linear's complexity limits, and on a limit pauses and then resumes (FR-023 point 6; research R21). Add unit tests in `scripts/automation/__tests__/label-write-queue.test.js` for spacing, `Retry-After`, pause-and-resume and both adapters - [ ] T062b [US4] Make `scripts/automation/label-consolidate.js` safe to stop and repeat (FR-023 points 1, 2 and 8; research R21): send every write through T062a; take `.github/reports/audits/2026-09-14-label-audit/evidence/run-lock.json` (`run_id`, `run_by`, `started_at`, `stage` one of "`3`, `4` or `5`", `epoch`, `host` and `resumed_from`, which is null on a first start and set by `--resume` to the previous `epoch` and the takeover time) at start with an exclusive create and hold an operating-system advisory lock (`flock`) on it while running, refuse to start while the file exists (naming its `run_id`), release the advisory lock and delete `run-lock.json` when the run, or a run resumed with `--resume `, finishes successfully (an interrupted run keeps the file so it can be resumed again), add `--resume ` (it succeeds only after the original process has released the advisory lock, keeps the `run_id`, raises `epoch` by one and reconciles that `run_id`'s unmatched records first) and `--abandon-run ` (only after @ashleyshaw confirms on the gate issue), refuse any append when the lock's `epoch` is not the run's own, add a `record` subcommand that appends the `intended` and `done` records for steps done by hand (T063), and prefix every `op_id` with the `run_id` (the UTC start time to the second plus eight random hex digits, so two runs started in the same minute never share one) so a resume reconciles only its own records (FR-023 point 11); skip any repository whose `.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json` has `executed_at` set for the stage being run (Stage 3 for T064, Stage 4 for T067); compare the current state with the approved set before each action and make no write when they match; set `executed_at` for that stage when a repository finishes; treat labels that differ only by case or spacing (for example `scope: website`) as separate sources and never merge them automatically, doing a case-only change as an in-place rename. Log every change to `.github/reports/audits/2026-09-14-label-audit/evidence/consolidation-log.jsonl` with the Consolidation Log Entry fields from `data-model.md` (`run_by`, `at`, `repository`, `action` one of "`rename`, `create`, `update`, `relabel`, `convert`, `delete`", `label`, `before` / `after`, `gate_issue`, `op_id`, `state` one of "`intended` (written before the API call) or `done` (written after it succeeds)"; "append-only; every change has an `intended` record before its API call and a `done` record after it succeeds; on resume, an `intended` record with no `done` record is checked against the live state and then completed or retried; a re-run that makes no write adds no record"); on start, truncate a log whose last line was cut off back to its last newline (keeping the removed bytes in a `.partial` file), then reconcile unmatched `intended` records before any other write (contract log rules 4 and 5; research R21), and post one summary comment per run on the gate issue (FR-023 point 10) (depends on T062, T062a) - [ ] T062c [US4] Add `scripts/automation/__tests__/label-consolidate.test.js`: a re-run on a finished repository makes no API write and adds no log record; a stopped run resumes with the repositories that have no `executed_at` for its stage, and a repository finished for Stage 3 is still processed by Stage 4; a failed API call writes no `done` record and leaves its `intended` record, which the next run reconciles against the live state (completing it if the change happened, retrying it if not); a log whose last line was cut off mid-write is truncated back to its last newline (the removed bytes kept in a `.partial` file) before a resumed run appends, so the next record parses and the interrupted change is reconciled; case-only variants stay separate mapping sources; two fresh runs started within the same second get different `run_id`s; a second run refuses to start while `run-lock.json` exists; a run killed mid-write leaves a lock that a plain start refuses and that `--resume ` takes over only after the first process has released its advisory lock, adopting the same `run_id`, raising `epoch` by one, recording `resumed_from` (the previous `epoch` and the takeover time) and reconciling its unmatched `intended` records first; a holder with a stale `epoch` cannot append; `--abandon-run` needs the gate-issue confirmation; and a resume ignores `intended` records from another `run_id` (depends on T062b) -- [ ] T063 [US4] First, before changing any label, write a snapshot of every issue's and PR's `type:*` labels (item kind, number and labels) to `.github/reports/audits/2026-09-14-label-audit/evidence/type-label-snapshot.json`, which SC-011 is checked against (T064, T067a); then, in each repository with open `type:question` issues, convert them to Discussions (Q&A category) with the Labels page bulk action; relabel closed ones `type:task` + `discussion:support`; record repositories where Discussions are disabled in `.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json` and relabel their open issues the same way. Must finish before any `type:question` deletion in Stage 4 (FR-014). Record every conversion and fallback relabel through `label-consolidate.js record` (T062b) as an `intended` record before and a `done` record after, under the Stage 3 run lock (a conversion carries the source issue in `before.item` and the resulting Discussion in `after.item`, each as kind and number); on resume an unmatched `intended` conversion is checked against the live state (the issue is already a Discussion, or the relabel is present) and then completed or repeated (FR-023 points 5 and 11) (depends on T059, T062b) -- [ ] T064 [US4] Run `scripts/automation/label-consolidate.js --apply` for renames, creates and relabels across all repositories (every change logged per T062b, and `executed_at` for Stage 3 set per repository), then confirm no issue or PR has zero or two `type:*` labels, recording the check in `.github/reports/audits/2026-09-14-label-audit/evidence/type-label-check.json` against the pre-run snapshot that T063 wrote before any change, which T067a uses for SC-011 (depends on T062b, T062c, T063, T071) +- [ ] T063 [US4] First, before changing any label, write a snapshot of every issue's and PR's `type:*` labels (item kind, number and labels) to `.github/reports/audits/2026-09-14-label-audit/evidence/type-label-snapshot.json` for public repositories and to `.private-evidence/type-label-snapshot.json` (never committed) for private ones, which SC-011 is checked against (T064, T067a); then, in each repository with open `type:question` issues, convert them to Discussions (Q&A category) with the Labels page bulk action; relabel closed ones `type:task` + `discussion:support`; record repositories where Discussions are disabled in their dry-run file (`evidence/dry-run/{repo}.json`, or `.private-evidence/dry-run/{repo}.json` for a private repository) and relabel their open issues the same way. Must finish before any `type:question` deletion in Stage 4 (FR-014). Record every conversion and fallback relabel through `label-consolidate.js record` (T062b) as an `intended` record before and a `done` record after, under the Stage 3 run lock (a conversion carries the source issue in `before.item` and the resulting Discussion in `after.item`, each as kind and number); on resume an unmatched `intended` conversion is checked against the live state (the issue is already a Discussion, or the relabel is present) and then completed or repeated (FR-023 points 5 and 11) (depends on T059, T062b) +- [ ] T064 [US4] Run `scripts/automation/label-consolidate.js --apply` for renames, creates and relabels across all repositories (every change logged per T062b, and `executed_at` for Stage 3 set per repository), then confirm no issue or PR has zero or two `type:*` labels, recording the check in `.github/reports/audits/2026-09-14-label-audit/evidence/type-label-check.json` for public repositories (private repositories are checked against `.private-evidence/` and add only an aggregate pass or fail, with no repository name, label name or item number) against the pre-run snapshot that T063 wrote before any change, which T067a uses for SC-011 (depends on T062b, T062c, T063, T071) - [ ] T064d [US4] After T059 merges, switch the labelling agent to the approved mapping: load the FR-012 mapping from `evidence/linear-labels.json` (or aliases added to `labels.yml`) as its alias list replacing mapped labels only and keeping unmapped removal off, so unmapped and retiring labels stay until their repository's approved Stage 4 deletion (FR-022; depends on T040d, T059) - [ ] T064a [US4] Superseded by T040c (manual organisation update); verify only. Original scope: Move every issue on the Maintenance, Story or Integration native types to its mapped type (Maintenance → Chore plus `area:maintenance`, Integration → Feature plus `area:integration`, Story → Feature) and update its `type:*` label to match, recording each change in `.github/reports/audits/2026-09-14-label-audit/evidence/native-issue-types.json` (depends on T041a, T059) - [ ] T064b [US4] Superseded by T040c (manual organisation update); verify only. Original scope: Change the organisation's native issue types in this order (the organisation is limited to 25): rename A11y → Accessibility, Code Refactor → Refactor, Code Review → Review, Build & CI → CI; remove Maintenance, Story and Integration once T064a shows zero issues on them; add Build, Dependency Update and Decision. Use the organisation issue-types API if available, otherwise organisation settings; confirm the final list matches `.github/issue-types.yml` and record it in `.github/reports/audits/2026-09-14-label-audit/evidence/native-issue-types.json` (depends on T064a) @@ -465,7 +465,7 @@ The active labelling agent removes labels outside `labels.yml` today, so this ru - [ ] T065a [US4] In `scripts/automation/label-consolidate.js`, re-read each repository just before deleting: if its labels, or the items carrying any `to_delete` label, differ from `.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json` once this run's own completed `done` records are applied to it (so a resumed run does not treat its own earlier deletions as drift, and only differences beyond them are stale), or `labels.yml` on `develop` differs from `approved_set_commit`, mark the dry run stale, skip the repository, post the reason on the gate issue, and require a new dry run and approval (FR-023 points 3 and 4; dry-run rules 6 and 7 in `contracts/dry-run-and-drift-report-schema.md`). Add the stale-dry-run case and the resumed-run case (own `done` records applied, no false stale) to `scripts/automation/__tests__/label-consolidate.test.js` (depends on T065, T062c) **Private repositories (decision of 2026-10-04): the dry-run file and its gate-issue comment for a private repository are not committed or posted in this public repository; keep it under `.private-evidence/dry-run/` and report it only in a private repository.** - [ ] T066 [US4] For each public repository, post its dry-run summary on the gate issue and record @ashleyshaw's per-repository approval (the comment author must be the `ashleyshaw` login, FR-016) in the `approval` block of `.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json`. For each private repository, post the summary and take the approval in the private report repository (`PRIVATE_REPORT_REPO`) instead and record it in the `approval` block of `.private-evidence/dry-run/{repo}.json`; nothing about a private repository is posted on the public gate issue or committed here. Repositories without approval are marked `skipped` (depends on T065) - [ ] T067 [US4] From @ashleyshaw's session, run `scripts/automation/label-consolidate.js --apply --confirm-gate `, which migrates open issues, PRs and Discussions, migrates closed items for labels that have a mapping target (FR-016), and deletes the listed labels only in repositories whose approval is `approved` and refuses otherwise (public repositories read `evidence/dry-run/{repo}.json`, private repositories read `.private-evidence/dry-run/{repo}.json`, and a private repository with no such file is refused, never read from the public path); it sets the Stage 4 `executed_at` as each repository finishes; `destructive_cleanup.enabled` in `.github/label-governance-policy.yml` stays `false`; then run quickstart Test 12 (depends on T066, T065a) -- [ ] T067a [US4] Verify SC-011 and SC-012 after T067: every issue and PR that had a `type:*` label in the T063 pre-run snapshot has exactly one now, and every deleted label has a snapshot entry with name, colour, description and item kinds and numbers in its dry-run file (`.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json` for a public repository, `.private-evidence/dry-run/{repo}.json` for a private one, which is checked and reported privately only); record both results in `.github/reports/audits/2026-09-14-label-audit/evidence/type-label-check.json` and on the gate issue (the SC-011 and SC-012 checks in quickstart Test 12) (depends on T067) +- [ ] T067a [US4] Verify SC-011 and SC-012 after T067: every issue and PR that had a `type:*` label in the T063 pre-run snapshot has exactly one now, and every deleted label has a snapshot entry with name, colour, description and item kinds and numbers in its dry-run file (`.github/reports/audits/2026-09-14-label-audit/evidence/dry-run/{repo}.json` for a public repository, `.private-evidence/dry-run/{repo}.json` for a private one, which is checked and reported privately only); record the per-repository SC-011 and SC-012 results for public repositories in `.github/reports/audits/2026-09-14-label-audit/evidence/type-label-check.json` and on the gate issue, and keep the per-repository results for private repositories in `PRIVATE_REPORT_REPO` only; the public file and the public gate issue carry only an aggregate pass or fail for the private repositories as a group, with no private repository name, label name or item number (the SC-011 and SC-012 checks in quickstart Test 12) (depends on T067) - [ ] T068 [P] [US4] Update the organisation's default repository labels to match `.github/labels.yml` (API if available, otherwise organisation settings), recording the method used on the gate issue and in `.github/reports/audits/2026-09-14-label-audit/evidence/change-requests.json` (research R9) (depends on T059) ### Stage 5: Linear Clean-up (FR-012, FR-015, FR-017) diff --git a/scripts/validation/__tests__/check-locked-file-approval.test.js b/scripts/validation/__tests__/check-locked-file-approval.test.js index 007497f7ff..c18d44f943 100644 --- a/scripts/validation/__tests__/check-locked-file-approval.test.js +++ b/scripts/validation/__tests__/check-locked-file-approval.test.js @@ -122,6 +122,51 @@ describe('check-locked-file-approval', () => { expect(result.approvedBy).toEqual({}); }); + describe('indented code blocks', () => { + test.each([ + ['at the start of the body', ' Closes #3556\n'], + ['after a blank line', 'Intro\n\n Closes #3556\n'], + ['with a tab', 'Intro\n\n\tCloses #3556\n'], + ['after a heading', '# Notes\n Closes #3556\n'], + ['after a thematic break', 'Intro\n\n---\n Closes #3556\n'], + ['after a closed fence', '```\nx\n```\n Closes #3556\n'], + ['spanning several lines', 'Intro\n\n one\n Closes #3556\n three\n'], + ['with a blank line inside the block', 'Intro\n\n one\n\n Closes #3556\n'], + ['with Windows line endings', 'Intro\r\n\r\n Closes #3556\r\n'], + ['inside a list item, indented past its content', '- item\n\n Closes #3556\n'], + [ + 'as a full issue URL', + 'Intro\n\n https://github.com/lightspeedwp/.github/issues/3556\n', + ], + ])( + 'ignores a reference in an indented block %s, and evaluate does not accept it', + (_name, body) => { + expect(referencedIssues(body)).toEqual([]); + const result = evaluate({ + changedFiles: ['.github/labels.yml'], + body, + register, + titlesByIssue, + }); + expect(result.ok).toBe(false); + expect(result.approvedBy).toEqual({}); + } + ); + + test.each([ + ['an indented line that continues a paragraph', 'Intro text\n Closes #3556\n'], + ['a nested list item', '- parent\n - Closes #3556\n'], + ['a list item continuation', '- parent\n Closes #3556\n'], + ['an indented line under three spaces', 'Intro\n\n Closes #3556\n'], + ])('still reads a reference in %s, which renders as text', (_name, body) => { + expect(referencedIssues(body)).toEqual([3556]); + }); + + test('a reference after the indented block ends is still read', () => { + expect(referencedIssues('Intro\n\n Closes #1111\n\nCloses #3556\n')).toEqual([3556]); + }); + }); + test('still reads a reference outside the code, after a fence closes', () => { const body = `${fence}\nCloses #1111\n${fence}\n\nCloses #3556\n`; expect(referencedIssues(body)).toEqual([3556]); diff --git a/scripts/validation/check-locked-file-approval.cjs b/scripts/validation/check-locked-file-approval.cjs index 37188369a4..4716944ed2 100644 --- a/scripts/validation/check-locked-file-approval.cjs +++ b/scripts/validation/check-locked-file-approval.cjs @@ -48,41 +48,94 @@ function isLocked(file) { } /** - * Removes code from a Markdown body: fenced blocks (three or more backticks or - * tildes, closed by a fence of the same character and at least the same length, - * or running to the end if never closed, as GitHub renders them) and inline code - * spans of any backtick length. A reference inside code is not rendered as a - * link, so it must not count as one. + * Width of a line's leading whitespace, counting a tab as four columns. + * @param {string} line One line of Markdown. + * @returns {number} Columns of indentation. + */ +function indentOf(line) { + let width = 0; + for (const char of line) { + if (char === ' ') width += 1; + else if (char === '\t') width += 4 - (width % 4); + else break; + } + return width; +} + +/** + * Removes code from a Markdown body, because a reference inside code is not + * rendered as a link and so must not count as one: + * - fenced blocks (three or more backticks or tildes, closed by a fence of the + * same character and at least the same length, or running to the end if never + * closed, as GitHub renders them); + * - indented code blocks (GitHub Flavored Markdown: four columns past the + * current list item's content, and not continuing a paragraph, so a line + * after a blank line, a heading, a rule, a fence or an HTML line counts); + * - inline code spans of any backtick length. + * + * Where the rules are ambiguous it errs towards treating text as code. That + * can only hide a reference, which fails the check closed, never add one. * @param {string} body Markdown text. * @returns {string} Text with code replaced by blanks. */ function stripCode(body) { const kept = []; let fence = null; + // What the previous line was: a paragraph line can be continued by an indented + // line, so only then is an indented line text rather than code. + let previous = 'blank'; + // Column where the latest list item's content starts, 0 outside a list. + let listOffset = 0; for (const line of body.split(/\r?\n/)) { if (fence) { const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line); if (close && close[1][0] === fence.char && close[1].length >= fence.length) { fence = null; + previous = 'other'; } kept.push(''); continue; } + if (line.trim() === '') { + kept.push(line); + previous = 'blank'; + continue; + } + const indent = indentOf(line); + if (indent < listOffset) { + listOffset = 0; + } + if (indent >= listOffset + 4 && previous !== 'paragraph') { + kept.push(''); + previous = 'other'; + continue; + } const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); // A backtick fence's info string cannot contain a backtick; that is inline code. if (open && !(open[1][0] === '`' && open[2].includes('`'))) { fence = { char: open[1][0], length: open[1].length }; kept.push(''); + previous = 'other'; continue; } kept.push(line); + const item = /^(\s*)([-*+]|\d{1,9}[.)])( {1,4})\S/.exec(line); + if (item) { + listOffset = indentOf(item[1]) + item[2].length + item[3].length; + } + const blockOnly = + /^ {0,3}#{1,6}(\s|$)/.test(line) || + /^ {0,3}([-*_])( *\1){2,} *$/.test(line) || + /^ {0,3}(=+|-+)[ \t]*$/.test(line) || + /^ {0,3} Date: Mon, 5 Oct 2026 19:52:24 +0200 Subject: [PATCH 21/22] Find a pull request body's linked issues by rendering it with GitHub's Markdown API instead of parsing Markdown --- .github/workflows/locked-files-guard.yml | 29 ++- .../check-locked-file-approval.test.js | 243 ++++++++++-------- .../fixtures/rendered-pr-bodies.json | 86 +++++++ .../validation/check-locked-file-approval.cjs | 140 +++------- 4 files changed, 278 insertions(+), 220 deletions(-) create mode 100644 scripts/validation/__tests__/fixtures/rendered-pr-bodies.json diff --git a/.github/workflows/locked-files-guard.yml b/.github/workflows/locked-files-guard.yml index 5cff0b6bcf..2d2ce826d4 100644 --- a/.github/workflows/locked-files-guard.yml +++ b/.github/workflows/locked-files-guard.yml @@ -113,12 +113,29 @@ jobs: const commentsByIssue = {}; const titlesByIssue = {}; const lookupFailures = []; - // Fetch every referenced issue: guard.evaluate processes all of - // them, and truncating here would fail a valid approval linked - // after other references. Fetching happens only when locked files - // changed; the job timeout bounds pathological bodies, failing closed. - const issues = guard.referencedIssues(pr.body || ''); + // Which issues the body links to is decided by GitHub itself: the + // body is rendered with the Markdown API, so anything GitHub does not + // render as a link (fenced or indented code, code spans, comments, + // other repositories) cannot count. Nothing here parses Markdown. A + // render failure fails closed. Fetch every linked issue: guard.evaluate + // processes all of them, and truncating here would fail a valid + // approval linked after other references. This happens only when + // locked files changed; the job timeout bounds pathological bodies, + // failing closed. + let issues = []; if (changedFiles.some(guard.isLocked)) { + try { + issues = await guard.linkedIssues( + github, + pr.body || '', + `${context.repo.owner}/${context.repo.repo}` + ); + } catch (error) { + core.setFailed( + `Locked Files Guard could not render the pull request body to find its linked issues (${error.message}); re-run the check. Refusing to pass without it.` + ); + return; + } for (const issue of issues) { try { const { data } = await github.rest.issues.get({ @@ -146,7 +163,7 @@ jobs: const result = guard.evaluate({ changedFiles, - body: pr.body || '', + issues, register, commentsByIssue, titlesByIssue, diff --git a/scripts/validation/__tests__/check-locked-file-approval.test.js b/scripts/validation/__tests__/check-locked-file-approval.test.js index c18d44f943..ce38ededf5 100644 --- a/scripts/validation/__tests__/check-locked-file-approval.test.js +++ b/scripts/validation/__tests__/check-locked-file-approval.test.js @@ -9,7 +9,8 @@ const { isApprovalComment, isChangeRequestTitle, isLocked, - referencedIssues, + issuesFromRenderedHtml, + linkedIssues, } = require('../check-locked-file-approval.cjs'); const register = { @@ -49,14 +50,126 @@ describe('check-locked-file-approval', () => { expect(isLocked(file)).toBe(false); }); - test('finds issue references and ignores code spans, comments and URLs to other repositories', () => { - const body = [ - 'Closes #3729', - 'Relates to https://github.com/lightspeedwp/.github/issues/3556', - 'Not this: `#999`, , https://github.com/other/repo/issues/997, a#1', - 'Part of #449 and #3729 again', - ].join('\n'); - expect(referencedIssues(body)).toEqual([3729, 3556, 449]); + describe('issues linked from a body rendered by the GitHub Markdown API', () => { + // Real output of POST /markdown (mode gfm, context lightspeedwp/.github), + // saved so the tests need no network. Nothing in the guard parses Markdown. + const fixtures = require('./fixtures/rendered-pr-bodies.json').bodies; + const linked = (name) => issuesFromRenderedHtml(fixtures[name].html); + + test.each([ + ['plain', [3556, 449]], + ['fullUrl', [3556]], + ['markdownLink', [3556]], + ['pullRequestRef', [3734]], + ['htmlAnchor', [3556]], + ['paragraphContinuation', [3556]], + ['nestedList', [3556]], + ])('finds the links GitHub renders in %s', (name, expected) => { + expect(linked(name)).toEqual(expected); + }); + + test.each([ + 'fenceFour', + 'fenceThree', + 'fenceTilde', + 'fenceUnclosed', + 'indented', + 'indentedTab', + 'indentedAfterHeading', + 'indentedInList', + 'inlineSingle', + 'inlineDouble', + 'htmlComment', + 'otherRepository', + 'noReferences', + ])('finds no link to #3556 in %s, because GitHub renders none', (name) => { + expect(linked(name)).not.toContain(3556); + }); + + test('a reference outside a four-backtick fence is still found', () => { + expect(linked('fenceFour')).toEqual([449]); + }); + + test('evaluate does not accept an approval borrowed through code', () => { + for (const name of ['fenceFour', 'indented', 'inlineDouble', 'htmlComment']) { + const result = evaluate({ + changedFiles: ['.github/labels.yml'], + issues: linked(name), + register, + titlesByIssue, + }); + expect(result.ok).toBe(false); + expect(result.approvedBy).toEqual({}); + } + }); + + test('evaluate accepts a link GitHub renders', () => { + const result = evaluate({ + changedFiles: ['.github/issue-types.yml'], + issues: linked('plain'), + register, + titlesByIssue, + }); + expect(result.ok).toBe(true); + }); + + test('ignores links into other repositories and tolerates empty or missing HTML', () => { + const html = 'x'; + expect(issuesFromRenderedHtml(html)).toEqual([]); + expect(issuesFromRenderedHtml('')).toEqual([]); + expect(issuesFromRenderedHtml(undefined)).toEqual([]); + }); + + test('ignores text that only looks like a link, such as escaped HTML in code', () => { + const html = + '
<a href="https://github.com/lightspeedwp/.github/issues/3556">x</a>
'; + expect(issuesFromRenderedHtml(html)).toEqual([]); + }); + + test('keeps order, drops duplicates and accepts a link with a fragment', () => { + const html = + 'a' + + 'b' + + 'c'; + expect(issuesFromRenderedHtml(html)).toEqual([10, 7]); + }); + + describe('linkedIssues', () => { + const clientReturning = (data) => ({ + calls: [], + rest: { + markdown: { + render(args) { + this.calls.push(args); + return Promise.resolve({ data }); + }, + }, + }, + }); + + test('renders the body in gfm mode with the repository as context', async () => { + const github = clientReturning(fixtures.plain.html); + github.rest.markdown.calls = github.calls; + await expect(linkedIssues(github, fixtures.plain.body)).resolves.toEqual([3556, 449]); + expect(github.calls).toEqual([ + { text: fixtures.plain.body, mode: 'gfm', context: 'lightspeedwp/.github' }, + ]); + }); + + test('makes no request for an empty body', async () => { + const github = clientReturning(''); + github.rest.markdown.calls = github.calls; + await expect(linkedIssues(github, ' ')).resolves.toEqual([]); + expect(github.calls).toEqual([]); + }); + + test('lets a render failure through, so the workflow fails closed', async () => { + const github = { + rest: { markdown: { render: () => Promise.reject(new Error('HTTP 502')) } }, + }; + await expect(linkedIssues(github, 'Closes #3556')).rejects.toThrow('HTTP 502'); + }); + }); }); test('reads approved requests from the register and tolerates a missing one', () => { @@ -73,7 +186,7 @@ describe('check-locked-file-approval', () => { }); test('passes when no locked file changes', () => { - const result = evaluate({ changedFiles: ['docs/README.md'], body: '', register }); + const result = evaluate({ changedFiles: ['docs/README.md'], issues: [], register }); expect(result.ok).toBe(true); expect(result.locked).toEqual([]); }); @@ -81,7 +194,7 @@ describe('check-locked-file-approval', () => { test('fails a locked-file change with no linked approved request', () => { const result = evaluate({ changedFiles: ['.github/labels.yml', 'docs/LABEL_STRATEGY.md'], - body: 'Relates to #3557', + issues: [3557], register, titlesByIssue, }); @@ -90,102 +203,10 @@ describe('check-locked-file-approval', () => { expect(result.message).toMatch(/no linked change request is approved/); }); - describe('references inside code are not links', () => { - const fence = '````'; - test.each([ - ['a four-backtick fence', `Intro\n${fence}\nCloses #3556\n${fence}\n`], - ['a three-backtick fence', 'Intro\n```\nCloses #3556\n```\n'], - ['a tilde fence', 'Intro\n~~~\nCloses #3556\n~~~\n'], - ['a fence with an info string', 'Intro\n```md\nCloses #3556\n```\n'], - ['an indented fence', 'Intro\n ```\nCloses #3556\n ```\n'], - ['a fence that is never closed', 'Intro\n```\nCloses #3556\n'], - [ - 'a short fence line inside a longer fence', - `${fence}\n\`\`\`\nCloses #3556\n\`\`\`\n${fence}\n`, - ], - ['a fence with Windows line endings', 'Intro\r\n```\r\nCloses #3556\r\n```\r\n'], - ['a double-backtick span', 'Intro ``Closes #3556`` more'], - ['a single-backtick span', 'Intro `Closes #3556` more'], - [ - 'a full issue URL in a fence', - 'Intro\n```\nhttps://github.com/lightspeedwp/.github/issues/3556\n```\n', - ], - ])('ignores a reference in %s, and evaluate does not accept it', (_name, body) => { - expect(referencedIssues(body)).toEqual([]); - const result = evaluate({ - changedFiles: ['.github/labels.yml'], - body, - register, - titlesByIssue, - }); - expect(result.ok).toBe(false); - expect(result.approvedBy).toEqual({}); - }); - - describe('indented code blocks', () => { - test.each([ - ['at the start of the body', ' Closes #3556\n'], - ['after a blank line', 'Intro\n\n Closes #3556\n'], - ['with a tab', 'Intro\n\n\tCloses #3556\n'], - ['after a heading', '# Notes\n Closes #3556\n'], - ['after a thematic break', 'Intro\n\n---\n Closes #3556\n'], - ['after a closed fence', '```\nx\n```\n Closes #3556\n'], - ['spanning several lines', 'Intro\n\n one\n Closes #3556\n three\n'], - ['with a blank line inside the block', 'Intro\n\n one\n\n Closes #3556\n'], - ['with Windows line endings', 'Intro\r\n\r\n Closes #3556\r\n'], - ['inside a list item, indented past its content', '- item\n\n Closes #3556\n'], - [ - 'as a full issue URL', - 'Intro\n\n https://github.com/lightspeedwp/.github/issues/3556\n', - ], - ])( - 'ignores a reference in an indented block %s, and evaluate does not accept it', - (_name, body) => { - expect(referencedIssues(body)).toEqual([]); - const result = evaluate({ - changedFiles: ['.github/labels.yml'], - body, - register, - titlesByIssue, - }); - expect(result.ok).toBe(false); - expect(result.approvedBy).toEqual({}); - } - ); - - test.each([ - ['an indented line that continues a paragraph', 'Intro text\n Closes #3556\n'], - ['a nested list item', '- parent\n - Closes #3556\n'], - ['a list item continuation', '- parent\n Closes #3556\n'], - ['an indented line under three spaces', 'Intro\n\n Closes #3556\n'], - ])('still reads a reference in %s, which renders as text', (_name, body) => { - expect(referencedIssues(body)).toEqual([3556]); - }); - - test('a reference after the indented block ends is still read', () => { - expect(referencedIssues('Intro\n\n Closes #1111\n\nCloses #3556\n')).toEqual([3556]); - }); - }); - - test('still reads a reference outside the code, after a fence closes', () => { - const body = `${fence}\nCloses #1111\n${fence}\n\nCloses #3556\n`; - expect(referencedIssues(body)).toEqual([3556]); - expect( - evaluate({ changedFiles: ['.github/issue-types.yml'], body, register, titlesByIssue }).ok - ).toBe(true); - }); - - test('a shorter closing fence does not end a longer one', () => { - expect(referencedIssues(`${fence}\n\`\`\`\nCloses #3556\n${fence}\nCloses #4000\n`)).toEqual([ - 4000, - ]); - }); - }); - test('names a failed issue lookup in the failure, still failing closed', () => { const result = evaluate({ changedFiles: ['.github/labels.yml'], - body: 'Closes #4000', + issues: [4000], register, // #4000 could not be read, so it has no title and cannot count as approved. titlesByIssue: {}, @@ -200,7 +221,7 @@ describe('check-locked-file-approval', () => { test('does not mention lookup failures when the request is approved anyway', () => { const result = evaluate({ changedFiles: ['.github/issue-types.yml'], - body: 'Closes #3556 and #4000', + issues: [3556, 4000], register, titlesByIssue, lookupFailures: [{ issue: 4000, message: 'HTTP 502' }], @@ -212,7 +233,7 @@ describe('check-locked-file-approval', () => { test('says nothing extra for an ordinary rejection with no lookup failure', () => { const result = evaluate({ changedFiles: ['.github/labels.yml'], - body: 'Relates to #3557', + issues: [3557], register, titlesByIssue, }); @@ -222,7 +243,7 @@ describe('check-locked-file-approval', () => { test('passes a locked-file change linked to a request approved in the register', () => { const result = evaluate({ changedFiles: ['.github/issue-types.yml'], - body: 'Closes #3556', + issues: [3556], register, titlesByIssue, }); @@ -233,7 +254,7 @@ describe('check-locked-file-approval', () => { test('passes a locked-file change linked to an issue with the approver’s approval comment', () => { const result = evaluate({ changedFiles: ['.github/PULL_REQUEST_TEMPLATE/pr_docs.md'], - body: 'Closes #4000', + issues: [4000], register, titlesByIssue, commentsByIssue: { 4000: [{ user: { login: APPROVER }, body: 'Approved, ship it' }] }, @@ -245,7 +266,7 @@ describe('check-locked-file-approval', () => { test('does not accept an approval comment from anyone else', () => { const result = evaluate({ changedFiles: ['.github/branch-labels.yml'], - body: 'Closes #4000', + issues: [4000], register, titlesByIssue, commentsByIssue: { 4000: [{ user: { login: 'teammate' }, body: 'Approved' }] }, @@ -267,7 +288,7 @@ describe('check-locked-file-approval', () => { test('does not accept the approver’s comment on an issue that is not a change request', () => { const result = evaluate({ changedFiles: ['.github/labels.yml'], - body: 'Part of #449', + issues: [449], register, titlesByIssue, commentsByIssue: { 449: [{ user: { login: APPROVER }, body: 'Approved' }] }, @@ -279,7 +300,7 @@ describe('check-locked-file-approval', () => { test('does not accept a register approval when the issue title is unknown', () => { const result = evaluate({ changedFiles: ['.github/issue-types.yml'], - body: 'Closes #3556', + issues: [3556], register, }); expect(result.ok).toBe(false); diff --git a/scripts/validation/__tests__/fixtures/rendered-pr-bodies.json b/scripts/validation/__tests__/fixtures/rendered-pr-bodies.json new file mode 100644 index 0000000000..48e88198fd --- /dev/null +++ b/scripts/validation/__tests__/fixtures/rendered-pr-bodies.json @@ -0,0 +1,86 @@ +{ + "renderedAt": "2026-10-05", + "repository": "lightspeedwp/.github", + "bodies": { + "plain": { + "body": "Closes #3556 and Relates to #449", + "html": "

Closes #3556 and Relates to #449

" + }, + "fullUrl": { + "body": "See https://github.com/lightspeedwp/.github/issues/3556 for the request", + "html": "

See #3556 for the request

" + }, + "markdownLink": { + "body": "[request](https://github.com/lightspeedwp/.github/issues/3556)", + "html": "

request

" + }, + "pullRequestRef": { + "body": "Stacked on #3734", + "html": "

Stacked on #3734

" + }, + "fenceFour": { + "body": "Intro\n````\nCloses #3556\n````\n\nCloses #449\n", + "html": "

Intro

\n
Closes #3556\n
\n

Closes #449

" + }, + "fenceThree": { + "body": "Intro\n```\nCloses #3556\n```\n", + "html": "

Intro

\n
Closes #3556\n
" + }, + "fenceTilde": { + "body": "Intro\n~~~\nCloses #3556\n~~~\n", + "html": "

Intro

\n
Closes #3556\n
" + }, + "fenceUnclosed": { + "body": "Intro\n```\nCloses #3556\n", + "html": "

Intro

\n
Closes #3556\n
" + }, + "indented": { + "body": "Intro\n\n Closes #3556\n", + "html": "

Intro

\n
Closes #3556\n
" + }, + "indentedTab": { + "body": "Intro\n\n\tCloses #3556\n", + "html": "

Intro

\n
Closes #3556\n
" + }, + "indentedAfterHeading": { + "body": "# Notes\n Closes #3556\n", + "html": "

Notes

\n
Closes #3556\n
" + }, + "indentedInList": { + "body": "- item\n\n Closes #3556\n", + "html": "
    \n
  • \n

    item

    \n
    Closes #3556\n
    \n
  • \n
" + }, + "paragraphContinuation": { + "body": "Intro text\n Closes #3556\n", + "html": "

Intro text
\nCloses #3556

" + }, + "nestedList": { + "body": "- parent\n - Closes #3556\n", + "html": "
    \n
  • parent\n\n
  • \n
" + }, + "inlineSingle": { + "body": "x `Closes #3556` y", + "html": "

x Closes #3556 y

" + }, + "inlineDouble": { + "body": "x ``Closes #3556`` y", + "html": "

x Closes #3556 y

" + }, + "htmlComment": { + "body": "\nDone", + "html": "\n

Done

" + }, + "htmlAnchor": { + "body": "request", + "html": "

request

" + }, + "otherRepository": { + "body": "other/repo#12 and https://github.com/other/repo/issues/9", + "html": "

other/repo#12 and https://github.com/other/repo/issues/9

" + }, + "noReferences": { + "body": "Just a description with no links", + "html": "

Just a description with no links

" + } + } +} diff --git a/scripts/validation/check-locked-file-approval.cjs b/scripts/validation/check-locked-file-approval.cjs index 4716944ed2..77d2fe4b37 100644 --- a/scripts/validation/check-locked-file-approval.cjs +++ b/scripts/validation/check-locked-file-approval.cjs @@ -48,113 +48,47 @@ function isLocked(file) { } /** - * Width of a line's leading whitespace, counting a tab as four columns. - * @param {string} line One line of Markdown. - * @returns {number} Columns of indentation. + * Issue and pull request numbers a rendered pull request body links to in this + * repository. The input is the HTML GitHub's own Markdown renderer produced for + * the body, so exactly what GitHub does not render as a link (fenced and + * indented code, code spans, HTML comments, references to other repositories, + * references to issues that do not exist) is not found here. Nothing in this + * module parses Markdown. + * @param {string} html Output of the GitHub Markdown render API (gfm mode). + * @param {string} [repo] `owner/name`, to ignore links into other repositories. + * @returns {number[]} Unique numbers, in order of appearance. */ -function indentOf(line) { - let width = 0; - for (const char of line) { - if (char === ' ') width += 1; - else if (char === '\t') width += 4 - (width % 4); - else break; - } - return width; -} - -/** - * Removes code from a Markdown body, because a reference inside code is not - * rendered as a link and so must not count as one: - * - fenced blocks (three or more backticks or tildes, closed by a fence of the - * same character and at least the same length, or running to the end if never - * closed, as GitHub renders them); - * - indented code blocks (GitHub Flavored Markdown: four columns past the - * current list item's content, and not continuing a paragraph, so a line - * after a blank line, a heading, a rule, a fence or an HTML line counts); - * - inline code spans of any backtick length. - * - * Where the rules are ambiguous it errs towards treating text as code. That - * can only hide a reference, which fails the check closed, never add one. - * @param {string} body Markdown text. - * @returns {string} Text with code replaced by blanks. - */ -function stripCode(body) { - const kept = []; - let fence = null; - // What the previous line was: a paragraph line can be continued by an indented - // line, so only then is an indented line text rather than code. - let previous = 'blank'; - // Column where the latest list item's content starts, 0 outside a list. - let listOffset = 0; - for (const line of body.split(/\r?\n/)) { - if (fence) { - const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line); - if (close && close[1][0] === fence.char && close[1].length >= fence.length) { - fence = null; - previous = 'other'; - } - kept.push(''); - continue; - } - if (line.trim() === '') { - kept.push(line); - previous = 'blank'; - continue; - } - const indent = indentOf(line); - if (indent < listOffset) { - listOffset = 0; - } - if (indent >= listOffset + 4 && previous !== 'paragraph') { - kept.push(''); - previous = 'other'; - continue; - } - const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); - // A backtick fence's info string cannot contain a backtick; that is inline code. - if (open && !(open[1][0] === '`' && open[2].includes('`'))) { - fence = { char: open[1][0], length: open[1].length }; - kept.push(''); - previous = 'other'; - continue; - } - kept.push(line); - const item = /^(\s*)([-*+]|\d{1,9}[.)])( {1,4})\S/.exec(line); - if (item) { - listOffset = indentOf(item[1]) + item[2].length + item[3].length; - } - const blockOnly = - /^ {0,3}#{1,6}(\s|$)/.test(line) || - /^ {0,3}([-*_])( *\1){2,} *$/.test(line) || - /^ {0,3}(=+|-+)[ \t]*$/.test(line) || - /^ {0,3}]*?\\bhref="https://github\\.com/${escaped}/(?:issues|pull)/(\\d+)(?:[/?#][^"]*)?"`, + 'gi' + ); + const numbers = []; + for (const match of String(html || '').matchAll(link)) { + numbers.push(Number(match[1])); } - return kept.join('\n').replace(/(`+)([\s\S]*?[^`])\1(?!`)/g, ' '); + return [...new Set(numbers)]; } /** - * Issue numbers referenced in a pull request body (`#123` or an issue URL in - * this repository). Fenced and indented code, code spans and HTML comments are ignored. + * Asks GitHub which issues a pull request body links to, by rendering it. + * A render failure throws, so the caller fails closed instead of guessing. + * @param {{ rest: { markdown: { render: Function } } }} github Octokit client (github-script). * @param {string} body Pull request body. - * @param {string} [repo] `owner/name`, to match full issue URLs. - * @returns {number[]} Unique issue numbers, in order of appearance. + * @param {string} [repo] `owner/name`, used as the rendering context. + * @returns {Promise} Unique issue numbers, in order of appearance. */ -function referencedIssues(body, repo = 'lightspeedwp/.github') { - const text = stripCode(String(body || '')).replace(//g, ' '); - // Bare references and issue URLs are collected with their positions, so the - // result follows the order they appear in the body. - const found = []; - for (const match of text.matchAll(/(?:^|[^\w&/])#(\d+)\b/g)) { - found.push({ at: match.index + match[0].lastIndexOf('#'), number: Number(match[1]) }); - } - const escaped = repo.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const url = new RegExp(`https://github\\.com/${escaped}/issues/(\\d+)\\b`, 'g'); - for (const match of text.matchAll(url)) { - found.push({ at: match.index, number: Number(match[1]) }); +async function linkedIssues(github, body, repo = 'lightspeedwp/.github') { + if (!String(body || '').trim()) { + return []; } - found.sort((a, b) => a.at - b.at); - return [...new Set(found.map((entry) => entry.number))]; + const { data } = await github.rest.markdown.render({ + text: String(body), + mode: 'gfm', + context: repo, + }); + return issuesFromRenderedHtml(data, repo); } /** @@ -199,7 +133,7 @@ function isApprovalComment(comment) { * Decide whether a pull request may merge. * @param {object} input * @param {string[]} input.changedFiles Paths the pull request changes. - * @param {string} input.body Pull request body. + * @param {number[]} input.issues Issues the pull request body links to, from `linkedIssues`. * @param {object|null} input.register Change-request register from the base branch. * @param {Record>} [input.commentsByIssue] Comments on each referenced issue. * @param {Record} [input.titlesByIssue] Title of each referenced issue; an issue @@ -208,14 +142,13 @@ function isApprovalComment(comment) { */ function evaluate({ changedFiles, - body, + issues = [], register, commentsByIssue = {}, titlesByIssue = {}, lookupFailures = [], }) { const locked = changedFiles.filter(isLocked); - const issues = referencedIssues(body); if (locked.length === 0) { return { locked, issues, approvedBy: {}, ok: true, message: 'No locked file changed.' }; } @@ -267,5 +200,6 @@ module.exports = { isApprovalComment, isChangeRequestTitle, isLocked, - referencedIssues, + issuesFromRenderedHtml, + linkedIssues, }; From 6fe26a152569b22f6c465279774ed0f4d74f52ce Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Mon, 5 Oct 2026 20:31:23 +0200 Subject: [PATCH 22/22] Find the repository root from the script location for the full-output guard --- .../__tests__/label-inventory.test.js | 46 +++++++++++++++++++ scripts/automation/label-inventory.js | 45 ++++++++++++++---- 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/scripts/automation/__tests__/label-inventory.test.js b/scripts/automation/__tests__/label-inventory.test.js index 32aa99d90b..a11efa2a53 100644 --- a/scripts/automation/__tests__/label-inventory.test.js +++ b/scripts/automation/__tests__/label-inventory.test.js @@ -13,6 +13,7 @@ import { buildInventory, incompleteRepositories, incompleteRepositoryLabels, + findRepositoryRoot, isInsidePublicRepository, privateRepositoryGap, publicRepositoryName, @@ -269,6 +270,51 @@ describe('label-inventory', () => { } }); + it('judges a path against the repository root, not the directory the script runs from', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'inventory-sub-')); + try { + fs.mkdirSync(path.join(root, 'scripts')); + fs.mkdirSync(path.join(root, 'docs')); + fs.mkdirSync(path.join(root, '.private-evidence')); + const cwd = path.join(root, 'scripts'); + // From scripts/, ../docs/full.json starts with .. but is still inside the repository. + expect(isInsidePublicRepository('../docs/full.json', root, cwd)).toBe(true); + expect(isInsidePublicRepository('full.json', root, cwd)).toBe(true); + expect(isInsidePublicRepository('../.private-evidence/full.json', root, cwd)).toBe(false); + // The old behaviour took the working directory as the root and accepted the first one. + expect(isInsidePublicRepository('../docs/full.json', cwd)).toBe(false); + expect(isInsidePublicRepository('../../outside/full.json', root, cwd)).toBe(false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('finds the repository root from the script location, whatever the working directory', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'inventory-root-')); + try { + const scriptDir = path.join(root, 'scripts', 'automation'); + fs.mkdirSync(scriptDir, { recursive: true }); + const script = path.join(scriptDir, 'label-inventory.js'); + fs.writeFileSync(script, ''); + const real = fs.realpathSync(root); + // Git reports the top level. + const git = () => ({ status: 0, stdout: `${real}\n` }); + expect(findRepositoryRoot(script, git)).toBe(real); + // Without git, it is two directories above scripts/automation/. + const noGit = () => ({ status: 128, stdout: '' }); + expect(findRepositoryRoot(script, noGit)).toBe(real); + // It is asked from the script's directory, not the caller's. + const seen = []; + findRepositoryRoot(script, (command, args, options) => { + seen.push(options.cwd); + return git(); + }); + expect(seen).toEqual([fs.realpathSync(scriptDir)]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it('is not fooled by a path that climbs out of .private-evidence or a symlink into the repository', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'inventory-root-')); const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'inventory-outside-')); diff --git a/scripts/automation/label-inventory.js b/scripts/automation/label-inventory.js index 580f94b335..3ef740577d 100644 --- a/scripts/automation/label-inventory.js +++ b/scripts/automation/label-inventory.js @@ -26,6 +26,7 @@ * LABEL_INVENTORY_TOKEN=... node scripts/automation/label-inventory.js [--org lightspeedwp] [--output path] [--full-output path] */ +import { spawnSync } from 'child_process'; import fs from 'fs'; import path from 'path'; @@ -257,19 +258,37 @@ function resolveReal(target) { * than under the git-ignored `.private-evidence/` directory. The complete * inventory names private repositories, so it may be written only there or * outside the repository. - * @param {string} target Output path. - * @param {string} [cwd] Repository root. + * @param {string} target Output path; a relative path is taken from `cwd`. + * @param {string} [root] Repository root. It is not the working directory: a + * script run from a subdirectory must still be judged against the repository. + * @param {string} [cwd] Directory a relative `target` is resolved from. * @returns {boolean} True when the path is inside the repository and not private evidence. */ -export function isInsidePublicRepository(target, cwd = process.cwd()) { - const root = resolveReal(path.resolve(cwd)); +export function isInsidePublicRepository(target, root = process.cwd(), cwd = root) { + const realRoot = resolveReal(path.resolve(root)); const resolved = resolveReal(path.resolve(cwd, target)); - const relative = path.relative(root, resolved); + const relative = path.relative(realRoot, resolved); if (relative === '') return true; if (relative.startsWith('..') || path.isAbsolute(relative)) return false; return relative.split(path.sep)[0] !== '.private-evidence'; } +/** + * Finds the repository root from where this script lives, not from the working + * directory, so running it from a subdirectory cannot change which directory the + * output guard protects. Uses `git rev-parse --show-toplevel` and falls back to + * two directories above `scripts/automation/`. + * @param {string} [scriptPath] Path of this script. + * @param {typeof spawnSync} [run] Process runner, for tests. + * @returns {string} Absolute repository root. + */ +export function findRepositoryRoot(scriptPath = process.argv[1], run = spawnSync) { + const scriptDir = path.dirname(fs.realpathSync(scriptPath)); + const result = run('git', ['rev-parse', '--show-toplevel'], { cwd: scriptDir, encoding: 'utf8' }); + const top = result.status === 0 ? String(result.stdout).trim() : ''; + return top ? fs.realpathSync(top) : path.resolve(scriptDir, '..', '..'); +} + function parseArgs(argv) { const args = { org: 'lightspeedwp', output: DEFAULT_OUTPUT, fullOutput: null }; for (let i = 0; i < argv.length; i += 1) { @@ -288,12 +307,20 @@ async function main() { } const { Octokit } = await import('octokit'); const client = new Octokit({ auth: token }); - const { org, output, fullOutput } = parseArgs(process.argv.slice(2)); + const parsed = parseArgs(process.argv.slice(2)); + const { org, fullOutput } = parsed; + const output = + parsed.output === DEFAULT_OUTPUT + ? path.join(findRepositoryRoot(), DEFAULT_OUTPUT) + : parsed.output; // The full inventory may name private repositories, so it is only ever written - // outside the tracked `.github` directory (this repository is public). Checked - // before any API call. - if (fullOutput && isInsidePublicRepository(fullOutput)) { + // under .private-evidence/ or outside this public repository, never anywhere + // else inside it. The repository is found from the script's location, so the + // check holds whichever directory the script is run from. Checked before any + // API call. + const root = findRepositoryRoot(); + if (fullOutput && isInsidePublicRepository(fullOutput, root, process.cwd())) { console.error( '❌ --full-output must be under .private-evidence/ or outside this repository, because the full inventory names private repositories and this repository is public' );