From 75ec902935fcdbe7cb8c9fd9dd89ebaf01a5e722 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Wed, 9 Sep 2026 15:22:34 -0500 Subject: [PATCH 001/100] fix(scripts): keep generated audit JSON output parseable --- .../scripts/generated-audit-wrappers.test.cjs | 156 ++++++++++++++++++ docs/generated-child-theme-contract.md | 14 ++ package.json | 1 + whisk/docs/development.md | 33 ++++ whisk/package.json | 4 +- 5 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/generated-audit-wrappers.test.cjs diff --git a/.github/scripts/generated-audit-wrappers.test.cjs b/.github/scripts/generated-audit-wrappers.test.cjs new file mode 100644 index 0000000..d9481a9 --- /dev/null +++ b/.github/scripts/generated-audit-wrappers.test.cjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +// Opt-in integration: generate the real child theme and install a packed Core +// artifact. No network installation is added to the ordinary contract tests. +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const { createHash } = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const repoRoot = path.resolve(__dirname, '../..'); +const excluded = new Set(['.git', 'node_modules', 'dist', '.out', '.coverage', '.cache']); +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'; + +function writeJson(file, value) { + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function run(command, args, cwd, env) { + const result = spawnSync(command, args, { + cwd, + env, + encoding: 'utf8', + timeout: 180000, + maxBuffer: 8 * 1024 * 1024, + }); + assert.ifError(result.error); + assert.equal(result.signal, null, `Unexpected signal: ${result.signal}`); + return result; +} + +function assertWrapper(result, direct, footer) { + assert.equal(result.status, direct.status, 'Wrapper must preserve the exact Core exit status.'); + let report; + assert.doesNotThrow(() => { + // Parsing a substring or dropping a footer would conceal this regression. + report = JSON.parse(result.stdout); + }, 'The entire stdout stream must be valid JSON.'); + assert.deepEqual(report, JSON.parse(direct.stdout), 'Wrapper must forward every argument unchanged.'); + assert.equal(report.schemaVersion, 1); + assert.equal(report.tool.name, '@emulsify/core'); + assert.equal(result.stderr, `${direct.stderr}\n${footer}\n`, 'The exact documentation footer belongs on stderr.'); + assert.ok(!result.stdout.includes(footer), 'Documentation footer must not enter stdout.'); +} + +test('real generated audit wrappers retain the packed Core CLI contract', { timeout: 240000 }, async (t) => { + assert.ok(process.env.EMULSIFY_CORE_TARBALL, 'Set EMULSIFY_CORE_TARBALL to a local npm pack .tgz artifact.'); + const tarball = path.resolve(process.env.EMULSIFY_CORE_TARBALL); + const tarballHash = createHash('sha256').update(fs.readFileSync(tarball)).digest('hex'); + const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-generated-audit-')); + const consumer = path.join(workRoot, 'generated child with spaces'); + const env = { + ...process.env, + CI: '1', + FORCE_COLOR: '0', + PUPPETEER_SKIP_DOWNLOAD: 'true', + npm_config_cache: process.env.npm_config_cache || path.join(workRoot, 'npm-cache'), + }; + + try { + fs.cpSync(path.join(repoRoot, 'whisk'), consumer, { + recursive: true, + filter: (source) => !excluded.has(path.basename(source)), + }); + const projectPath = path.join(consumer, 'project.emulsify.json'); + const project = JSON.parse(fs.readFileSync(projectPath, 'utf8')); + Object.assign(project.project, { + name: 'Audit Fixture', machineName: 'audit-fixture', description: 'Generated audit wrapper contract.', + }); + writeJson(projectPath, project); + const generation = run(process.execPath, [path.join(consumer, '.cli/init.js')], consumer, env); + assert.equal(generation.status, 0, generation.stderr); + const packagePath = path.join(consumer, 'package.json'); + const generated = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + const source = JSON.parse(fs.readFileSync(path.join(repoRoot, 'whisk/package.json'), 'utf8')); + assert.deepEqual(generated.scripts, source.scripts, 'Generation must retain the real starter scripts.'); + const install = run(npm, ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', '--save-exact', tarball], consumer, env); + assert.equal(install.status, 0, install.stderr); + const installed = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + assert.deepEqual(installed.scripts, generated.scripts); + const coreRoot = path.join(consumer, 'node_modules/@emulsify/core'); + assert.equal(fs.lstatSync(coreRoot).isSymbolicLink(), false, 'Use an installed package, not a source symlink.'); + const coreVersion = JSON.parse(fs.readFileSync(path.join(coreRoot, 'package.json'), 'utf8')).version; + t.diagnostic(JSON.stringify({ coreVersion, tarballSha256: tarballHash, generatedScripts: { audit: generated.scripts.audit, 'audit:twig-stories': generated.scripts['audit:twig-stories'] } })); + + const auditRoot = path.join(workRoot, 'audit root with spaces'); + const component = path.join(auditRoot, 'src/components/card'); + fs.mkdirSync(component, { recursive: true }); + writeJson(path.join(auditRoot, 'project.emulsify.json'), { project: { platform: 'none' } }); + fs.writeFileSync(path.join(component, 'card.twig'), '
{{ title }}
'); + fs.writeFileSync(path.join(component, 'card.stories.js'), 'import cardTwig from "./card.twig";\nexport const Card = (args) => cardTwig(args);\n'); + const cleanRoot = path.join(workRoot, 'clean root with spaces'); + const cleanComponent = path.join(cleanRoot, 'src/components/clean'); + fs.mkdirSync(cleanComponent, { recursive: true }); + writeJson(path.join(cleanRoot, 'project.emulsify.json'), { project: { platform: 'none' } }); + fs.writeFileSync(path.join(cleanComponent, 'clean.stories.js'), 'export default { title: "Audit/Clean" };\nexport const Clean = () => "Clean
";\n'); + + for (const [script, executable, footer, threshold] of [ + ['audit', 'audit.js', 'Audit docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#storybook-migration', ['--fail-on', 'warn']], + ['audit:twig-stories', 'audit-twig-stories.js', 'Migration docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/storybook.md#legacy-twig-story-compatibility', ['--fail-on-found']], + ]) { + const args = ['--root', auditRoot, '--json']; + const directRun = (options) => run(path.join(coreRoot, 'scripts', executable), options, consumer, env); + const wrapperRun = (options) => run(npm, ['run', script, '--silent', '--', ...options], consumer, env); + for (const [scenario, options, expectedStatus] of [ + ['clean scan', ['--root', cleanRoot, '--json', ...threshold], 0], + ['advisory findings', args, 0], + ['findings fail threshold', [...args, ...threshold], 1], + ['invalid argument', [...args, '--unknown-wrapper-option'], 2], + ]) { + await t.test(`${script}: ${scenario}`, () => { + const direct = directRun(options); + assert.equal(direct.status, expectedStatus, direct.stderr); + const report = JSON.parse(direct.stdout); + if (scenario === 'clean scan') { + assert.deepEqual(report.findings, []); + } else if (expectedStatus !== 2) { + assert.ok(report.findings.some((finding) => finding.id === 'legacy-twig-story' && finding.path === 'src/components/card/card.stories.js' && finding.line === 2 && finding.severity === 'warn')); + } + const wrapped = wrapperRun(options); + assertWrapper(wrapped, direct, footer); + const executableName = executable === 'audit.js' ? 'emulsify-audit' : 'emulsify-audit-twig-stories'; + const directAlias = run(npx, ['--no-install', executableName, ...options], consumer, env); + assert.equal(directAlias.status, expectedStatus); + assert.deepEqual(JSON.parse(directAlias.stdout), report); + assert.equal(directAlias.stderr, direct.stderr); + t.diagnostic(JSON.stringify({ script, scenario, options, directStatus: direct.status, wrapperStatus: wrapped.status, npxNoInstallStatus: directAlias.status, wholeStdoutJson: true, footerOnStderr: true })); + }); + } + + for (const [scenario, mutate, expectedFailure] of [ + ['rejects stdout footer', (command) => command.replace(' >&2;', ';'), /entire stdout stream/], + ['rejects masked status', (command) => command.replace('exit $status', 'exit 0'), /exact Core exit status/], + ['rejects dropped arguments', (command) => command.replace('"$@"', ''), /exact Core exit status|forward every argument/], + ]) { + await t.test(`${script}: ${scenario}`, () => { + const modified = structuredClone(installed); + modified.scripts[script] = mutate(modified.scripts[script]); + assert.notEqual(modified.scripts[script], installed.scripts[script]); + writeJson(packagePath, modified); + try { + const options = [...args, ...threshold]; + assert.throws(() => assertWrapper(wrapperRun(options), directRun(options), footer), expectedFailure); + } finally { + writeJson(packagePath, installed); + } + }); + } + } + } finally { + fs.rmSync(workRoot, { recursive: true, force: true }); + } +}); diff --git a/docs/generated-child-theme-contract.md b/docs/generated-child-theme-contract.md index 42c54ce..71f2cb6 100644 --- a/docs/generated-child-theme-contract.md +++ b/docs/generated-child-theme-contract.md @@ -52,6 +52,20 @@ npm run release:check -- --skip-smoke `npm run test:generated-theme` runs focused contract tests that generate a throwaway theme and then mutate it to prove each class of failure is detected. `npm run smoke:generation-parity` generates through both paths and compares them; it requires a PHP binary and skips with a warning when none is available. +To verify generated audit wrappers against an actual packed Core artifact, run: + +```sh +EMULSIFY_CORE_TARBALL=/absolute/path/emulsify-core-4.5.0.tgz npm run test:audit-wrappers +``` + +This opt-in test generates a temporary child theme and installs that package +without installer hooks. It compares both wrappers with the installed Core +executables, checks complete JSON stdout, stderr footers, exit codes 0/1/2, and +arguments containing spaces. Negative controls prove that stdout footers, +masked failures, and dropped arguments are detected. It requires Node 24 and +registry access for the packed package's dependencies; it leaves the source +theme and dependency ranges unchanged. + The full release check additionally exercises a real WordPress fixture: ```bash diff --git a/package.json b/package.json index 2bbf13f..2984546 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "pr:check": "node .github/scripts/pr-validation.cjs", "release:check": "node .github/scripts/release-check.cjs", "test:generated-theme": "node --test .github/scripts/generated-theme-contract.test.cjs", + "test:audit-wrappers": "node --test .github/scripts/generated-audit-wrappers.test.cjs", "smoke:acf-json": "php .github/scripts/acf-local-json-smoke.php", "smoke:asset-manifest": "php .github/scripts/asset-manifest-smoke.php", "smoke:attributes": "php .github/scripts/attribute-helper-smoke.php", diff --git a/whisk/docs/development.md b/whisk/docs/development.md index 5e4da05..8246bb1 100644 --- a/whisk/docs/development.md +++ b/whisk/docs/development.md @@ -91,6 +91,39 @@ npm run a11y `npm run test` succeeds when no tests have been added yet. The accessibility check builds Storybook first, so it takes longer than lint or unit tests. +For machine-readable project audits, silence npm's command banner and keep the +documentation footer on stderr: + +```bash +npm run audit --silent -- --json +npm run audit:twig-stories --silent -- --json +``` + +Both wrappers preserve Core's exit status and forward arguments unchanged. Use +`npm run audit --silent -- --json --fail-on warn` or +`npm run audit:twig-stories --silent -- --json --fail-on-found` to fail when +migration findings are present. Quote paths containing spaces, for example +`npm run audit --silent -- --root "../theme source" --json`. Existing generated +themes must copy the updated audit scripts from the current starter; updating +Core alone does not change a project's `package.json`. + +To update an existing wrapper, keep `"$@"`, the `status=$?` immediately after +Core runs, and `exit $status`. Add only `>&2` after the footer's `printf`: + +```diff +- printf "\nAudit docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#storybook-migration\n"; exit $status ++ printf "\nAudit docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#storybook-migration\n" >&2; exit $status +``` + +Apply the same redirection to `audit:twig-stories`, retaining its existing +`Migration docs:` footer. Alternatively, bypass the project wrappers and run +the installed Core executables directly without downloading another version: + +```bash +npx --no-install emulsify-audit --json +npx --no-install emulsify-audit-twig-stories --json +``` + ## Project ownership ### Component library diff --git a/whisk/package.json b/whisk/package.json index 787012a..0975ce9 100644 --- a/whisk/package.json +++ b/whisk/package.json @@ -18,8 +18,8 @@ "type": "module", "scripts": { "a11y": "npm run storybook-build && node config/emulsify-core/run-a11y.js", - "audit": "sh -c 'node_modules/@emulsify/core/scripts/audit.js \"$@\"; status=$?; printf \"\\nAudit docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#storybook-migration\\n\"; exit $status' --", - "audit:twig-stories": "sh -c 'node_modules/@emulsify/core/scripts/audit-twig-stories.js \"$@\"; status=$?; printf \"\\nMigration docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/storybook.md#legacy-twig-story-compatibility\\n\"; exit $status' --", + "audit": "sh -c 'node_modules/@emulsify/core/scripts/audit.js \"$@\"; status=$?; printf \"\\nAudit docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#storybook-migration\\n\" >&2; exit $status' --", + "audit:twig-stories": "sh -c 'node_modules/@emulsify/core/scripts/audit-twig-stories.js \"$@\"; status=$?; printf \"\\nMigration docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/storybook.md#legacy-twig-story-compatibility\\n\" >&2; exit $status' --", "build": "npm run ensure-dist && vite build --config node_modules/@emulsify/core/config/vite/vite.config.js", "coverage": "npm run test && open-cli .coverage/lcov-report/index.html", "develop": "npm run ensure-dist && concurrently --raw --no-shell npm:vite npm:storybook", From afb8591346c34a8dfc710c2c47e93c95fe2d0d18 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 15:49:46 -0500 Subject: [PATCH 002/100] fix(release): support prepared stable releases --- .github/scripts/release-check.cjs | 88 +++----- .github/scripts/release-version-contract.cjs | 100 +++++++++ .../scripts/release-version-contract.test.cjs | 166 ++++++++++++++ .github/scripts/semantic-release.cjs | 202 ++++++------------ .github/scripts/semantic-release.test.cjs | 118 ++++++++++ .github/workflows/theme-readiness.yml | 4 + README.md | 2 +- UPGRADE.md | 9 + docs/release-notes-next.md | 29 ++- docs/release-process.md | 63 +++++- release.config.js | 129 +---------- 11 files changed, 580 insertions(+), 330 deletions(-) create mode 100644 .github/scripts/release-version-contract.cjs create mode 100644 .github/scripts/release-version-contract.test.cjs create mode 100644 .github/scripts/semantic-release.test.cjs diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 4cf3b52..ec8fcd1 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -7,8 +7,10 @@ const fs = require('fs'); const path = require('path'); const childProcess = require('child_process'); +const { validateReleaseVersionContract } = require('./release-version-contract.cjs'); const repoRoot = path.resolve(__dirname, '../..'); +const skipSmoke = process.argv.slice(2).includes('--skip-smoke'); const results = []; function addResult(status, name, detail) { @@ -46,8 +48,8 @@ function runStaticCheck(name, callback) { } function runCommandCheck(name, command, args) { - // Command checks are reserved for smoke paths that already know how to skip - // when local prerequisites such as WP-CLI or MySQL are absent. + // Focused contract tests always run. Runtime smoke commands can also report + // missing local prerequisites such as WP-CLI or MySQL as an explicit skip. const result = childProcess.spawnSync(command, args, { cwd: repoRoot, encoding: 'utf8', @@ -299,33 +301,12 @@ function runStaticChecks() { const license = readFile('LICENSE'); const issueTemplate = readFile('.github/ISSUE_TEMPLATE.md'); const pullRequestTemplate = readFile('.github/PULL_REQUEST_TEMPLATE.md'); - const releaseGuardRejectContext = { - branch: { name: 'main' }, - lastRelease: { version: '1.0.0' }, - nextRelease: { version: '1.1.0' }, - }; - const releaseGuardAcceptContext = { - branch: { name: 'main' }, - lastRelease: { version: '1.0.0' }, - nextRelease: { version: '2.0.0' }, - }; - const releaseGuardFutureContext = { - branch: { name: 'main' }, - lastRelease: { version: '2.0.0' }, - nextRelease: { version: '2.0.1' }, - }; - const releaseAnalyzerAlphaContext = { - branch: { name: 'main' }, - lastRelease: { version: '1.0.0-alpha.5' }, - }; - const releaseAnalyzerNoStableContext = { - branch: { name: 'main' }, - lastRelease: {}, - }; - runStaticCheck('Required files', () => { const requiredFiles = [ '.github/scripts/release-check.cjs', + '.github/scripts/release-version-contract.cjs', + '.github/scripts/release-version-contract.test.cjs', + '.github/scripts/semantic-release.test.cjs', '.github/scripts/docs-command-check.cjs', '.github/scripts/pr-validation.cjs', '.github/scripts/semantic-release.cjs', @@ -438,10 +419,17 @@ function runStaticChecks() { return `Found ${requiredFiles.length} required release files.`; }); + runStaticCheck('Release version contract', () => { + const { errors, packageVersion, latestStableTag, releasePrepared } = validateReleaseVersionContract({ repoRoot }); + ensure(errors.length === 0, errors.join('\n')); + return releasePrepared + ? `Prepared version ${packageVersion} follows latest stable tag ${latestStableTag}.` + : `Package version ${packageVersion} matches latest stable tag ${latestStableTag}; no release is prepared.`; + }); + runStaticCheck('Root release metadata', () => { ensure(rootPackage.name === 'emulsify-wordpress', 'package.json name should be emulsify-wordpress.'); ensure(semver(rootPackage.version), 'package.json version must be a valid semver string.'); - ensure(rootPackage.version === releaseConfig.expectedStableRelease, `package.json version should prepare the ${releaseConfig.expectedStableRelease} release.`); ensure(rootPackage.description, 'package.json description is required.'); ensureParentThemeLanguage('package.json description', rootPackage.description); ensureNoTitleCaseBuildPhrase('package.json description', rootPackage.description); @@ -1208,7 +1196,6 @@ function runStaticChecks() { const analyzerOptions = getReleasePluginOptions(releaseConfig, '@semantic-release/commit-analyzer'); const notesOptions = getReleasePluginOptions(releaseConfig, '@semantic-release/release-notes-generator'); const githubOptions = getReleasePluginOptions(releaseConfig, '@semantic-release/github'); - const releaseAnalyzer = releaseConfig.plugins.find((plugin) => plugin && typeof plugin.analyzeCommits === 'function'); const releaseGuard = releaseConfig.plugins.find((plugin) => plugin && typeof plugin.verifyRelease === 'function'); const readinessJobStart = semanticReleaseWorkflow.indexOf(' release-readiness:'); const releaseJobStart = semanticReleaseWorkflow.indexOf(' semantic-release:'); @@ -1219,20 +1206,14 @@ function runStaticChecks() { const releaseAsset = Array.isArray(githubOptions.assets) ? githubOptions.assets.find((asset) => asset.path === 'dist-artifact/emulsify.zip') : null; - ensure(releaseConfig.expectedStableRelease === '2.0.0', 'release.config.js should declare 2.0.0 as the expected stable release.'); - ensure(releaseConfig.initialStableBaseline === '1.0.0', 'release.config.js should declare the temporary 1.0.0 stable baseline.'); ensure(releaseConfig.tagFormat === '${version}', 'release.config.js should emit non-prefixed semver tags.'); ensure(releaseConfig.repositoryUrl === 'git@github.com:emulsify-ds/emulsify-wordpress.git', 'release.config.js should publish against emulsify-wordpress.'); ensure(Array.isArray(releaseConfig.branches), 'release.config.js branches must be an array.'); ensure(releaseConfig.branches.length === 1 && releaseConfig.branches[0] === 'main', 'release.config.js should publish only from main.'); - ensure(releaseAnalyzer, 'release.config.js should force the first stable release to a major release type.'); - ensure(releaseGuard, 'release.config.js should guard the first stable release version.'); + ensure(!releaseConfig.plugins.some((plugin) => ['@semantic-release/npm', '@semantic-release/git'].includes(Array.isArray(plugin) ? plugin[0] : plugin)), 'release.config.js must not rewrite committed versions through @semantic-release/npm or @semantic-release/git.'); + ensure(releaseGuard, 'release.config.js should require the computed release version to match the prepared package version.'); ensure(rootPackage.scripts.publish.startsWith('node .github/scripts/semantic-release.cjs '), 'The publish script should prepare semantic-release through the guarded runner.'); ensure(rootPackage.scripts['publish-test'].startsWith('node .github/scripts/semantic-release.cjs '), 'The publish-test script should use the same guarded semantic-release runner.'); - ensure(semanticReleaseRunner.includes("git(['tag', initialStableBaseline, sourceSha])"), 'The semantic-release runner should expose the latest prerelease commit as a temporary stable baseline.'); - ensure(semanticReleaseRunner.includes('finally') && semanticReleaseRunner.includes('removeStableBaseline(temporaryBaseline)'), 'The semantic-release runner should always clean up its temporary baseline tag.'); - ensure(releaseGuard.verifyRelease.toString().includes('removeSyntheticBaseline'), 'The stable release guard should remove the temporary baseline before semantic-release pushes tags.'); - ensure(typeof notesOptions.writerOpts?.finalizeContext === 'function', 'Release notes should suppress the inaugural compare link to the temporary baseline tag.'); ensure(releaseAsset, 'release.config.js should attach dist-artifact/emulsify.zip to GitHub releases.'); ensure(releaseAsset.label === 'Emulsify WordPress theme (with dependencies)', 'release.config.js should give the installable ZIP a clear release label.'); ensureBreakingParser('@semantic-release/commit-analyzer', analyzerOptions.parserOpts); @@ -1242,6 +1223,7 @@ function runStaticChecks() { ensure(semanticReleaseWorkflow.includes('contents: write'), 'semantic-release.yml should grant GitHub release permissions explicitly.'); ensure(semanticReleaseWorkflow.includes('id: semantic'), 'semantic-release.yml should expose the semantic-release step as steps.semantic.'); ensure(semanticReleaseWorkflow.includes('npm run release:check'), 'semantic-release.yml should run local release readiness checks.'); + ensure(readinessJob.includes('fetch-depth: 0') && releaseJob.includes('fetch-depth: 0'), 'Release readiness and publishing should fetch full history and tags for the release version contract.'); ensure(semanticReleaseWorkflow.includes('wp-cli'), 'semantic-release.yml should install WP-CLI for the WordPress smoke fixture.'); ensure(semanticReleaseWorkflow.includes('mysql:'), 'semantic-release.yml should provide a MySQL service for the WordPress smoke fixture.'); ensure(semanticReleaseWorkflow.includes('WP_SMOKE_DB_HOST'), 'semantic-release.yml should pass WordPress smoke database settings.'); @@ -1252,21 +1234,8 @@ function runStaticChecks() { ensure(releaseJob.includes('tools: composer:v2'), 'semantic-release.yml release job should install Composer.'); ensure(buildStep >= 0, 'semantic-release.yml release job should build the installable theme archive.'); ensure(publishStep >= 0 && buildStep < publishStep, 'semantic-release.yml should build the installable theme archive before publishing.'); - try { - releaseGuard.verifyRelease({}, releaseGuardRejectContext); - throw new Error('release.config.js release guard should reject pre-2.0.0 releases.'); - } - catch (error) { - const expectedMessage = error.message.includes('Expected semantic-release to prepare 2.0.0'); - ensure(expectedMessage, 'release.config.js release guard should explain the expected 2.0.0 release.'); - } - releaseGuard.verifyRelease({}, releaseGuardAcceptContext); - releaseGuard.verifyRelease({}, releaseGuardFutureContext); - ensure(releaseAnalyzer.analyzeCommits({}, releaseAnalyzerAlphaContext) === 'major', 'release.config.js should force a major release from the latest alpha tag.'); - ensure(releaseAnalyzer.analyzeCommits({}, releaseAnalyzerNoStableContext) === 'major', 'release.config.js should force a major release when semantic-release finds no previous stable release.'); - ensure(releaseAnalyzer.analyzeCommits({}, releaseGuardRejectContext) === 'major', 'release.config.js should force a major release before 2.0.0 exists.'); - ensure(releaseAnalyzer.analyzeCommits({}, releaseGuardFutureContext) === null, 'release.config.js should use normal commit analysis after 2.0.0 exists.'); - return 'Semantic release is configured for non-prefixed tags, main-only publishing, a forced 2.0.0 stable release, and the installable ZIP asset.'; + ensure(semanticReleaseRunner.includes('Refusing to publish local-only stable tag(s)'), 'The guarded runner should refuse to push unpublished local stable tags.'); + return 'Semantic release requires a prepared package version, non-prefixed tags, main-only publishing, and the tested installable ZIP asset.'; }); runStaticCheck('Installable release artifact', () => { @@ -1321,6 +1290,7 @@ function runStaticChecks() { ensure(themeReadinessWorkflow.includes('npm run lint:php'), 'theme-readiness.yml should run PHP lint.'); ensure(themeReadinessWorkflow.includes('npm run pr:check'), 'theme-readiness.yml should delegate project smoke checks to npm run pr:check.'); ensure(themeReadinessWorkflow.includes('npm run release:check'), 'theme-readiness.yml should run release readiness checks.'); + ensure(practicalJob.includes('fetch-depth: 0') && wordpressFixtureJob.includes('fetch-depth: 0'), 'Readiness jobs running the release version contract should fetch full history and tags.'); ensure(practicalJob.includes("- name: Build installable theme archive\n if: matrix.php-version == '8.3'\n run: npm run build:dist"), 'The PHP 8.3 practical lane should build the client-testable release archive.'); ensure(practicalJob.includes("- name: Upload installable theme archive\n if: matrix.php-version == '8.3'\n uses: actions/upload-artifact@v7"), 'The PHP 8.3 practical lane should use direct-file artifact upload.'); ensure(practicalJob.includes('path: dist-artifact/emulsify.zip') && practicalJob.includes('archive: false') && practicalJob.includes('overwrite: true'), 'Theme readiness should upload the installable ZIP directly and support workflow reruns.'); @@ -1396,7 +1366,7 @@ function runStaticChecks() { 'docs/post-2x-optimization-roadmap.md', ]; - ensure(readme.includes('Emulsify WordPress 2.0.0 is a Timber-first WordPress parent theme'), 'README.md should describe the 2.0.0 Timber-first parent theme.'); + ensure(readme.includes('Emulsify WordPress is a Timber-first WordPress parent theme'), 'README.md should describe the Timber-first parent theme.'); ensure(readme.includes('Emulsify WordPress is licensed under GPL-2.0-only'), 'README.md should document the GPL-2.0-only license.'); ensure(readme.includes('[LICENSE](LICENSE)'), 'README.md should link to the repository license file.'); ensure(readme.includes('## Requirements'), 'README.md should keep requirements visible.'); @@ -1534,7 +1504,7 @@ function runStaticChecks() { ensure(docs.cli.includes('Force replacement safety') && docs.cli.includes('Emulsify-generated child theme markers'), 'WP-CLI doc should document force replacement safety.'); ensure(docs.cli.includes('Upgrade and support diagnostics') && docs.cli.includes('generatedFromVersion'), 'WP-CLI doc should document generated child theme lineage diagnostics.'); ensure(docs.cli.includes('Ignored dependency, cache, Storybook, and Vite output directories are not copied'), 'WP-CLI doc should explain that generated themes do not inherit build output.'); - ensure(docs.release.includes('release-2.x') && docs.release.includes('2.0.0'), 'Release process doc should document the release-2.x target release.'); + ensure(docs.release.includes('## Release PR procedure') && docs.release.includes('prepared version'), 'Release process doc should explain preparing a version in a release PR.'); ensure(docs.release.includes('Packagist lists `emulsify-ds/emulsify-wordpress-theme` as maintained'), 'Release process doc should require the published Composer package to be un-abandoned.'); ensure(docs.release.includes('WordPress Theme Readiness workflow'), 'Release process doc should document the theme readiness workflow.'); ensure(docs.index.includes('## Continuous integration') && docs.index.includes('dedicated PHPCS/PHPStan job') && docs.index.includes('Pull requests targeting `main` or `release-2.x` additionally run'), 'Docs index should summarize fast and release-targeted pull-request readiness coverage.'); @@ -1611,7 +1581,7 @@ function runStaticChecks() { ensure(issueTemplate.includes('emulsify-wordpress/releases'), 'Issue template should link to WordPress theme releases.'); ensure(pullRequestTemplate.includes('emulsify-wordpress/issues/1'), 'Pull request template should link to WordPress theme issues.'); ensure(!/emulsify-drupal/.test(`${issueTemplate}\n${pullRequestTemplate}`), 'GitHub templates should not link to the Drupal repository.'); - return 'README, docs, and GitHub templates match the WordPress 2.0.0 release story.'; + return 'README, docs, and GitHub templates match the WordPress parent theme release process.'; }); runStaticCheck('License metadata', () => { @@ -1654,7 +1624,15 @@ function runStaticChecks() { return 'No .DS_Store files were found outside .git.'; }); - runCommandCheck('WordPress fixture smoke', process.execPath, ['.github/scripts/wordpress-fixture-smoke.cjs']); + runCommandCheck('Release version contract tests', process.execPath, ['--test', '.github/scripts/release-version-contract.test.cjs']); + runCommandCheck('Semantic release guard tests', process.execPath, ['--test', '.github/scripts/semantic-release.test.cjs']); + + if (skipSmoke) { + addResult('SKIP', 'WordPress fixture smoke', 'Skipped by --skip-smoke.'); + } + else { + runCommandCheck('WordPress fixture smoke', process.execPath, ['.github/scripts/wordpress-fixture-smoke.cjs']); + } } function printSummary() { diff --git a/.github/scripts/release-version-contract.cjs b/.github/scripts/release-version-contract.cjs new file mode 100644 index 0000000..b70a33f --- /dev/null +++ b/.github/scripts/release-version-contract.cjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +function stableVersionParts(version) { + if (typeof version !== 'string') return null; + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version); + if (!match || match[0] !== version) return null; + const parts = match.slice(1).map(Number); + return parts.every(Number.isSafeInteger) ? parts : null; +} + +function compareVersions(left, right) { + const leftParts = stableVersionParts(left); + const rightParts = stableVersionParts(right); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] !== rightParts[index]) return rightParts[index] - leftParts[index]; + } + return 0; +} + +function validateReleaseVersions({ tags = [], latestStableTag, packageVersion, documents = {} }) { + const errors = []; + const stableTags = tags.filter((tag) => stableVersionParts(tag)).sort(compareVersions); + latestStableTag ??= stableTags[0]; + const result = { errors, packageVersion, latestStableTag, stableTags, releasePrepared: false }; + + if (!stableVersionParts(latestStableTag)) { + errors.push('No stable release tag is available; fetch the full Git history and tags (fetch-depth: 0).'); + return result; + } + + if (!stableVersionParts(packageVersion)) { + errors.push('package.json version must use a canonical stable x.y.z version.'); + } else { + const [major, minor, patch] = stableVersionParts(latestStableTag); + const nextVersions = [`${major}.${minor}.${patch + 1}`, `${major}.${minor + 1}.0`, `${major + 1}.0.0`]; + if (packageVersion !== latestStableTag && (tags.includes(packageVersion) || !nextVersions.includes(packageVersion))) { + errors.push(`package.json version must equal the latest stable tag ${latestStableTag} or an untagged next patch, minor, or major version (${nextVersions.join(', ')}).`); + } + } + + const releasedVersions = new Set(tags); + const draft = documents['docs/release-notes-next.md'] || ''; + for (const line of draft.split(/\r?\n/)) { + // Draft headings and release titles claim a version; historical prose and + // compatibility requirements elsewhere in the draft do not. + if (!/^\s*(?:#{1,6}\s|Title\s*:)/i.test(line)) continue; + for (const match of line.matchAll(/\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/g)) { + if (releasedVersions.has(match[1])) { + errors.push(`The next-release draft names already-tagged release ${match[1]}; keep the draft unreleased.`); + } + } + } + + for (const [file, text] of Object.entries(documents)) { + for (const match of text.matchAll(/(?:latest|current)\s+(?:(?:published|stable)\s+)?(?:release|tag|version)\s*:\s*[`*]*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/gi)) { + if (match[1] !== latestStableTag) { + errors.push(`${file} names a stale current release (${match[1]}); use the latest stable tag ${latestStableTag} or remove the claim.`); + } + } + } + + result.releasePrepared = errors.length === 0 && packageVersion !== latestStableTag; + return result; +} + +function validateReleaseVersionContract({ repoRoot = path.resolve(__dirname, '../..') } = {}) { + try { + const git = (...args) => execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + if (git('rev-parse', '--is-shallow-repository') !== 'false') { + return { errors: ['Release version checks require full Git history and tags (fetch-depth: 0).'], releasePrepared: false }; + } + const tags = git('tag', '--list').split(/\r?\n/).filter(Boolean); + if (!tags.some((tag) => stableVersionParts(tag))) return validateReleaseVersions({ tags }); + const read = (file) => fs.readFileSync(path.join(repoRoot, file), 'utf8'); + const documents = Object.fromEntries([ + 'README.md', + 'docs/release-notes-next.md', + 'docs/release-process.md', + ].map((file) => [file, read(file)])); + return validateReleaseVersions({ tags, packageVersion: JSON.parse(read('package.json')).version, documents }); + } catch (error) { + return { errors: [`Unable to validate the release version contract: ${error.message}`], releasePrepared: false }; + } +} + +if (require.main === module) { + const result = validateReleaseVersionContract(); + if (result.errors.length > 0) { + for (const error of result.errors) console.error(`FAIL ${error}`); + process.exitCode = 1; + } else { + console.log(`PASS Release version policy and unreleased draft (latest tag: ${result.latestStableTag}; package: ${result.packageVersion}).`); + } +} + +module.exports = { validateReleaseVersionContract, validateReleaseVersions }; diff --git a/.github/scripts/release-version-contract.test.cjs b/.github/scripts/release-version-contract.test.cjs new file mode 100644 index 0000000..e8dee24 --- /dev/null +++ b/.github/scripts/release-version-contract.test.cjs @@ -0,0 +1,166 @@ +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const test = require('node:test'); +const { validateReleaseVersionContract, validateReleaseVersions } = require('./release-version-contract.cjs'); + +function fixture(overrides = {}) { + return { + tags: ['1.0.0-alpha.4', '2.0.0', '1.9.9'], + packageVersion: '2.0.0', + documents: { + 'README.md': 'Emulsify WordPress is the parent theme. Current release: `2.0.0`.', + 'docs/release-notes-next.md': '# 2.1.0 Release Notes Draft\n\nRequires Core 4.3.2. Latest published tag: 2.0.0.', + 'docs/release-process.md': 'Review commits since the latest tag.', + }, + ...overrides, + }; +} + +function validate(overrides) { + return validateReleaseVersions(fixture(overrides)); +} + +test('accepts the latest tag and exact untagged next patch, minor, or major', () => { + for (const packageVersion of ['2.0.0', '2.0.1', '2.1.0', '3.0.0']) { + const result = validate({ packageVersion }); + assert.deepEqual(result.errors, []); + assert.equal(result.latestStableTag, '2.0.0'); + assert.equal(result.releasePrepared, packageVersion !== '2.0.0'); + } +}); + +test('rejects old versions and skipped increments', () => { + for (const packageVersion of ['1.9.9', '1.9.8', '2.0.2', '2.1.1', '2.2.0', '3.0.1', '4.0.0']) { + const result = validate({ packageVersion }); + assert.match(result.errors.join('\n'), /untagged next patch, minor, or major/); + assert.equal(result.releasePrepared, false); + } +}); + +test('rejects a proposed next version that is already tagged', () => { + for (const packageVersion of ['2.0.1', '2.1.0', '3.0.0']) { + assert.match(validate({ + tags: ['2.0.0', packageVersion], + latestStableTag: '2.0.0', + packageVersion, + }).errors.join('\n'), /untagged next patch, minor, or major/); + } +}); + +test('rejects prereleases and noncanonical package versions', () => { + for (const packageVersion of [ + undefined, null, 2.1, '', '2', '2.1', 'v2.1.0', '2.1.0-beta.1', + '2.1.0+build.1', '2.1.0.1', '02.1.0', '2.01.0', '2.1.00', ' 2.1.0', '2.1.0\n', + '9007199254740992.0.0', + ]) { + assert.match(validate({ packageVersion }).errors.join('\n'), /canonical stable x.y.z/); + } +}); + +test('finds the latest stable unprefixed tag using numeric version order', () => { + const result = validate({ + tags: ['2.9.0', 'v10.0.0', '2.10.0-beta.1', '2.10.0', '02.20.0'], + packageVersion: '2.10.0', + documents: {}, + }); + assert.deepEqual(result.errors, []); + assert.equal(result.latestStableTag, '2.10.0'); + assert.deepEqual(result.stableTags, ['2.10.0', '2.9.0']); +}); + +test('fails clearly when full tags contain no stable release', () => { + for (const tags of [[], ['v2.0.0', '2.1.0-beta.1']]) { + assert.match(validate({ tags }).errors.join('\n'), /No stable release tag.*full Git history and tags/); + } +}); + +test('rejects already-shipped draft headings and release titles, including prereleases', () => { + for (const draft of [ + '# 2.0.0 Release Notes Draft', + '## 1.9.9', + '# Unreleased\nTitle: `emulsify 2.0.0`', + '# Unreleased\nTitle: `v1.9.9`', + '# 1.0.0-alpha.4 Release Notes', + ]) { + assert.match(validate({ documents: { 'docs/release-notes-next.md': draft } }).errors.join('\n'), /already-tagged release/); + } +}); + +test('accepts historical upgrade headings, compatibility text, and current published-tag labels', () => { + assert.deepEqual(validate({ documents: { + 'UPGRADE.md': '## Upgrading from 1.9.9 to 2.0.0', + 'docs/release-notes-next.md': '# Unreleased\nRequires Core 4.3.2. Follow the upgrade notes from 2.0.0.\nLatest published tag: `2.0.0`.', + } }).errors, []); +}); + +test('rejects stale latest/current release claims in each release document', () => { + for (const file of ['README.md', 'docs/release-notes-next.md', 'docs/release-process.md']) { + for (const claim of [ + 'Latest release: 1.9.9', 'Current release: **1.9.9**', 'Latest stable tag: 1.9.9', + 'Latest published tag: `1.9.9`', 'current version: v1.9.9', + 'Latest release: 2.0.0-beta.1', + ]) { + assert.match(validate({ documents: { [file]: claim } }).errors.join('\n'), /stale current release/); + } + } +}); + +test('keeps a prepared package valid after its tag is published', () => { + assert.deepEqual(validate({ packageVersion: '2.1.0', documents: {} }).errors, []); + const result = validate({ tags: ['2.1.0', '2.0.0'], packageVersion: '2.1.0', documents: {} }); + assert.deepEqual(result.errors, []); + assert.equal(result.releasePrepared, false); + assert.match(validate({ tags: ['2.1.0', '2.0.0'], packageVersion: '2.0.0', documents: {} }).errors.join('\n'), /latest stable tag 2.1.0/); +}); + +function git(repoRoot, ...args) { + return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +function repositoryFixture(t) { + const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-release-version-')); + t.after(() => fs.rmSync(workRoot, { recursive: true, force: true })); + const repoRoot = path.join(workRoot, 'repository'); + fs.mkdirSync(path.join(repoRoot, 'docs'), { recursive: true }); + const { documents, packageVersion } = fixture(); + for (const [file, contents] of Object.entries(documents)) fs.writeFileSync(path.join(repoRoot, file), contents); + fs.writeFileSync(path.join(repoRoot, 'package.json'), JSON.stringify({ version: packageVersion })); + git(repoRoot, 'init', '--quiet'); + git(repoRoot, 'add', '.'); + git(repoRoot, '-c', 'user.name=Release Test', '-c', 'user.email=release@example.test', 'commit', '--quiet', '-m', 'test: create release fixture'); + return { workRoot, repoRoot }; +} + +test('reads package metadata, real Git tags, and release documents from a repository', (t) => { + const { repoRoot } = repositoryFixture(t); + git(repoRoot, 'tag', '2.0.0'); + let result = validateReleaseVersionContract({ repoRoot }); + assert.deepEqual(result.errors, []); + assert.equal(result.latestStableTag, '2.0.0'); + assert.equal(result.packageVersion, '2.0.0'); + assert.equal(result.releasePrepared, false); + + fs.writeFileSync(path.join(repoRoot, 'README.md'), 'Latest release: 1.9.9'); + result = validateReleaseVersionContract({ repoRoot }); + assert.match(result.errors.join('\n'), /README.md names a stale current release/); +}); + +test('rejects a real Git repository with no stable tag', (t) => { + const { repoRoot } = repositoryFixture(t); + git(repoRoot, 'tag', '2.0.0-beta.1'); + fs.rmSync(path.join(repoRoot, 'docs'), { recursive: true }); + assert.match(validateReleaseVersionContract({ repoRoot }).errors.join('\n'), /No stable release tag/); +}); + +test('rejects a shallow clone even when its stable tag is available', (t) => { + const { repoRoot, workRoot } = repositoryFixture(t); + git(repoRoot, 'tag', '2.0.0'); + const shallowRoot = path.join(workRoot, 'shallow'); + git(workRoot, 'clone', '--quiet', '--depth=1', pathToFileURL(repoRoot).href, shallowRoot); + assert.equal(git(shallowRoot, 'rev-parse', '--is-shallow-repository'), 'true'); + assert.match(validateReleaseVersionContract({ repoRoot: shallowRoot }).errors.join('\n'), /require full Git history and tags \(fetch-depth: 0\)/); +}); diff --git a/.github/scripts/semantic-release.cjs b/.github/scripts/semantic-release.cjs index ac3926d..545d2cc 100644 --- a/.github/scripts/semantic-release.cjs +++ b/.github/scripts/semantic-release.cjs @@ -1,162 +1,94 @@ #!/usr/bin/env node -const { execFileSync, spawnSync } = require('node:child_process'); +const { execFileSync } = require('node:child_process'); const path = require('node:path'); +const { parseArgs } = require('node:util'); +const { validateReleaseVersionContract } = require('./release-version-contract.cjs'); -const repoRoot = path.resolve(__dirname, '..', '..'); -const { - expectedStableRelease, - initialStableBaseline, -} = require(path.join(repoRoot, 'release.config.js')); -const syntheticBaselineEnvironment = 'EMULSIFY_SYNTHETIC_STABLE_BASELINE'; -const syntheticBaselineShaEnvironment = 'EMULSIFY_SYNTHETIC_STABLE_BASELINE_SHA'; -const syntheticBaselineSourceEnvironment = 'EMULSIFY_SYNTHETIC_STABLE_BASELINE_SOURCE'; - -function git(args, options = {}) { - const output = execFileSync('git', args, { - cwd: repoRoot, - encoding: 'utf8', - ...options, - }); - - return typeof output === 'string' ? output.trim() : ''; -} - -function mergedTags() { - const output = git(['tag', '--merged', 'HEAD', '--sort=-version:refname']); - - return output === '' ? [] : output.split(/\r?\n/); +function git(repoRoot, args) { + return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8' }).trim(); } -function publishedTags() { - const output = git(['ls-remote', '--tags', 'origin']); - const tags = new Set(); +function assertPublishedStableTags(repoRoot) { + const stableTags = git(repoRoot, ['tag', '--list']) + .split(/\r?\n/).filter((tag) => /^\d+\.\d+\.\d+$/.test(tag)); + const remoteTags = new Set(git(repoRoot, ['ls-remote', '--tags', 'origin']) + .split(/\r?\n/).map((line) => (line.split(/\s+/)[1] || '') + .replace(/^refs\/tags\//, '').replace(/\^\{\}$/, ''))); + const localOnlyTags = stableTags.filter((tag) => !remoteTags.has(tag)); - for (const line of output.split(/\r?\n/)) { - const ref = line.trim().split(/\s+/)[1] || ''; - if (ref.startsWith('refs/tags/')) { - tags.add(ref.slice('refs/tags/'.length).replace(/\^\{\}$/, '')); - } + if (localOnlyTags.length > 0) { + throw new Error( + `Refusing to publish local-only stable tag(s): ${localOnlyTags.join(', ')}. Remove them or publish them intentionally first.`, + ); } - - return tags; } -function tagCommit(tag) { - try { - return git(['rev-parse', '--verify', `${tag}^{commit}`]); - } - catch { - return null; - } +function releaseOptions(args) { + const { values } = parseArgs({ + args, + allowNegative: true, + options: { + 'tag-format': { type: 'string', short: 't' }, + 'dry-run': { type: 'boolean', short: 'd' }, + debug: { type: 'boolean' }, + ci: { type: 'boolean' }, + }, + }); + return { + ...(values['tag-format'] !== undefined && { tagFormat: values['tag-format'] }), + ...(values['dry-run'] !== undefined && { dryRun: values['dry-run'] }), + ...(values.ci !== undefined && { ci: values.ci }), + ...(values.debug !== undefined && { debug: values.debug }), + }; } -function prepareStableBaseline() { - const branch = process.env.GITHUB_REF_NAME || git(['branch', '--show-current']); - - if (branch !== 'main') { - return null; - } - - const tags = mergedTags(); - const stableTags = tags.filter((tag) => /^\d+\.\d+\.\d+$/.test(tag)); - - if (stableTags.length > 0) { - const remoteTags = publishedTags(); - const localOnlyTags = stableTags.filter((tag) => !remoteTags.has(tag)); - - if (localOnlyTags.length > 0) { - throw new Error( - `Refusing to publish local-only stable tag(s): ${localOnlyTags.join(', ')}. Remove them or publish them intentionally first.` - ); - } - - return null; - } - - const prereleaseTag = tags.find((tag) => tag.startsWith(`${initialStableBaseline}-`)); - - if (!prereleaseTag) { - throw new Error( - `Cannot prepare ${expectedStableRelease}: no stable tag or merged ${initialStableBaseline} prerelease tag was found.` - ); +async function runSemanticRelease(options, context) { + if (options.debug) { + (await import('debug')).default.enable('semantic-release:*'); } + return (await import('semantic-release')).default(options, context); +} - const sourceSha = tagCommit(prereleaseTag); - if (!sourceSha) { - throw new Error(`Cannot resolve ${prereleaseTag} to a commit for the temporary stable baseline.`); +async function main({ + repoRoot = path.resolve(__dirname, '../..'), + args = process.argv.slice(2), + runRelease = runSemanticRelease, +} = {}) { + const options = releaseOptions(args); + const contract = validateReleaseVersionContract({ repoRoot }); + if (contract.errors.length > 0) { + throw new Error(contract.errors.join('\n')); } - git(['tag', initialStableBaseline, sourceSha]); - return { - sha: sourceSha, - source: prereleaseTag, - tag: initialStableBaseline, - }; -} - -function removeStableBaseline(baseline) { - if (!baseline) { - return; + // Check before skipping too: semantic-release must never push an unpublished + // stable tag from a developer's checkout. + assertPublishedStableTags(repoRoot); + if (!contract.releasePrepared) { + console.log(`No release prepared: package.json matches latest stable tag ${contract.latestStableTag}. Skipping publishing.`); + return 0; } - const existingSha = tagCommit(baseline.tag); - if (!existingSha) { - return; + // A release PR can verify its version before merging to main. This override + // is only for an explicit local dry run, which cannot tag or publish. + if (options.dryRun && options.ci === false) { + options.branches = [git(repoRoot, ['branch', '--show-current'])]; } - if (existingSha !== baseline.sha) { + const result = await runRelease(options, { cwd: repoRoot }); + if (result?.nextRelease?.version !== contract.packageVersion) { throw new Error( - `Refusing to delete ${baseline.tag}: it no longer points to the temporary baseline commit ${baseline.sha}.` + `Prepared package.json version ${contract.packageVersion}, but semantic-release ${result?.nextRelease ? `computed ${result.nextRelease.version}` : 'did not compute a release'}. Run on main with releasable Conventional Commits matching the prepared version, then rerun npm run release:check.`, ); } - - git(['tag', '--delete', baseline.tag], { stdio: 'ignore' }); + return 0; } -function main() { - let temporaryBaseline = null; - - try { - temporaryBaseline = prepareStableBaseline(); - - const cli = require.resolve('semantic-release/bin/semantic-release.js'); - const result = spawnSync(process.execPath, [cli, ...process.argv.slice(2)], { - cwd: repoRoot, - env: { - ...process.env, - [syntheticBaselineEnvironment]: temporaryBaseline?.tag || '', - [syntheticBaselineShaEnvironment]: temporaryBaseline?.sha || '', - [syntheticBaselineSourceEnvironment]: temporaryBaseline?.source || '', - }, - stdio: 'inherit', - }); - - if (result.error) { - throw result.error; - } - - if ( - result.status === 0 - && temporaryBaseline - && tagCommit(temporaryBaseline.tag) - ) { - throw new Error( - `Semantic-release completed without removing the temporary ${temporaryBaseline.tag} baseline tag.` - ); - } - - return Number.isInteger(result.status) ? result.status : 1; - } - finally { - removeStableBaseline(temporaryBaseline); - } +if (require.main === module) { + main().then((status) => { process.exitCode = status; }).catch((error) => { + console.error(`Semantic release runner failed: ${error.message}`); + process.exitCode = 1; + }); } -try { - process.exitCode = main(); -} -catch (error) { - console.error(`Semantic release runner failed: ${error.message}`); - process.exitCode = 1; -} +module.exports = { assertPublishedStableTags, main, releaseOptions }; diff --git a/.github/scripts/semantic-release.test.cjs b/.github/scripts/semantic-release.test.cjs new file mode 100644 index 0000000..7e41ef4 --- /dev/null +++ b/.github/scripts/semantic-release.test.cjs @@ -0,0 +1,118 @@ +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); +const { assertPublishedStableTags, main, releaseOptions } = require('./semantic-release.cjs'); +const releaseConfig = require('../../release.config.js'); +const guard = releaseConfig.plugins.find((plugin) => typeof plugin.verifyRelease === 'function'); + +function fixture(t, version = '2.1.0') { + const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-publish-contract-')); + t.after(() => fs.rmSync(workRoot, { recursive: true, force: true })); + const repoRoot = path.join(workRoot, 'checkout'); + const remote = path.join(workRoot, 'origin.git'); + fs.mkdirSync(repoRoot); + const git = (...args) => execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' }).trim(); + git('init', '--bare', remote); + git('init', '-b', 'main'); + git('-c', 'user.name=Contract Test', '-c', 'user.email=contract@example.invalid', 'commit', '--allow-empty', '-m', 'chore: initial release'); + git('-c', 'user.name=Contract Test', '-c', 'user.email=contract@example.invalid', 'tag', '-a', '2.0.0', '-m', '2.0.0'); + git('remote', 'add', 'origin', remote); + git('push', 'origin', 'main', '--tags'); + fs.mkdirSync(path.join(repoRoot, 'docs')); + for (const file of ['README.md', 'docs/release-process.md', 'docs/release-notes-next.md']) { + fs.writeFileSync(path.join(repoRoot, file), ''); + } + fs.writeFileSync(path.join(repoRoot, 'package.json'), JSON.stringify({ name: 'release-contract', version })); + return { repoRoot, remote, git }; +} + +test('publish guard accepts the exact prepared version on every subsequent release', (t) => { + const { repoRoot } = fixture(t); + guard.verifyRelease({}, { cwd: repoRoot, nextRelease: { version: '2.1.0' } }); + assert.throws(() => guard.verifyRelease({}, { cwd: repoRoot, nextRelease: { version: '2.0.1' } }), /Prepared package.json version 2\.1\.0.*computed 2\.0\.1.*release PR/); + fs.writeFileSync(path.join(repoRoot, 'package.json'), JSON.stringify({ version: '3.0.0' })); + assert.throws(() => guard.verifyRelease({}, { cwd: repoRoot, lastRelease: { version: '2.1.0' }, nextRelease: { version: '2.2.0' } }), /Prepared package.json version 3\.0\.0.*computed 2\.2\.0/); +}); + +test('skips publishing successfully when package.json equals the latest tag', async (t) => { + const { repoRoot } = fixture(t, '2.0.0'); + const status = await main({ repoRoot, args: ['--dry-run', '--debug', '--no-ci'], runRelease: () => assert.fail('An unprepared release must not invoke semantic-release.') }); + assert.equal(status, 0); +}); + +test('prepared release accepts a matching semantic-release result and forwards documented flags', async (t) => { + const { repoRoot } = fixture(t); + const status = await main({ repoRoot, args: ['--tag-format', '${version}', '--dry-run', '--debug', '--no-ci'], runRelease: async (options, context) => { + assert.equal(context.cwd, repoRoot); + assert.equal(options.tagFormat, '${version}'); + assert.equal(options.dryRun, true); + assert.equal(options.debug, true); + assert.equal(options.ci, false); + guard.verifyRelease({}, { ...context, nextRelease: { version: '2.1.0' } }); + return { nextRelease: { version: '2.1.0' } }; + } }); + assert.equal(status, 0); +}); + +test('prepared release rejects mismatched or absent semantic-release results', async (t) => { + const { repoRoot } = fixture(t); + for (const result of [false, { nextRelease: { version: '2.0.1' } }]) { + await assert.rejects(main({ repoRoot, args: [], runRelease: async () => result }), /Prepared package.json version 2\.1\.0.*(?:computed 2\.0\.1|did not compute a release).*Conventional Commits/); + } +}); + +test('refuses local-only stable tags before any publishing or skip', async (t) => { + const { repoRoot, git } = fixture(t, '2.0.1'); + assert.doesNotThrow(() => assertPublishedStableTags(repoRoot)); + git('tag', '2.0.1'); + assert.throws(() => assertPublishedStableTags(repoRoot), /Refusing to publish local-only stable tag\(s\): 2\.0\.1/); + await assert.rejects(main({ repoRoot, args: [], runRelease: () => assert.fail('Local-only stable tags must block semantic-release.') }), /Refusing to publish local-only stable tag\(s\): 2\.0\.1/); +}); + +test('also refuses local-only stable tags on unmerged branches', (t) => { + const { repoRoot, git } = fixture(t); + git('switch', '-c', 'unmerged'); + git('-c', 'user.name=Contract Test', '-c', 'user.email=contract@example.invalid', 'commit', '--allow-empty', '-m', 'chore: unmerged work'); + git('tag', '2.0.1'); + git('switch', 'main'); + assert.equal(git('tag', '--merged', 'HEAD'), '2.0.0'); + assert.throws(() => assertPublishedStableTags(repoRoot), /Refusing to publish local-only stable tag\(s\): 2\.0\.1/); +}); + +test('parses the existing publish and publish-test command options', () => { + assert.deepEqual(releaseOptions(['--tag-format', '${version}', '--dry-run', '--debug', '--no-ci']), { tagFormat: '${version}', dryRun: true, debug: true, ci: false }); + assert.deepEqual(releaseOptions(['-t', '${version}', '-d']), { tagFormat: '${version}', dryRun: true }); +}); + +test('real semantic-release dry run analyzes a pushed release PR branch and blocks mismatches before tagging', (t) => { + const { repoRoot, remote, git } = fixture(t); + fs.symlinkSync(path.resolve(__dirname, '../../node_modules'), path.join(repoRoot, 'node_modules'), 'dir'); + git('switch', '-c', 'release-2.1.x'); + git('-c', 'user.name=Contract Test', '-c', 'user.email=contract@example.invalid', 'commit', '--allow-empty', '-m', 'feat: add a project capability'); + git('push', 'origin', 'release-2.1.x'); + // semantic-release hooks stdout, so run it outside node:test's event stream. + const script = ` + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + const { main } = require(${JSON.stringify(path.join(__dirname, 'semantic-release.cjs'))}); + const config = require(${JSON.stringify(path.resolve(__dirname, '../../release.config.js'))}); + (async () => { + const { default: semanticRelease } = await import(${JSON.stringify(require.resolve('semantic-release'))}); + const guard = config.plugins.find((plugin) => typeof plugin.verifyRelease === 'function'); + const runRelease = (options, context) => semanticRelease({ + ...config, ...options, repositoryUrl: ${JSON.stringify(remote)}, plugins: [config.plugins[0], guard], + }, context); + const options = { repoRoot: process.cwd(), args: ['--dry-run', '--no-ci'], runRelease }; + assert.equal(await main(options), 0); + fs.writeFileSync('package.json', JSON.stringify({ version: '2.0.1' })); + await assert.rejects(main(options), /Prepared package.json version 2\\.0\\.1.*computed 2\\.1\\.0/); + })().catch((error) => { console.error(error); process.exitCode = 1; }); + `; + // CI provider branch variables and node:test IPC belong to the outer repo. + const env = { PATH: process.env.PATH, HOME: process.env.HOME }; + execFileSync(process.execPath, ['-e', script], { cwd: repoRoot, env, stdio: 'pipe' }); + assert.equal(git('tag', '--list'), '2.0.0'); +}); diff --git a/.github/workflows/theme-readiness.yml b/.github/workflows/theme-readiness.yml index db13c46..1749a92 100644 --- a/.github/workflows/theme-readiness.yml +++ b/.github/workflows/theme-readiness.yml @@ -58,6 +58,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v6 @@ -191,6 +193,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v6 diff --git a/README.md b/README.md index 6ab6cc6..55e4acf 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Emulsify WordPress -Emulsify WordPress 2.0.0 is a Timber-first WordPress parent theme for teams building component-driven sites with Emulsify Core 4, Vite, Storybook, and Twig. +Emulsify WordPress is a Timber-first WordPress parent theme for teams building component-driven sites with Emulsify Core 4, Vite, Storybook, and Twig. The parent theme provides the WordPress runtime: theme setup, Timber bootstrapping, Twig namespaces and helpers, template fallbacks, asset loading, and optional block registration. Generated child themes provide the project layer: components, templates, source Sass and JavaScript, compiled assets, and site-specific overrides. diff --git a/UPGRADE.md b/UPGRADE.md index 1659dff..f9efb2c 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -4,6 +4,15 @@ Emulsify WordPress is a parent theme. Generated child themes do not inherit later starter changes, so most upgrades have two parts: update the parent theme, then decide which starter changes to adopt in each generated project. +## Upgrading from 2.0.0 to 2.1.0 + +The release tooling change requires no runtime or generated child theme edits. +Maintainers need a full Git checkout with tags to run release checks. Before +publishing, prepare a release PR that updates root `package.json` and all derived +version metadata together, then confirm semantic-release computes the same +version. A checkout still at the latest tagged version skips publishing. See +the [release PR procedure](docs/release-process.md#release-pr-procedure). + ## Upgrading from 1.x to 2.0.0 2.0.0 is a full rebuild of the WordPress theme around Timber, Emulsify Core 4, diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 4eee136..6fccc25 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -1,16 +1,21 @@ -# 2.0.0 Release Notes Draft +# 2.1.0 Release Notes Draft -Status date: 2026-08-02 +Status date: 2026-10-22 ## Version strategy -Latest published tag: none on the stable line. The `release-2.x` branch prepares -the first stable release as `2.0.0`. +Latest published tag: `2.0.0`. This draft tracks the upcoming `2.1.0` release. -Until the `2.0.0` tag exists, `release.config.js` forces the first `main` publish -to a major release type and verifies the computed version is exactly `2.0.0`. -After that tag exists, normal Conventional Commit analysis drives later releases -and this file should be rewritten for the next version. +Root `package.json` remains at the latest stable version until a release PR +bumps it and every derived version surface together. The version contract +accepts the latest stable tag or an untagged next patch, minor, or major, and +requires full Git history and tags. It rejects already-tagged draft versions +and stale claims about the latest release. + +The guarded runner skips publishing when no new version is prepared. Once a +release PR prepares `2.1.0`, semantic-release must compute exactly `2.1.0` from +Conventional Commits before it can publish the committed theme headers, ZIP, +and generated-theme lineage. See the [release process](release-process.md). ## Compatibility notes @@ -24,7 +29,7 @@ and this file should be rewritten for the next version. ## Draft GitHub release notes -Title: `2.0.0` +Title: `2.1.0` ### Added @@ -59,6 +64,10 @@ Title: `2.0.0` ### Fixed +- Release tooling supports prepared releases after `2.0.0`, validates versions + against stable Git tags, and prevents publishing metadata that differs from + the version semantic-release computes. `release:check -- --skip-smoke` now + skips runtime smoke commands as documented. - `style.css` header replacement no longer warns when a header already holds the requested value; only a genuinely missing header is reported. @@ -77,4 +86,4 @@ Title: `2.0.0` WordPress fixture path. - Run `npm run publish-test -- --no-ci` before publishing. - Publish the compatible `@emulsify/core` release before Emulsify WordPress - `2.0.0`. + `2.1.0`. diff --git a/docs/release-process.md b/docs/release-process.md index 4c9296f..3c82487 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -2,11 +2,13 @@ This repository uses semantic-release with Conventional Commits. Releases are prepared from `main`, and Git tags use non-prefixed SemVer such as `2.0.0`. -## Release branch target +## Repository history -The `release-2.x` branch prepares the next stable release as `2.0.0`. Until that stable tag exists, the semantic-release config forces the first `main` publish to use a major release type and then verifies the computed release is exactly `2.0.0`. After the `2.0.0` tag exists, normal Conventional Commit analysis drives later releases. - -Semantic-release excludes prerelease tags when it determines the previous release on a stable branch. The repository publish scripts therefore use a guarded runner that temporarily aliases the latest merged `1.0.0` alpha tag as a local `1.0.0` baseline. The release guard removes that temporary tag before semantic-release can push tags, so only the computed `2.0.0` tag is published. The runner verifies the tag target and also removes the alias on failure. +Release checks require full Git history and tags. Use a full clone, run +`git fetch origin --tags` before preparing a release, and use +`git fetch --unshallow --tags origin` first if the checkout is shallow. CI jobs +that run release checks use `fetch-depth: 0`. The version contract fails clearly +if no stable tag is available; it does not invent a release baseline. ## Commit messages @@ -39,8 +41,45 @@ other version surface is compared against it rather than restating a literal: `release:check` fails when any of these disagree, so a missed bump cannot ship inconsistent generated-theme lineage metadata. -Before publishing a release, update root `package.json`, then run -`npm run release:check -- --skip-smoke` and fix any surface it reports. +The root version must be stable `x.y.z`: either the latest stable tag, or an +untagged next patch, minor, or major of it. For example, after `2.0.0`, the +prepared version may be `2.0.1`, `2.1.0`, or `3.0.0`. Skipped versions, older +tagged versions, and prereleases are rejected. Release-note draft headings must +name an untagged version, and claims about the latest or current release must +match the latest stable tag. + +## Release PR procedure + +1. Start a release PR from an up-to-date `origin/main` with full history and + tags. Review the Conventional Commits since the latest stable tag to choose + the upcoming version. +2. Bump root `package.json` to that prepared version. Update `style.css` + `Version`, `whisk/package.json` version, `whisk/style.css` `Version`, and + `whisk/project.emulsify.json` `project.generatedFromVersion` to match. Keep + lockfile package metadata aligned when applicable. No release plugin writes + these versions for you. +3. Update `docs/release-notes-next.md` and `UPGRADE.md`, then run the local + readiness checks below, leaving `publish-test` for step 4. Include the full + `npm run release:check` with WP-CLI and + MySQL available. The release job packages these committed files, including + their version strings, into the ZIP. +4. Push the release PR branch, then run `npm run publish-test -- --no-ci`. + This explicit local dry run analyzes the current pushed branch; publishing + remains restricted to `main`. Semantic-release must compute exactly + the prepared version from the Conventional Commits. A mismatch fails with + an actionable message; resolve the version or commit classification before + merging. +5. Merge the release PR to `main` after readiness checks pass. The publishing + workflow repeats the checks and publishes only when its computed version + matches the prepared version. +6. After publishing, rotate `docs/release-notes-next.md` to an untagged draft + heading and release title, and update or remove its latest-tag claim. The + contract rejects the shipped draft on subsequent runs until it is rotated. + +When `package.json` still equals the latest stable tag, no release is prepared. +The guarded runner skips publishing successfully, even if newer commits would +otherwise trigger semantic-release. Ordinary change PRs do not need to bump +versions; the release PR prepares them together. ## Release checks @@ -105,6 +144,10 @@ commands still exist in the package where readers are instructed to run them. `release:check` adds static release-readiness checks and the full WordPress fixture smoke path. In CI, the fixture extracts the built `emulsify.zip` into an isolated WordPress site, confirms the packaged `wp emulsify` command, generates and activates a child theme from the packaged Whisk source, adds neutral built asset and block fixtures, renders frontend routes through Timber, fetches those built child assets, and checks ACF/Twig and native `block.json` discovery. It requires WP-CLI and MySQL. It skips gracefully when WP-CLI or database settings are unavailable unless `WP_SMOKE_REQUIRED=1` is set. +Use `npm run release:check -- --skip-smoke` for static checks and focused +contract tests without runtime smoke commands. This is a quick local check; +the release PR and publishing workflow still require the full fixture path. + ## Installable release artifact Run `npm run build:dist` to create `dist-artifact/emulsify.zip`. The build stages an explicit parent-theme runtime file list under a top-level `emulsify/` directory, copies only tracked Whisk generator files, and installs the versions pinned in `composer.lock` with `--no-dev --optimize-autoloader` directly into that staged tree. The ZIP therefore bundles `vendor/` and can be installed without running Composer after download. @@ -133,18 +176,18 @@ Pull requests targeting `main` or `release-2.x` additionally run the two extende Weekly scheduled runs repeat both extended jobs. Manual dispatch runs the WordPress fixture when `wordpress_fixture` is enabled and the Whisk build/audit when `extended_checks` is enabled. The workflow concurrency group cancels superseded pull-request runs, and both extended jobs have explicit runtime limits. -## 2.0 merge and release gate +## Merge and release gate -The two fast pull-request jobs run automatically for every configured target branch. All four jobs run for changes targeting `main` or `release-2.x`. For an additional final rerun before merging the 2.0 release branch: +The two fast pull-request jobs run automatically for every configured target branch. All four jobs run for changes targeting `main` or `release-2.x`. For an additional final rerun before merging a release PR: 1. Open GitHub Actions for `emulsify-ds/emulsify-wordpress`. 2. Select the `WordPress Theme Readiness` workflow. 3. Choose `Run workflow`. -4. Select the `release-2.x` branch. +4. Select the release PR branch. 5. Keep `wordpress_fixture` enabled. It defaults to enabled for manual runs. 6. Enable `extended_checks` to rerun the CI-seeded Storybook and accessibility audit. -Success means `Practical theme readiness`, `PHP coding standards and static analysis`, `WordPress fixture smoke`, and `Extended Whisk Storybook and a11y` pass. The fixture job installs WP-CLI, starts MySQL, sets `WP_SMOKE_REQUIRED=1`, and runs `npm run release:check` against the isolated WordPress fixture. Record the successful workflow run in the 2.0 release PR before merging. +Success means `Practical theme readiness`, `PHP coding standards and static analysis`, `WordPress fixture smoke`, and `Extended Whisk Storybook and a11y` pass. The fixture job installs WP-CLI, starts MySQL, sets `WP_SMOKE_REQUIRED=1`, and runs `npm run release:check` against the isolated WordPress fixture. Record the successful workflow run in the release PR before merging. The semantic-release workflow remains release-gated. It runs release readiness, requires the full WordPress fixture smoke path with WP-CLI and MySQL, runs a semantic-release dry run, and then allows the final publish job only after the readiness job succeeds. Release publishing must not proceed when the full fixture path is skipped or failed. diff --git a/release.config.js b/release.config.js index fee464d..67831b9 100644 --- a/release.config.js +++ b/release.config.js @@ -1,4 +1,5 @@ -const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); const parserOpts = { // semantic-release's default parser does not treat "feat!:" consistently @@ -10,132 +11,23 @@ const parserOpts = { noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING-CHANGE'], }; -const expectedStableRelease = '2.0.0'; -const initialStableBaseline = '1.0.0'; -const syntheticBaselineEnvironment = 'EMULSIFY_SYNTHETIC_STABLE_BASELINE'; -const syntheticBaselineShaEnvironment = 'EMULSIFY_SYNTHETIC_STABLE_BASELINE_SHA'; -const syntheticBaselineSourceEnvironment = 'EMULSIFY_SYNTHETIC_STABLE_BASELINE_SOURCE'; +const preparedReleaseGuard = { + verifyRelease(pluginConfig, { cwd = __dirname, nextRelease }) { + const preparedVersion = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')).version; -function parseVersionParts(version) { - return String(version || '') - .split('-')[0] - .split('.') - .map((part) => Number.parseInt(part, 10)); -} - -function isAtLeastVersion(version, minimumVersion) { - const current = parseVersionParts(version); - const minimum = parseVersionParts(minimumVersion); - - if ( - current.length !== 3 || - minimum.length !== 3 || - current.some(Number.isNaN) || - minimum.some(Number.isNaN) - ) { - return false; - } - - for (let index = 0; index < current.length; index += 1) { - if (current[index] > minimum[index]) { - return true; - } - - if (current[index] < minimum[index]) { - return false; - } - } - - return true; -} - -function removeSyntheticBaseline(cwd) { - const baselineTag = process.env[syntheticBaselineEnvironment]; - const expectedSha = process.env[syntheticBaselineShaEnvironment]; - - if (baselineTag !== initialStableBaseline || !expectedSha) { - return; - } - - try { - const actualSha = execFileSync('git', ['rev-parse', '--verify', `${baselineTag}^{commit}`], { - cwd: cwd || process.cwd(), - encoding: 'utf8', - }).trim(); - - if (actualSha !== expectedSha) { - throw new Error(`${baselineTag} points to ${actualSha}, not the temporary baseline commit ${expectedSha}.`); - } - - execFileSync('git', ['tag', '--delete', baselineTag], { - cwd: cwd || process.cwd(), - stdio: 'ignore', - }); - delete process.env[syntheticBaselineEnvironment]; - delete process.env[syntheticBaselineShaEnvironment]; - } - catch (error) { - throw new Error(`Unable to remove the temporary ${baselineTag} release baseline tag before publishing.`, { - cause: error, - }); - } -} - -const expectedStableReleaseGuard = { - verifyRelease(pluginConfig, { branch, cwd, lastRelease = {}, nextRelease }) { - try { - // The 2.x release branch prepares the stable 2.0.0 baseline. After that - // baseline exists, normal semantic-release versioning can continue. - if (branch.name !== 'main' || isAtLeastVersion(lastRelease.version, expectedStableRelease)) { - return; - } - - if (nextRelease.version !== expectedStableRelease) { - const message = `Expected semantic-release to prepare ${expectedStableRelease} on main, but computed ${nextRelease.version}. Confirm stable baseline tags and breaking-change commits before publishing.`; - throw new Error(message); - } - } - finally { - // semantic-release pushes every local tag. Remove the temporary baseline - // after version calculation, regardless of whether the guard accepts it. - removeSyntheticBaseline(cwd); + if (nextRelease.version !== preparedVersion) { + throw new Error( + `Prepared package.json version ${preparedVersion}, but semantic-release computed ${nextRelease.version}. Update the release PR's package.json and all derived version files to match Conventional Commits, then rerun npm run release:check.`, + ); } }, }; -const expectedStableReleaseAnalyzer = { - analyzeCommits(pluginConfig, { branch, lastRelease = {} }) { - // The release-2.x branch is the first stable release line for the rebuilt - // parent theme. The runner temporarily exposes the latest 1.0.0 alpha as a - // local 1.0.0 baseline so semantic-release can calculate 2.0.0. - return branch.name === 'main' && !isAtLeastVersion(lastRelease.version, expectedStableRelease) - ? 'major' - : null; - }, -}; - -function finalizeReleaseNotesContext(context) { - if ( - context.version === expectedStableRelease - && context.previousTag === initialStableBaseline - && process.env[syntheticBaselineSourceEnvironment]?.startsWith(`${initialStableBaseline}-`) - ) { - // The temporary baseline is never published, so do not emit a compare URL - // whose left-hand tag would not exist on GitHub. - context.linkCompare = false; - } - - return context; -} - module.exports = { - expectedStableRelease, - initialStableBaseline, tagFormat: '${version}', branches: ['main'], repositoryUrl: 'git@github.com:emulsify-ds/emulsify-wordpress.git', plugins: [ - expectedStableReleaseAnalyzer, ['@semantic-release/commit-analyzer', { preset: 'angular', parserOpts }], [ '@semantic-release/release-notes-generator', @@ -144,11 +36,10 @@ module.exports = { parserOpts, writerOpts: { commitsSort: ['subject', 'scope'], - finalizeContext: finalizeReleaseNotesContext, }, }, ], - expectedStableReleaseGuard, + preparedReleaseGuard, [ '@semantic-release/github', { From a085460538d104b31c62c59945f2fc7bdf3785f1 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 16:03:02 -0500 Subject: [PATCH 003/100] fix(templates): correct fallback pagination markup and URLs --- .github/scripts/attribute-helper-smoke.php | 5 +--- .github/scripts/release-check.cjs | 2 +- .github/scripts/twig-autoescape-smoke.php | 5 +--- UPGRADE.md | 6 +++++ docs/release-notes-next.md | 5 ++++ includes/Runtime/Twig.php | 16 ++++++----- templates/partials/pagination.twig | 31 ++++++++-------------- 7 files changed, 35 insertions(+), 35 deletions(-) diff --git a/.github/scripts/attribute-helper-smoke.php b/.github/scripts/attribute-helper-smoke.php index b8a3170..9fbd7e3 100644 --- a/.github/scripts/attribute-helper-smoke.php +++ b/.github/scripts/attribute-helper-smoke.php @@ -38,10 +38,7 @@ function emulsify_attribute_helper_assert_same( string $label, $expected, $actua */ function emulsify_register_smoke_twig_functions( \Twig\Environment $environment, Twig $helpers ): void { foreach ( $helpers->functions( array() ) as $name => $definition ) { - $options = array( - 'is_safe' => $definition['is_safe'] ?? array(), - 'needs_context' => $definition['needs_context'] ?? false, - ); + $options = $definition['options'] ?? array(); $environment->addFunction( new \Twig\TwigFunction( $name, $definition['callable'], $options ) ); } diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index ec8fcd1..87b2823 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -558,7 +558,7 @@ function runStaticChecks() { ensure(twig.includes("$options['autoescape'] = 'html'"), 'Timber integration should enable HTML autoescaping.'); ensure(smoke.includes('{{ fields.heading }}'), 'Twig autoescape smoke should render a field value.'); ensure(smoke.includes(''), 'Twig autoescape smoke should use a hostile script fixture.'); - ensure(smoke.includes("$definition['is_safe']"), 'Twig autoescape smoke should register helper safety metadata.'); + ensure(smoke.includes("$definition['options']"), 'Twig autoescape smoke should register the same helper options as Timber.'); return 'Twig HTML autoescaping is registered and covered by a behavioral smoke test.'; }); diff --git a/.github/scripts/twig-autoescape-smoke.php b/.github/scripts/twig-autoescape-smoke.php index 4d55ac9..3eb9db5 100644 --- a/.github/scripts/twig-autoescape-smoke.php +++ b/.github/scripts/twig-autoescape-smoke.php @@ -58,10 +58,7 @@ function emulsify_twig_autoescape_fail( string $message ): void { new \Twig\TwigFunction( $name, $definition['callable'], - array( - 'is_safe' => $definition['is_safe'] ?? array(), - 'needs_context' => $definition['needs_context'] ?? false, - ) + $definition['options'] ?? array() ) ); } diff --git a/UPGRADE.md b/UPGRADE.md index f9efb2c..6072dce 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -6,6 +6,12 @@ then decide which starter changes to adopt in each generated project. ## Upgrading from 2.0.0 to 2.1.0 +Fallback pagination ellipses remain non-interactive spans with the existing BEM +classes, now displaying a translated `…` and omitting `aria-current`. Child +themes that override `templates/partials/pagination.twig` should adopt these +markup corrections and decode Timber's display-escaped URLs before passing +them to `add_attributes`. The parent adds no frontend CSS. + The release tooling change requires no runtime or generated child theme edits. Maintainers need a full Git checkout with tags to run release checks. Before publishing, prepare a release PR that updates root `package.json` and all derived diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 6fccc25..a689385 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -64,6 +64,11 @@ Title: `2.1.0` ### Fixed +- Fallback pagination marks only the actual current page, renders a translated + ellipsis character as non-interactive text, and preserves multi-argument URLs + in numbered, previous, and next links without double escaping. +- Twig helper registration uses Timber's function options so rendered attribute + markup remains intact and helpers receive the intended Twig context. - Release tooling supports prepared releases after `2.0.0`, validates versions against stable Git tags, and prevents publishing metadata that differs from the version semantic-release computes. `release:check -- --skip-smoke` now diff --git a/includes/Runtime/Twig.php b/includes/Runtime/Twig.php index fb9d9a7..479690a 100644 --- a/includes/Runtime/Twig.php +++ b/includes/Runtime/Twig.php @@ -130,15 +130,19 @@ public function loader_paths( $loader ) { */ public function functions( array $functions ): array { $functions['bem'] = array( - 'callable' => array( $this, 'bem' ), - 'needs_context' => true, - 'is_safe' => array( 'html' ), + 'callable' => array( $this, 'bem' ), + 'options' => array( + 'needs_context' => true, + 'is_safe' => array( 'html' ), + ), ); $functions['add_attributes'] = array( - 'callable' => array( $this, 'add_attributes' ), - 'needs_context' => true, - 'is_safe' => array( 'html' ), + 'callable' => array( $this, 'add_attributes' ), + 'options' => array( + 'needs_context' => true, + 'is_safe' => array( 'html' ), + ), ); return $functions; diff --git a/templates/partials/pagination.twig b/templates/partials/pagination.twig index e3927ec..4f13542 100644 --- a/templates/partials/pagination.twig +++ b/templates/partials/pagination.twig @@ -17,7 +17,7 @@ } %} {% set previous_link_attributes = { class: bem('link', [], 'pagination'), - href: pagination.prev.link + href: function('html_entity_decode', pagination.prev.link) } %}