fix(release): derive approval gate paths from version (#2383)

Co-authored-by: jan <jan@w-saxs001.local>
This commit is contained in:
Yang Cheng 2026-06-30 06:50:55 +08:00 committed by GitHub
parent 8973d0f6c5
commit 9896644dab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 98 additions and 47 deletions

View File

@ -5,13 +5,8 @@ const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const RELEASE = '2.0.0-rc.1';
const RELEASE_DIR = `docs/releases/${RELEASE}`;
const SCHEMA_VERSION = 'ecc.release-approval-gate.v1'; const SCHEMA_VERSION = 'ecc.release-approval-gate.v1';
const SCRIPT_PATH = 'scripts/release-approval-gate.js'; const SCRIPT_PATH = 'scripts/release-approval-gate.js';
const OWNER_PACKET_PATH = `${RELEASE_DIR}/owner-approval-packet-2026-05-19.md`;
const URL_LEDGER_PATH = `${RELEASE_DIR}/release-url-ledger-2026-05-19.md`;
const PREVIEW_MANIFEST_PATH = `${RELEASE_DIR}/preview-pack-manifest.md`;
const REQUIRED_COMMAND = 'npm run release:approval-gate -- --format json'; const REQUIRED_COMMAND = 'npm run release:approval-gate -- --format json';
const REQUIRED_DECISIONS = [ const REQUIRED_DECISIONS = [
@ -87,20 +82,19 @@ const REQUIRED_URL_SURFACES = [
}, },
]; ];
const ANNOUNCEMENT_FILES = [ const ANNOUNCEMENT_FILE_NAMES = [
`${RELEASE_DIR}/release-notes.md`, 'release-notes.md',
`${RELEASE_DIR}/x-thread.md`, 'x-thread.md',
`${RELEASE_DIR}/linkedin-post.md`, 'linkedin-post.md',
`${RELEASE_DIR}/article-outline.md`, 'article-outline.md',
`${RELEASE_DIR}/partner-sponsor-talks-pack.md`, 'partner-sponsor-talks-pack.md',
'docs/business/social-launch-copy.md',
]; ];
function usage() { function usage() {
console.log([ console.log([
'Usage: node scripts/release-approval-gate.js [--format <text|json>] [--root <dir>]', 'Usage: node scripts/release-approval-gate.js [--format <text|json>] [--root <dir>]',
'', '',
'Final approval gate for ECC 2.0 rc.1 publication and outbound actions.', 'Final approval gate for the release version declared by package.json.',
'', '',
'Options:', 'Options:',
' --format <text|json> Output format (default: text)', ' --format <text|json> Output format (default: text)',
@ -195,6 +189,32 @@ function safeParseJson(text) {
} }
} }
function resolveRelease(packageJson, options = {}) {
if (typeof options.release === 'string' && options.release.trim()) {
return options.release.trim();
}
return typeof packageJson.version === 'string' ? packageJson.version.trim() : '';
}
function releaseDirFor(release) {
return `docs/releases/${release}`;
}
function releasePathsFor(release) {
const releaseDir = releaseDirFor(release);
return {
ownerPacketPath: `${releaseDir}/owner-approval-packet-2026-05-19.md`,
urlLedgerPath: `${releaseDir}/release-url-ledger-2026-05-19.md`,
previewManifestPath: `${releaseDir}/preview-pack-manifest.md`,
announcementFiles: [
...ANNOUNCEMENT_FILE_NAMES.map(fileName => `${releaseDir}/${fileName}`),
'docs/business/social-launch-copy.md',
],
};
}
function normalizeLabel(value) { function normalizeLabel(value) {
return String(value) return String(value)
.replace(/[`*_]/g, '') .replace(/[`*_]/g, '')
@ -366,11 +386,13 @@ function topActionsForChecks(checks) {
function buildReport(options = {}) { function buildReport(options = {}) {
const rootDir = path.resolve(options.root || process.cwd()); const rootDir = path.resolve(options.root || process.cwd());
const packageJson = safeParseJson(readText(rootDir, 'package.json')) || {}; const packageJson = safeParseJson(readText(rootDir, 'package.json')) || {};
const release = resolveRelease(packageJson, options);
const releasePaths = releasePathsFor(release);
const packageScripts = packageJson.scripts || {}; const packageScripts = packageJson.scripts || {};
const packageFiles = Array.isArray(packageJson.files) ? packageJson.files : []; const packageFiles = Array.isArray(packageJson.files) ? packageJson.files : [];
const ownerPacket = readText(rootDir, OWNER_PACKET_PATH); const ownerPacket = readText(rootDir, releasePaths.ownerPacketPath);
const ledger = readText(rootDir, URL_LEDGER_PATH); const ledger = readText(rootDir, releasePaths.urlLedgerPath);
const manifest = readText(rootDir, PREVIEW_MANIFEST_PATH); const manifest = readText(rootDir, releasePaths.previewManifestPath);
const decisions = parseDecisionRegister(ownerPacket); const decisions = parseDecisionRegister(ownerPacket);
const missingDecisions = []; const missingDecisions = [];
@ -388,11 +410,11 @@ function buildReport(options = {}) {
.filter(surface => !ledger.includes(surface.label)) .filter(surface => !ledger.includes(surface.label))
.map(surface => surface.label); .map(surface => surface.label);
const urlBlockers = ledgerBlockers(ledger); const urlBlockers = ledgerBlockers(ledger);
const announcementOffenders = findAnnouncementOffenders(rootDir, ANNOUNCEMENT_FILES); const announcementOffenders = findAnnouncementOffenders(rootDir, releasePaths.announcementFiles);
const commandListedIn = [ const commandListedIn = [
ownerPacket.includes(REQUIRED_COMMAND) ? OWNER_PACKET_PATH : '', ownerPacket.includes(REQUIRED_COMMAND) ? releasePaths.ownerPacketPath : '',
ledger.includes(REQUIRED_COMMAND) ? URL_LEDGER_PATH : '', ledger.includes(REQUIRED_COMMAND) ? releasePaths.urlLedgerPath : '',
manifest.includes(REQUIRED_COMMAND) ? PREVIEW_MANIFEST_PATH : '', manifest.includes(REQUIRED_COMMAND) ? releasePaths.previewManifestPath : '',
].filter(Boolean); ].filter(Boolean);
const checks = [ const checks = [
@ -440,7 +462,7 @@ function buildReport(options = {}) {
'announcement-copy-finalized', 'announcement-copy-finalized',
announcementOffenders.length === 0 ? 'pass' : 'fail', announcementOffenders.length === 0 ? 'pass' : 'fail',
announcementOffenders.length === 0 announcementOffenders.length === 0
? `${ANNOUNCEMENT_FILES.length} launch/outbound copy files have no placeholders or private paths` ? `${releasePaths.announcementFiles.length} launch/outbound copy files have no placeholders or private paths`
: `offenders: ${announcementOffenders.map(item => `${item.path}:${item.line}`).join(', ')}`, : `offenders: ${announcementOffenders.map(item => `${item.path}:${item.line}`).join(', ')}`,
'Replace placeholders with live URLs and remove private local paths from launch/outbound copy.' 'Replace placeholders with live URLs and remove private local paths from launch/outbound copy.'
), ),
@ -465,7 +487,7 @@ function buildReport(options = {}) {
return { return {
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
release: RELEASE, release,
ready: failed.length === 0, ready: failed.length === 0,
digest, digest,
summary: { summary: {
@ -543,11 +565,12 @@ if (require.main === module) {
} }
module.exports = { module.exports = {
ANNOUNCEMENT_FILES, ANNOUNCEMENT_FILE_NAMES,
REQUIRED_COMMAND, REQUIRED_COMMAND,
REQUIRED_DECISIONS, REQUIRED_DECISIONS,
REQUIRED_URL_SURFACES, REQUIRED_URL_SURFACES,
buildReport, buildReport,
releasePathsFor,
parseArgs, parseArgs,
renderText, renderText,
}; };

View File

@ -15,7 +15,12 @@ const {
renderText, renderText,
} = require(SCRIPT); } = require(SCRIPT);
const RELEASE_DIR = 'docs/releases/2.0.0-rc.1'; const CURRENT_RELEASE = require(path.join(__dirname, '..', '..', 'package.json')).version;
const RC_RELEASE = '2.0.0-rc.1';
function releaseDirFor(release) {
return `docs/releases/${release}`;
}
function createTempDir(prefix) { function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@ -31,14 +36,14 @@ function writeFile(rootDir, relativePath, content) {
fs.writeFileSync(targetPath, content); fs.writeFileSync(targetPath, content);
} }
function approvedPacketContent(overrides = {}) { function approvedPacketContent(overrides = {}, release = CURRENT_RELEASE) {
const decisions = new Map(REQUIRED_DECISIONS.map(decision => [decision.label, 'approve'])); const decisions = new Map(REQUIRED_DECISIONS.map(decision => [decision.label, 'approve']));
for (const [label, value] of Object.entries(overrides)) { for (const [label, value] of Object.entries(overrides)) {
decisions.set(label, value); decisions.set(label, value);
} }
return [ return [
'# ECC v2.0.0-rc.1 Owner Approval Packet', `# ECC v${release} Owner Approval Packet`,
'', '',
'## Decision Register', '## Decision Register',
'', '',
@ -58,16 +63,16 @@ function approvedPacketContent(overrides = {}) {
].join('\n'); ].join('\n');
} }
function finalLedgerContent(extra = '') { function finalLedgerContent(extra = '', release = CURRENT_RELEASE) {
return [ return [
'# ECC v2.0.0-rc.1 Release URL Ledger', `# ECC v${release} Release URL Ledger`,
'', '',
'## Final Published URLs', '## Final Published URLs',
'', '',
'| Surface | URL | Verification |', '| Surface | URL | Verification |',
'| --- | --- | --- |', '| --- | --- | --- |',
...REQUIRED_URL_SURFACES.map(surface => ( ...REQUIRED_URL_SURFACES.map(surface => (
`| ${surface.label} | ${surface.exampleUrl} | readback from final release commit |` `| ${surface.label} | ${surface.exampleUrl.split(RC_RELEASE).join(release)} | readback from final release commit |`
)), )),
'', '',
'## Final Verification Commands', '## Final Verification Commands',
@ -80,9 +85,9 @@ function finalLedgerContent(extra = '') {
].join('\n'); ].join('\n');
} }
function manifestContent() { function manifestContent(release = CURRENT_RELEASE) {
return [ return [
'# ECC v2.0.0-rc.1 Preview Pack Manifest', `# ECC v${release} Preview Pack Manifest`,
'', '',
'| Artifact | Role | Gate |', '| Artifact | Role | Gate |',
'| --- | --- | --- |', '| --- | --- | --- |',
@ -96,23 +101,26 @@ function manifestContent() {
].join('\n'); ].join('\n');
} }
function seedRepo(rootDir, overrides = {}) { function seedRepo(rootDir, overrides = {}, options = {}) {
const release = options.release || CURRENT_RELEASE;
const releaseDir = releaseDirFor(release);
const files = { const files = {
'package.json': JSON.stringify({ 'package.json': JSON.stringify({
version: release,
files: ['scripts/release-approval-gate.js'], files: ['scripts/release-approval-gate.js'],
scripts: { scripts: {
'release:approval-gate': 'node scripts/release-approval-gate.js', 'release:approval-gate': 'node scripts/release-approval-gate.js',
}, },
}, null, 2), }, null, 2),
'scripts/release-approval-gate.js': 'release approval gate script', 'scripts/release-approval-gate.js': 'release approval gate script',
[`${RELEASE_DIR}/owner-approval-packet-2026-05-19.md`]: approvedPacketContent(), [`${releaseDir}/owner-approval-packet-2026-05-19.md`]: approvedPacketContent({}, release),
[`${RELEASE_DIR}/release-url-ledger-2026-05-19.md`]: finalLedgerContent(), [`${releaseDir}/release-url-ledger-2026-05-19.md`]: finalLedgerContent('', release),
[`${RELEASE_DIR}/preview-pack-manifest.md`]: manifestContent(), [`${releaseDir}/preview-pack-manifest.md`]: manifestContent(release),
[`${RELEASE_DIR}/release-notes.md`]: 'Release notes with final URLs.', [`${releaseDir}/release-notes.md`]: 'Release notes with final URLs.',
[`${RELEASE_DIR}/x-thread.md`]: 'X post with final URLs.', [`${releaseDir}/x-thread.md`]: 'X post with final URLs.',
[`${RELEASE_DIR}/linkedin-post.md`]: 'LinkedIn post with final URLs.', [`${releaseDir}/linkedin-post.md`]: 'LinkedIn post with final URLs.',
[`${RELEASE_DIR}/article-outline.md`]: 'Article outline with final URLs.', [`${releaseDir}/article-outline.md`]: 'Article outline with final URLs.',
[`${RELEASE_DIR}/partner-sponsor-talks-pack.md`]: 'Outbound copy with final URLs.', [`${releaseDir}/partner-sponsor-talks-pack.md`]: 'Outbound copy with final URLs.',
'docs/business/social-launch-copy.md': 'Business launch copy with final URLs.', 'docs/business/social-launch-copy.md': 'Business launch copy with final URLs.',
}; };
@ -189,6 +197,7 @@ function runTests() {
const report = buildReport({ root: rootDir }); const report = buildReport({ root: rootDir });
assert.strictEqual(report.schema_version, 'ecc.release-approval-gate.v1'); assert.strictEqual(report.schema_version, 'ecc.release-approval-gate.v1');
assert.strictEqual(report.release, CURRENT_RELEASE);
assert.strictEqual(report.ready, true); assert.strictEqual(report.ready, true);
assert.strictEqual(report.summary.failed, 0); assert.strictEqual(report.summary.failed, 0);
assert.deepStrictEqual(report.top_actions, []); assert.deepStrictEqual(report.top_actions, []);
@ -202,12 +211,27 @@ function runTests() {
} }
})) passed++; else failed++; })) passed++; else failed++;
if (test('release override keeps rc.1 approval fixtures testable', () => {
const rootDir = createTempDir('release-approval-rc-');
try {
seedRepo(rootDir, {}, { release: RC_RELEASE });
const report = buildReport({ root: rootDir, release: RC_RELEASE });
assert.strictEqual(report.release, RC_RELEASE);
assert.strictEqual(report.ready, true);
} finally {
cleanup(rootDir);
}
})) passed++; else failed++;
if (test('deferred owner decisions keep the publication gate blocked', () => { if (test('deferred owner decisions keep the publication gate blocked', () => {
const rootDir = createTempDir('release-approval-deferred-'); const rootDir = createTempDir('release-approval-deferred-');
try { try {
const releaseDir = releaseDirFor(CURRENT_RELEASE);
seedRepo(rootDir, { seedRepo(rootDir, {
[`${RELEASE_DIR}/owner-approval-packet-2026-05-19.md`]: approvedPacketContent({ [`${releaseDir}/owner-approval-packet-2026-05-19.md`]: approvedPacketContent({
'GitHub prerelease': 'defer', 'GitHub prerelease': 'defer',
'Sponsor, partner, consulting, conference, podcast outreach': 'block', 'Sponsor, partner, consulting, conference, podcast outreach': 'block',
}), }),
@ -230,15 +254,16 @@ function runTests() {
const rootDir = createTempDir('release-approval-ledger-'); const rootDir = createTempDir('release-approval-ledger-');
try { try {
const releaseDir = releaseDirFor(CURRENT_RELEASE);
seedRepo(rootDir, { seedRepo(rootDir, {
[`${RELEASE_DIR}/release-url-ledger-2026-05-19.md`]: [ [`${releaseDir}/release-url-ledger-2026-05-19.md`]: [
'# ECC v2.0.0-rc.1 Release URL Ledger', `# ECC v${CURRENT_RELEASE} Release URL Ledger`,
'', '',
'## Approval-Gated URLs', '## Approval-Gated URLs',
'', '',
'| Surface | Intended URL or command | Gate before use |', '| Surface | Intended URL or command | Gate before use |',
'| --- | --- | --- |', '| --- | --- | --- |',
'| GitHub prerelease | https://github.com/affaan-m/ECC/releases/tag/v2.0.0-rc.1 | must return the prerelease |', `| GitHub prerelease | https://github.com/affaan-m/ECC/releases/tag/v${CURRENT_RELEASE} | must return the prerelease |`,
].join('\n'), ].join('\n'),
}); });
@ -257,8 +282,9 @@ function runTests() {
const rootDir = createTempDir('release-approval-copy-'); const rootDir = createTempDir('release-approval-copy-');
try { try {
const releaseDir = releaseDirFor(CURRENT_RELEASE);
seedRepo(rootDir, { seedRepo(rootDir, {
[`${RELEASE_DIR}/x-thread.md`]: 'Ship copy with <video-url> and /Users/affaan/raw-footage.', [`${releaseDir}/x-thread.md`]: 'Ship copy with <video-url> and /Users/affaan/raw-footage.',
}); });
const report = buildReport({ root: rootDir }); const report = buildReport({ root: rootDir });
@ -266,7 +292,7 @@ function runTests() {
assert.strictEqual(report.ready, false); assert.strictEqual(report.ready, false);
assert.strictEqual(copy.status, 'fail'); assert.strictEqual(copy.status, 'fail');
assert.ok(copy.evidence.includes(`${RELEASE_DIR}/x-thread.md:1`)); assert.ok(copy.evidence.includes(`${releaseDir}/x-thread.md:1`));
} finally { } finally {
cleanup(rootDir); cleanup(rootDir);
} }
@ -280,10 +306,12 @@ function runTests() {
const stdout = run(['--format=json', `--root=${rootDir}`], { cwd: rootDir }); const stdout = run(['--format=json', `--root=${rootDir}`], { cwd: rootDir });
const parsed = JSON.parse(stdout); const parsed = JSON.parse(stdout);
assert.strictEqual(parsed.ready, true); assert.strictEqual(parsed.ready, true);
assert.strictEqual(parsed.release, CURRENT_RELEASE);
const releaseDir = releaseDirFor(CURRENT_RELEASE);
writeFile( writeFile(
rootDir, rootDir,
`${RELEASE_DIR}/owner-approval-packet-2026-05-19.md`, `${releaseDir}/owner-approval-packet-2026-05-19.md`,
approvedPacketContent({ 'Video upload': 'defer' }) approvedPacketContent({ 'Video upload': 'defer' })
); );
const failedRun = runProcess(['--format=json', `--root=${rootDir}`], { cwd: rootDir }); const failedRun = runProcess(['--format=json', `--root=${rootDir}`], { cwd: rootDir });