diff --git a/.github/scripts/approve-rule.mjs b/.github/scripts/approve-rule.mjs index f4444424d0..032fb234f7 100644 --- a/.github/scripts/approve-rule.mjs +++ b/.github/scripts/approve-rule.mjs @@ -1,83 +1,91 @@ #!/usr/bin/env zx -import 'zx/globals'; -import assert from 'assert'; -import moment from 'moment'; +import 'zx/globals' +import assert from 'assert' +import moment from 'moment' -import { - config, - cloneWcagActRules, - createOrCheckoutBranch, - commitAndPush -} from './commons.mjs'; +import { config, cloneWcagActRules, createOrCheckoutBranch, commitAndPush } from './commons.mjs' +import { parseChanges, updateRuleVersions } from './update-rule-versions.mjs' +import { rewriteArchivedFrontmatter } from './archive-rule-snapshot.mjs' -const w3cDataFormat = 'D MMMM YYYY'; -const isoDateFormat = 'YYYY-MM-DD'; +const w3cDataFormat = 'D MMMM YYYY' +const isoDateFormat = 'YYYY-MM-DD' -assert(typeof argv.ruleId === 'string', 'Expected --ruleId to be set'); -assert(argv.ruleId.length === 6, 'Expected --ruleId to be 6 characters long'); -assert(typeof argv.branch === 'string', 'Expected --branch to be set'); +assert(typeof argv.ruleId === 'string', 'Expected --ruleId to be set') +assert(argv.ruleId.length === 6, 'Expected --ruleId to be 6 characters long') +assert(typeof argv.branch === 'string', 'Expected --branch to be set') +const changes = parseChanges(argv) if (!argv['skip-clone']) { - await cloneWcagActRules(config); + await cloneWcagActRules(config) } -await createOrCheckoutBranch(config, argv.branch); -await generateApprovedRulePages(config, argv.ruleId); -await updateRuleVersionsYaml(config, argv.ruleId); -await approveexampleJson(config, argv.ruleId); -await commitAndPush(config, `Set ${argv.ruleId} to approved`); +await createOrCheckoutBranch(config, argv.branch) +const ruleVersionsUpdate = prepareRuleVersionsUpdate(config, argv.ruleId, changes) +await generateApprovedRulePages(config, argv.ruleId) +writeRuleVersionsYaml(ruleVersionsUpdate, argv.ruleId) +await approveexampleJson(config, argv.ruleId) +await commitAndPush(config, `Set ${argv.ruleId} to approved`) async function generateApprovedRulePages({ tmpDir, rulesDir, glossaryDir, testAssetsDir }, ruleId) { - await $`node ./node_modules/act-tools/dist/cli/rule-transform.js \ + await $`node ./node_modules/act-tools/dist/cli/rule-transform.js \ --rulesDir "${rulesDir}" \ --glossaryDir "${glossaryDir}" \ --testAssetsDir "${testAssetsDir}" \ --outDir "${tmpDir}" \ --ruleIds "${ruleId}" - `; + ` } -async function updateRuleVersionsYaml({ tmpDir }, ruleId) { - const ruleVersionPath = `${tmpDir}_data/wcag-act-rules/rule-versions.yml`; - let ruleVersionsStr = fs.readFileSync(ruleVersionPath, 'utf8'); - const ruleVersions = YAML.parse(ruleVersionsStr); - assert( - ruleVersions[ruleId] === undefined, - `RuleID ${ruleId} should not exists in rule-versions.yml. Was this rule approved before?` - ); +function prepareRuleVersionsUpdate({ tmpDir }, ruleId, changes) { + const ruleVersionPath = `${tmpDir}_data/wcag-act-rules/rule-versions.yml` + const ruleVersions = YAML.parse(fs.readFileSync(ruleVersionPath, 'utf8')) - const proposedText = fs.readFileSync(`${tmpDir}content/rules/${ruleId}/proposed.md`, 'utf8'); - const proposedData = proposedText.match(/last_modified:\s+(.*)/)?.[1] - assert(proposedData, `Unable to find last_modified data in ${ruleId}/proposed.md`); + const proposedText = fs.readFileSync(`${tmpDir}content/rules/${ruleId}/proposed.md`, 'utf8') + const proposedW3cDate = proposedText.match(/last_modified:\s+(.*)/)?.[1] + assert(proposedW3cDate, `Unable to find last_modified data in ${ruleId}/proposed.md`) - ruleVersions[ruleId] = [{ - file: 'proposed.md', - url: `${ruleId}/proposed/`, - w3cDate: proposedData, - isoDate: moment(proposedData, w3cDataFormat).format(isoDateFormat) - }, { - file: 'index.md', - url: `${ruleId}/`, - w3cDate: moment().format(w3cDataFormat), - isoDate: moment().format(isoDateFormat) - }] - - ruleVersionsStr = YAML.stringify(ruleVersions); - fs.writeFileSync(ruleVersionPath, ruleVersionsStr, 'utf8'); - console.log(`Added ${ruleId} to rule-versions.yml`); + const result = updateRuleVersions({ + ruleVersions, + ruleId, + proposedDate: { + w3cDate: proposedW3cDate, + isoDate: moment(proposedW3cDate, w3cDataFormat).format(isoDateFormat), + }, + w3cDate: moment().format(w3cDataFormat), + isoDate: moment().format(isoDateFormat), + changes, + }) + + if (result.isReapproval) { + const ruleDir = `${tmpDir}content/rules/${ruleId}/` + const archived = rewriteArchivedFrontmatter({ + text: fs.readFileSync(`${ruleDir}index.md`, 'utf8'), + ruleId, + isoDate: result.previousIsoDate, + }) + fs.writeFileSync(`${ruleDir}${result.previousIsoDate}.md`, archived, 'utf8') + console.log(`Archived ${ruleId}/index.md as ${result.previousIsoDate}.md`) + } + + return { ruleVersionPath, ruleVersions } +} + +function writeRuleVersionsYaml({ ruleVersionPath, ruleVersions }, ruleId) { + fs.writeFileSync(ruleVersionPath, YAML.stringify(ruleVersions), 'utf8') + console.log(`Updated ${ruleId} in rule-versions.yml`) } async function approveexampleJson({ tmpDir }, ruleId) { - let exampleCount = 0; - const exampleJsonPath = `${tmpDir}content-assets/wcag-act-rules/examples.json`; - const exampleJson = JSON.parse(fs.readFileSync(exampleJsonPath, 'utf8')); - exampleJson.examples.forEach((example, index) => { - if (example.ruleId === ruleId) { - // Override rather than update so that `approved` isn't at the bottom - exampleJson.examples[index] = { ruleId, approved: true, ...example } - exampleCount++ - } - }); - console.log(`Set ${exampleCount} examples of rule ${ruleId} to be approved in examples.json`); - fs.writeFileSync(exampleJsonPath, JSON.stringify(exampleJson, null, 2), 'utf8'); + let exampleCount = 0 + const exampleJsonPath = `${tmpDir}content-assets/wcag-act-rules/examples.json` + const exampleJson = JSON.parse(fs.readFileSync(exampleJsonPath, 'utf8')) + exampleJson.examples.forEach((example, index) => { + if (example.ruleId === ruleId) { + // Override rather than update so that `approved` isn't at the bottom + exampleJson.examples[index] = { ruleId, approved: true, ...example } + exampleCount++ + } + }) + console.log(`Set ${exampleCount} examples of rule ${ruleId} to be approved in examples.json`) + fs.writeFileSync(exampleJsonPath, JSON.stringify(exampleJson, null, 2), 'utf8') } diff --git a/.github/scripts/archive-rule-snapshot.mjs b/.github/scripts/archive-rule-snapshot.mjs new file mode 100644 index 0000000000..36523925d8 --- /dev/null +++ b/.github/scripts/archive-rule-snapshot.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert' + +const frontmatterPattern = /^---\r?\n[\s\S]*?\r?\n---(\r?\n|$)/ + +/** + * Point the frontmatter of an archived rule snapshot at its dated URL and file, + * leaving the rest of the page (including `last_modified` and the footer date) + * as it was when the rule was approved. + */ +export function rewriteArchivedFrontmatter({ text, ruleId, isoDate }) { + assert(/^[0-9a-z]{6}$/.test(ruleId), `Expected a 6 character rule id, got "${ruleId}"`) + assert(/^\d{4}-\d{2}-\d{2}$/.test(isoDate), `Expected an ISO 8601 date, got "${isoDate}"`) + + const frontmatter = text.match(frontmatterPattern)?.[0] + assert(frontmatter, `Expected the ${ruleId} snapshot to start with YAML frontmatter`) + + let rewritten = replaceLines(frontmatter, { + pattern: new RegExp(`^(permalink|ref): (/standards-guidelines/act/rules/${ruleId}/)$`, 'gm'), + replacement: `$1: $2${isoDate}/`, + expected: 2, + description: `permalink and ref of ${ruleId}/index.md`, + }) + rewritten = replaceLines(rewritten, { + pattern: new RegExp(`^(\\s*path: content/rules/${ruleId}/)index\\.md$`, 'gm'), + replacement: `$1${isoDate}.md`, + expected: 1, + description: `github path of ${ruleId}/index.md`, + }) + + return rewritten + text.slice(frontmatter.length) +} + +function replaceLines(text, { pattern, replacement, expected, description }) { + const matches = text.match(pattern) ?? [] + assert(matches.length === expected, `Expected ${expected} lines with the ${description}, found ${matches.length}`) + return text.replace(pattern, replacement) +} diff --git a/.github/scripts/archive-rule-snapshot.test.mjs b/.github/scripts/archive-rule-snapshot.test.mjs new file mode 100644 index 0000000000..8a4bdbff82 --- /dev/null +++ b/.github/scripts/archive-rule-snapshot.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { rewriteArchivedFrontmatter } from './archive-rule-snapshot.mjs' + +const indexText = `--- +title: "Element with lang attribute has valid language tag" +permalink: /standards-guidelines/act/rules/de46e4/ +ref: /standards-guidelines/act/rules/de46e4/ +lang: en +github: + repository: w3c/wcag-act-rules + path: content/rules/de46e4/index.md +feedbackmail: public-wcag-act@w3.org +footer: | +

Rule Identifier: de46e4

+

Date: Updated 20 December 2023

+proposed: false +rule_meta: + id: de46e4 + last_modified: 20 December 2023 +--- + +## Applicability + +This rule applies to any element with a \`lang\` attribute. + +See [rules](/standards-guidelines/act/rules/de46e4/) for the latest version. +` + +test('rewrites permalink, ref and github path of the archived snapshot', () => { + const archived = rewriteArchivedFrontmatter({ text: indexText, ruleId: 'de46e4', isoDate: '2023-12-20' }) + + assert.match(archived, /^permalink: \/standards-guidelines\/act\/rules\/de46e4\/2023-12-20\/$/m) + assert.match(archived, /^ref: \/standards-guidelines\/act\/rules\/de46e4\/2023-12-20\/$/m) + assert.match(archived, /^ {2}path: content\/rules\/de46e4\/2023-12-20\.md$/m) +}) + +test('leaves the last_modified, footer date and body untouched', () => { + const archived = rewriteArchivedFrontmatter({ text: indexText, ruleId: 'de46e4', isoDate: '2023-12-20' }) + const body = text => text.slice(text.lastIndexOf('\n---\n')) + + assert.match(archived, /^ {2}last_modified: 20 December 2023$/m) + assert.match(archived, /Date:<\/strong> Updated 20 December 2023/) + assert.equal(body(archived), body(indexText)) +}) + +test('only changes the three frontmatter lines', () => { + const archived = rewriteArchivedFrontmatter({ text: indexText, ruleId: 'de46e4', isoDate: '2023-12-20' }) + const originalLines = indexText.split('\n') + + const changedKeys = archived + .split('\n') + .filter((line, index) => line !== originalLines[index]) + .map(line => line.trim().split(':')[0]) + + assert.deepEqual(changedKeys, ['permalink', 'ref', 'path']) +}) + +test('throws when the snapshot has no frontmatter', () => { + assert.throws( + () => rewriteArchivedFrontmatter({ text: '## Applicability\n', ruleId: 'de46e4', isoDate: '2023-12-20' }), + /start with YAML frontmatter/ + ) +}) + +test('throws when the frontmatter does not have the expected rule URLs', () => { + const otherRule = indexText.replaceAll('de46e4', 'abc123') + + assert.throws( + () => rewriteArchivedFrontmatter({ text: otherRule, ruleId: 'de46e4', isoDate: '2023-12-20' }), + /Expected 2 lines with the permalink and ref/ + ) +}) diff --git a/.github/scripts/update-rule-versions.mjs b/.github/scripts/update-rule-versions.mjs new file mode 100644 index 0000000000..7eaacade2d --- /dev/null +++ b/.github/scripts/update-rule-versions.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert' +import fs from 'node:fs' +import yaml from 'js-yaml' + +export function parseChanges({ change, changesFile } = {}) { + const cliChanges = change === undefined ? [] : Array.isArray(change) ? change : [change] + let fileChanges = [] + + if (changesFile !== undefined) { + assert(typeof changesFile === 'string', 'Expected --changesFile to be a path') + fileChanges = yaml.load(fs.readFileSync(changesFile, 'utf8')) + assert(Array.isArray(fileChanges), `Expected ${changesFile} to contain a YAML list of changes`) + } + + const changes = [...fileChanges, ...cliChanges] + assert( + changes.every(changeEntry => typeof changeEntry === 'string' && changeEntry.trim().length > 0), + 'Each changelog entry must be a non-empty string' + ) + return changes.map(changeEntry => changeEntry.trim()) +} + +export function updateRuleVersions({ ruleVersions, ruleId, proposedDate, w3cDate, isoDate, changes = [] }) { + const existingVersions = ruleVersions[ruleId] ?? [] + const currentIndex = existingVersions.find(version => version.file === 'index.md') + const proposedVersion = existingVersions.find(version => version.file === 'proposed.md') ?? { + file: 'proposed.md', + url: `${ruleId}/proposed/`, + w3cDate: proposedDate.w3cDate, + isoDate: proposedDate.isoDate, + } + + const newIndex = { + file: 'index.md', + url: `${ruleId}/`, + w3cDate, + isoDate, + } + + if (!currentIndex) { + ruleVersions[ruleId] = [ + proposedVersion, + newIndex, + ...existingVersions.filter(version => version !== proposedVersion), + ] + return { isReapproval: false } + } + + assert(changes.length > 0, `Re-approval of ${ruleId} requires at least one changelog entry`) + assert(currentIndex.isoDate, `Existing index.md version for ${ruleId} must have an isoDate`) + + newIndex.changes = changes + const archivedIndex = { + ...currentIndex, + file: `${currentIndex.isoDate}.md`, + url: `${ruleId}/${currentIndex.isoDate}/`, + } + const otherVersions = existingVersions.filter(version => version !== proposedVersion && version !== currentIndex) + ruleVersions[ruleId] = [proposedVersion, newIndex, archivedIndex, ...otherVersions] + + return { isReapproval: true, previousIsoDate: currentIndex.isoDate } +} diff --git a/.github/scripts/update-rule-versions.test.mjs b/.github/scripts/update-rule-versions.test.mjs new file mode 100644 index 0000000000..1f09b858ea --- /dev/null +++ b/.github/scripts/update-rule-versions.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { parseChanges, updateRuleVersions } from './update-rule-versions.mjs' + +const dates = { + proposedDate: { w3cDate: '1 January 2024', isoDate: '2024-01-01' }, + w3cDate: '21 September 2026', + isoDate: '2026-09-21', +} + +test('adds first-time proposed and index versions without changes', () => { + const ruleVersions = {} + + const result = updateRuleVersions({ ruleVersions, ruleId: 'abc123', ...dates }) + + assert.deepEqual(result, { isReapproval: false }) + assert.deepEqual(ruleVersions.abc123, [ + { + file: 'proposed.md', + url: 'abc123/proposed/', + w3cDate: '1 January 2024', + isoDate: '2024-01-01', + }, + { + file: 'index.md', + url: 'abc123/', + w3cDate: '21 September 2026', + isoDate: '2026-09-21', + }, + ]) +}) + +test('keeps an existing proposed version on first-time approval', () => { + const proposed = { + file: 'proposed.md', + url: 'abc123/proposed/', + w3cDate: '2 February 2024', + isoDate: '2024-02-02', + } + const ruleVersions = { abc123: [proposed] } + + updateRuleVersions({ ruleVersions, ruleId: 'abc123', ...dates }) + + assert.equal(ruleVersions.abc123[0], proposed) + assert.equal(ruleVersions.abc123[1].file, 'index.md') +}) + +test('re-approval archives the current index and records explicit changes', () => { + const proposed = { + file: 'proposed.md', + url: 'abc123/proposed/', + w3cDate: '1 January 2024', + isoDate: '2024-01-01', + } + const current = { + file: 'index.md', + url: 'abc123/', + w3cDate: '3 March 2025', + isoDate: '2025-03-03', + changes: ['Old change'], + } + const older = { + file: '2024-04-04.md', + url: 'abc123/2024-04-04/', + w3cDate: '4 April 2024', + isoDate: '2024-04-04', + } + const ruleVersions = { abc123: [proposed, current, older] } + + const result = updateRuleVersions({ + ruleVersions, + ruleId: 'abc123', + changes: ['Clarify applicability', 'Add an example'], + ...dates, + }) + + assert.deepEqual(result, { isReapproval: true, previousIsoDate: '2025-03-03' }) + assert.deepEqual(ruleVersions.abc123, [ + proposed, + { + file: 'index.md', + url: 'abc123/', + w3cDate: '21 September 2026', + isoDate: '2026-09-21', + changes: ['Clarify applicability', 'Add an example'], + }, + { + ...current, + file: '2025-03-03.md', + url: 'abc123/2025-03-03/', + }, + older, + ]) +}) + +test('re-approval rejects an empty changelog', () => { + const ruleVersions = { + abc123: [{ file: 'index.md', isoDate: '2025-03-03', w3cDate: '3 March 2025' }], + } + + assert.throws( + () => updateRuleVersions({ ruleVersions, ruleId: 'abc123', ...dates }), + /requires at least one changelog entry/ + ) +}) + +test('combines a YAML changes file with repeated change flags', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'approve-rule-changes-')) + const changesFile = path.join(directory, 'changes.yml') + fs.writeFileSync(changesFile, '- Change from file one\n- Change from file two\n') + + try { + assert.deepEqual( + parseChanges({ + changesFile, + change: ['Change from flag one', 'Change from flag two'], + }), + ['Change from file one', 'Change from file two', 'Change from flag one', 'Change from flag two'] + ) + } finally { + fs.rmSync(directory, { recursive: true }) + } +}) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 235ea968c1..30ea18ecf1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,3 +24,4 @@ jobs: - run: | npm install npm test + npm run test:approve-rule diff --git a/README.md b/README.md index 632b3b7c65..e94de595e5 100644 --- a/README.md +++ b/README.md @@ -34,5 +34,7 @@ For info on how to use this GitHub repository, see the [ACT-Rules GitHub Guideli This repository automatically pushes changes to rules to the [w3c/wcag-act-rules](https://github.com/w3c/wcag-act-rules/) repository. There is an "Approve rule" action available which can be triggered manually by an ACT Task Force facilitator, which will set a proposed rule to "approved". +Rule updates require an explicit changelog when using the approval script. Pass entries with repeated `--change "..."` flags, with `--changesFile path.yaml` containing a YAML list of strings, or with both. First-time approvals do not require a changelog. + [wcag22]: https://www.w3.org/TR/WCAG22/ [act-r]: https://www.w3.org/community/act-r/ diff --git a/package.json b/package.json index 468cc510c1..5c8a788625 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,7 @@ "formatRulesDir": "prettier --write './_rules/**/*.md'", "format": "prettier --write *.{json,md,js,html,css,yml} './{__tests__,_rules,.github,pages,test-assets,test-utils,utils}/**/*.{json,md,js,html,css,yml}'", "test": "jest --coverage", + "test:approve-rule": "node --test './.github/scripts/*.test.mjs'", "build:wai": "zx .github/scripts/wai-build.mjs", "pr:preview": "zx .github/scripts/pr-preview.mjs" }, @@ -218,7 +219,8 @@ "testPathIgnorePatterns": [ "/test-utils/", "/.cache/", - "/.public/" + "/.public/", + "/.github/scripts/" ] }, "lint-staged": {