From 18840fb96c9d5e16befa732a31c2401488cbad83 Mon Sep 17 00:00:00 2001 From: Jason Kummerl Date: Fri, 2 Oct 2026 13:52:17 -0400 Subject: [PATCH 1/4] ci: guard signed CalVer release ownership --- .github/release-policy.json | 7 + .github/release-source.json | 13 + .github/scripts/calver-release.mjs | 141 +++ .github/scripts/calver-release.test.mjs | 261 ++++ .github/scripts/release-publication.mjs | 644 ++++++++++ .github/scripts/release-publication.test.mjs | 1181 ++++++++++++++++++ .github/scripts/release-workflow.test.mjs | 277 ++++ .github/workflows/release.yml | 121 +- .github/workflows/run-tests.yml | 57 + 9 files changed, 2665 insertions(+), 37 deletions(-) create mode 100644 .github/release-policy.json create mode 100644 .github/release-source.json create mode 100644 .github/scripts/calver-release.mjs create mode 100644 .github/scripts/calver-release.test.mjs create mode 100644 .github/scripts/release-publication.mjs create mode 100644 .github/scripts/release-publication.test.mjs create mode 100644 .github/scripts/release-workflow.test.mjs create mode 100644 .github/workflows/run-tests.yml diff --git a/.github/release-policy.json b/.github/release-policy.json new file mode 100644 index 0000000..605069f --- /dev/null +++ b/.github/release-policy.json @@ -0,0 +1,7 @@ +{ + "manager": "pnpm", + "event": "calver", + "manifests": ["package.json"], + "lockfile": null, + "readme": "unchanged" +} diff --git a/.github/release-source.json b/.github/release-source.json new file mode 100644 index 0000000..644a587 --- /dev/null +++ b/.github/release-source.json @@ -0,0 +1,13 @@ +{ + "files": { + ".github/scripts/calver-release.mjs": "323d9c692a449383410e267dee5e00b29e3a6f7a1b8a20bc9c26f87d973bb73f", + ".github/scripts/calver-release.test.mjs": "5ad6c3bfb830df8ba656815a0748f47bf20f0d878e5fa1f1811bdc278ed3b132", + ".github/scripts/release-publication.mjs": "c03d90062b6fcb38e11f9bb4cb5fd8c9a14a34a56bb67870f64d81af43f56e8e", + ".github/scripts/release-publication.test.mjs": "a6176fdcf61b0d118670ce1d43a09f39a12d4171e1ab6fed7960dfe048145ebf", + ".github/scripts/release-workflow.test.mjs": "7a9e393cad156ac86e19b4b6aa1c72afedd5a6012652c6459e288f501ab43609", + ".github/workflows/release.yml": "194fa090c71821afac12674ed0b78228c18ce82d5a4757b56301aaecd5af0b10", + ".github/workflows/run-tests.yml": "5bb1940c60ed3857c313a221f32c208d537302852a2d4ea706337380ff40a1f4" + }, + "repository": "humanspeak/svelte-diff", + "revision": "23ff1d373da36e28c13d41280571101be0e14a4e" +} diff --git a/.github/scripts/calver-release.mjs b/.github/scripts/calver-release.mjs new file mode 100644 index 0000000..0dbdc91 --- /dev/null +++ b/.github/scripts/calver-release.mjs @@ -0,0 +1,141 @@ +import console from 'node:console' +import { appendFileSync } from 'node:fs' +import { resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { + cleanup, + createRelease, + initialize, + pushArtifacts, + readState, + selectTagBaseline, + transport, + writeState +} from './release-publication.mjs' + +const requireValue = (condition, message) => { + if (!condition) throw new Error(message) +} +const run = (exec, command, args) => { + const result = exec(command, args) + requireValue(result.code === 0, `${command} operation failed; reconcile identifiers manually`) + return result.stdout.trim() +} +export const nextCalver = (tags, date) => { + const year = date.getUTCFullYear(), + month = date.getUTCMonth() + 1 + requireValue(year >= 2000 && year < 10000, 'Invalid release date') + let highest = 0 + for (const row of tags.trim().split('\n').filter(Boolean)) { + const match = row.match(/^[a-f0-9]{40}\s+refs\/tags\/(\d+)\.(\d+)\.(\d+)(\^\{\})?$/) + if (!match) { + // Foreign tags are allowed, but a malformed tag in this month is ambiguous. + requireValue( + !row.includes(`refs/tags/${year}.${month}.`), + 'Malformed monthly CalVer tag' + ) + continue + } + if (Number(match[1]) === year && Number(match[2]) === month) { + const micro = Number(match[3]) + requireValue(Number.isSafeInteger(micro) && micro > 0, 'Invalid CalVer micro') + highest = Math.max(highest, micro) + } + } + requireValue(Number.isSafeInteger(highest + 1), 'CalVer micro exhausted') + return `${year}.${month}.${highest + 1}` +} +export const createOwnedCalverTag = (path, identity, date = new Date(), exec = transport) => { + const state = readState(path, identity) + requireValue(state.version === null && state.tag === 'not-attempted', 'Tag already attempted') + const tags = run(exec, 'git', ['ls-remote', '--tags', 'origin']) + const version = nextCalver(tags, date) + run(exec, 'git', ['fetch', '--no-tags', 'origin', 'refs/heads/main']) + requireValue( + run(exec, 'git', ['rev-parse', 'FETCH_HEAD']) === state.base, + 'Stale tested CalVer baseline' + ) + requireValue(run(exec, 'git', ['rev-parse', 'HEAD']) === state.base, 'Untested CalVer checkout') + state.scheme = 'calver' + state.version = version + state.commit = state.base + run(exec, 'git', ['tag', '-a', version, '-m', `Release ${version}`]) + state.tagOid = run(exec, 'git', ['rev-parse', `refs/tags/${version}`]) + requireValue( + run(exec, 'git', ['cat-file', '-t', state.tagOid]) === 'tag', + 'Annotated tag required' + ) + requireValue( + run(exec, 'git', ['rev-parse', `refs/tags/${version}^{}`]) === state.base, + 'Invalid CalVer tag target' + ) + writeState(path, state, identity) + return version +} + +const cli = () => { + const env = process.env + const identity = { run: env.GITHUB_RUN_ID, attempt: env.GITHUB_RUN_ATTEMPT } + const path = env.RELEASE_STATE + const output = (key, value) => appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`) + switch (process.argv[2]) { + case 'prepare': { + let skip = false + if (env.EVENT_NAME === 'push') { + const prs = JSON.parse( + run(transport, 'gh', [ + 'api', + `repos/${env.GITHUB_REPOSITORY}/commits/${env.EVENT_SHA}/pulls` + ]) + ) + requireValue(Array.isArray(prs), 'Malformed original-event PR lookup') + skip = prs.some( + (pr) => + pr.merged_at && pr.labels?.some((label) => label.name === 'skip-publish') + ) + } + const result = selectTagBaseline({ + event: env.EVENT_NAME, + eventSha: env.EVENT_SHA, + ref: env.EVENT_REF, + skip + }) + output('ready', result.ready) + output('checkout_sha', result.checkoutSha || '') + console.log( + `${result.outcome}: ${result.checkoutSha || 'no release; retry from current main'}` + ) + break + } + case 'begin': { + const result = initialize(path, identity, env.PREPARED_SHA) + output('ready', result.ready) + break + } + case 'tag': + output('tag', createOwnedCalverTag(path, identity)) + break + case 'push': + pushArtifacts(path, identity) + break + case 'release': + createRelease(path, identity, '', transport, true) + break + case 'cleanup': + cleanup(path, identity, env.RELEASE_SUCCESS === 'success') + break + default: + throw new Error('Unsupported CalVer release operation') + } +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + cli() + } catch (error) { + console.error( + `CalVer release stopped: ${error instanceof SyntaxError ? 'malformed response' : error.code ? 'state operation failed' : error.message}` + ) + process.exitCode = 1 + } +} diff --git a/.github/scripts/calver-release.test.mjs b/.github/scripts/calver-release.test.mjs new file mode 100644 index 0000000..1f08c2a --- /dev/null +++ b/.github/scripts/calver-release.test.mjs @@ -0,0 +1,261 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { URL } from 'node:url' +import { createOwnedCalverTag, nextCalver } from './calver-release.mjs' +import { pushArtifacts, readState, selectTagBaseline } from './release-publication.mjs' + +const sha = 'a'.repeat(40), + oid = 'b'.repeat(40), + newer = 'c'.repeat(40) +const identity = { run: '17', attempt: '2' } +const date = new Date('2026-10-02T01:00:00Z') +const fixture = (t, changes = {}) => { + const dir = mkdtempSync(join(tmpdir(), 'calver-release-')) + t.after(() => rmSync(dir, { recursive: true, force: true })) + const path = join(dir, 'state.json') + writeFileSync( + path, + JSON.stringify({ + ...identity, + base: sha, + version: null, + commit: null, + tagOid: null, + tag: 'not-attempted', + release: 'not-attempted', + releaseId: null, + registry: 'not-attempted', + canonical: false, + ...changes + }) + ) + return path +} +const scripted = (expected) => { + let index = 0 + return { + exec: (command, args) => { + const row = expected[index++] + assert.ok(row, 'Unexpected transport call') + assert.deepEqual([command, ...args], row[0]) + return { code: 0, stdout: '', ...row[1] } + }, + done: () => assert.equal(index, expected.length) + } +} +const fetch = (head) => [ + [['git', 'fetch', '--no-tags', 'origin', 'refs/heads/main'], {}], + [['git', 'rev-parse', 'FETCH_HEAD'], { stdout: head }] +] + +test('CalVer selects UTC month and highest remote micro, rejecting malformed monthly identities', () => { + assert.equal(nextCalver('', date), '2026.10.1') + assert.equal( + nextCalver( + `${sha}\trefs/tags/2026.10.2\n${oid}\trefs/tags/2026.10.11\n${sha}\trefs/tags/2026.10.11^{}\n`, + date + ), + '2026.10.12' + ) + assert.equal( + nextCalver(`${sha}\trefs/tags/foreign\n${sha}\trefs/tags/2026.9.99\n`, date), + '2026.10.1' + ) + assert.throws(() => nextCalver('bad refs/tags/2026.10.2', date)) + assert.throws(() => nextCalver(`${sha}\trefs/tags/2026.10.9007199254740992`, date)) +}) + +test('CalVer automatic stale baseline skips all advancement; manual main resolves before testing', () => { + const input = { event: 'push', eventSha: sha, ref: 'refs/heads/main', skip: false } + const stale = scripted(fetch(newer)) + assert.equal(selectTagBaseline(input, stale.exec).outcome, 'stale') + stale.done() + const manual = scripted(fetch(newer)) + assert.equal( + selectTagBaseline({ ...input, event: 'workflow_dispatch' }, manual.exec).checkoutSha, + newer + ) + manual.done() + const none = scripted([]) + assert.equal(selectTagBaseline({ ...input, skip: true }, none.exec).ready, false) + assert.equal(selectTagBaseline({ ...input, ref: 'refs/heads/feature' }, none.exec).ready, false) + none.done() +}) + +test('CalVer main recheck occurs before tag mutation and stale run creates nothing', (t) => { + const path = fixture(t) + const stale = scripted([[['git', 'ls-remote', '--tags', 'origin'], {}], ...fetch(newer)]) + assert.throws(() => createOwnedCalverTag(path, identity, date, stale.exec), /Stale tested/) + stale.done() + assert.equal(readState(path, identity).version, null) +}) + +test('CalVer records exact annotated tag and tested commit without a version commit', (t) => { + const path = fixture(t) + const mock = scripted([ + [['git', 'ls-remote', '--tags', 'origin'], {}], + ...fetch(sha), + [['git', 'rev-parse', 'HEAD'], { stdout: sha }], + [['git', 'tag', '-a', '2026.10.1', '-m', 'Release 2026.10.1'], {}], + [['git', 'rev-parse', 'refs/tags/2026.10.1'], { stdout: oid }], + [['git', 'cat-file', '-t', oid], { stdout: 'tag' }], + [['git', 'rev-parse', 'refs/tags/2026.10.1^{}'], { stdout: sha }] + ]) + assert.equal(createOwnedCalverTag(path, identity, date, mock.exec), '2026.10.1') + mock.done() + assert.equal(readState(path, identity).scheme, 'calver') + assert.equal(readState(path, identity).commit, sha) + assert.equal(readState(path, identity).tagOid, oid) +}) + +test('CalVer push publishes only intended tag and forbids untested commit', (t) => { + const path = fixture(t, { scheme: 'calver', version: '2026.10.1', commit: sha, tagOid: oid }) + const mock = scripted([ + [['git', 'ls-remote', 'origin', 'refs/tags/2026.10.1', 'refs/tags/2026.10.1^{}'], {}], + [ + ['gh', 'api', '--include', 'repos/{owner}/{repo}/releases/tags/2026.10.1'], + { code: 1, stdout: 'HTTP/2.0 404 Not found\r\nContent-Type: application/json\r\n\r\n' } + ], + [ + ['git', 'push', '--porcelain', 'origin', 'refs/tags/2026.10.1:refs/tags/2026.10.1'], + { stdout: '*\trefs/tags/2026.10.1:refs/tags/2026.10.1\t[new tag]\n' } + ], + [ + ['git', 'ls-remote', 'origin', 'refs/tags/2026.10.1', 'refs/tags/2026.10.1^{}'], + { stdout: `${oid}\trefs/tags/2026.10.1\n${sha}\trefs/tags/2026.10.1^{}\n` } + ] + ]) + pushArtifacts(path, identity, mock.exec) + mock.done() + assert.equal(readState(path, identity).tag, 'created') + const wrong = fixture(t, { scheme: 'calver', version: '2026.10.1', commit: newer, tagOid: oid }) + const reject = scripted([ + [['git', 'ls-remote', 'origin', 'refs/tags/2026.10.1', 'refs/tags/2026.10.1^{}'], {}], + [ + ['gh', 'api', '--include', 'repos/{owner}/{repo}/releases/tags/2026.10.1'], + { code: 1, stdout: 'HTTP/2.0 404 Not found\r\nContent-Type: application/json\r\n\r\n' } + ] + ]) + assert.throws(() => pushArtifacts(wrong, identity, reject.exec), /Untested CalVer/) + reject.done() +}) + +test('CalVer workflow tests and releases one baseline without package mutation or registry publication', () => { + const policy = JSON.parse( + readFileSync(new URL('../release-policy.json', import.meta.url), 'utf8') + ) + if (policy.event !== 'calver') return + const workflow = readFileSync(new URL('../workflows/release.yml', import.meta.url), 'utf8') + assert.match(workflow, /group: repository-release\n {4}cancel-in-progress: false/) + assert.match(workflow, /ref: \$\{\{ needs.prepare.outputs.checkout_sha \}\}/) + assert.ok(workflow.indexOf('pnpm run check') < workflow.indexOf('pnpm build')) + assert.ok(workflow.indexOf('.mjs begin') < workflow.indexOf('.mjs tag')) + assert.ok(workflow.indexOf('.mjs push') < workflow.indexOf('.mjs release')) + assert.match(workflow, /steps.created.outcome != 'success'/) + assert.ok(!/pnpm version|pnpm publish|git push --tags|gh release delete/.test(workflow)) +}) + +test('CalVer release creation preserves generated notes and records numeric ownership', async (t) => { + const { createRelease } = await import('./release-publication.mjs') + const path = fixture(t, { + scheme: 'calver', + version: '2026.10.1', + commit: sha, + tagOid: oid, + tag: 'created' + }) + let calls = 0 + createRelease( + path, + identity, + '', + (command, args) => { + assert.equal(command, 'gh') + if (++calls === 1) { + assert.deepEqual(args, [ + 'api', + '--include', + 'repos/{owner}/{repo}/releases/tags/2026.10.1' + ]) + return { + code: 1, + stdout: 'HTTP/2.0 404 Fixture\r\nContent-Type: application/json\r\n\r\n' + } + } + assert.deepEqual(args, [ + 'api', + '--include', + '--method', + 'POST', + 'repos/{owner}/{repo}/releases', + '--input', + `${path}.request.json` + ]) + const request = JSON.parse(readFileSync(`${path}.request.json`, 'utf8')) + assert.equal(request.generate_release_notes, true) + assert.equal(request.target_commitish, sha) + assert.ok(!Object.hasOwn(request, 'body')) + return { + code: 0, + stdout: `HTTP/2.0 201 Fixture\r\nContent-Type: application/json\r\n\r\n{"id":37,"tag_name":"2026.10.1","target_commitish":"${sha}"}` + } + }, + true + ) + assert.equal(calls, 2) + assert.equal(readState(path, identity).releaseId, 37) +}) + +test('CalVer cleanup leases exact numeric tag identity and is safe to repeat', async (t) => { + const { cleanup } = await import('./release-publication.mjs') + const path = fixture(t, { + scheme: 'calver', + version: '2026.10.1', + commit: sha, + tagOid: oid, + tag: 'created', + release: 'created', + releaseId: 37 + }) + const api = (status, body = '') => ({ + code: status === 404 ? 1 : 0, + stdout: `HTTP/2.0 ${status} Fixture\r\nContent-Type: application/json\r\n\r\n${body}` + }) + const tag = ['git', 'ls-remote', 'origin', 'refs/tags/2026.10.1', 'refs/tags/2026.10.1^{}'] + const data = `{"id":37,"tag_name":"2026.10.1","target_commitish":"${sha}"}` + const expected = [ + [tag, { stdout: `${oid}\trefs/tags/2026.10.1\n${sha}\trefs/tags/2026.10.1^{}\n` }], + [['gh', 'api', '--include', 'repos/{owner}/{repo}/releases/37'], api(200, data)], + [ + ['gh', 'api', '--include', 'repos/{owner}/{repo}/releases/tags/2026.10.1'], + api(200, data) + ], + [ + ['gh', 'api', '--include', '--method', 'DELETE', 'repos/{owner}/{repo}/releases/37'], + api(204) + ], + [ + [ + 'git', + 'push', + `--force-with-lease=refs/tags/2026.10.1:${oid}`, + 'origin', + ':refs/tags/2026.10.1' + ], + {} + ] + ] + const first = scripted(expected) + cleanup(path, identity, false, first.exec, () => assert.fail('Unexpected retention')) + first.done() + const repeated = scripted([ + [tag, {}], + [['gh', 'api', '--include', 'repos/{owner}/{repo}/releases/37'], api(404)], + [['gh', 'api', '--include', 'repos/{owner}/{repo}/releases/tags/2026.10.1'], api(404)] + ]) + cleanup(path, identity, false, repeated.exec, () => assert.fail('Unexpected retention')) + repeated.done() +}) diff --git a/.github/scripts/release-publication.mjs b/.github/scripts/release-publication.mjs new file mode 100644 index 0000000..21be6ef --- /dev/null +++ b/.github/scripts/release-publication.mjs @@ -0,0 +1,644 @@ +import { spawnSync } from 'node:child_process' +import console from 'node:console' +import { appendFileSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { isDeepStrictEqual } from 'node:util' + +const shaPattern = /^[a-f0-9]{40}$/ +const versionPattern = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ +const statuses = ['not-attempted', 'created', 'unknown'] +export const defaultPolicy = { manifests: ['package.json'], lockfile: null, readme: 'unchanged' } +export const readPolicy = () => { + const policy = JSON.parse(readFileSync('.github/release-policy.json', 'utf8')) + requireValue( + Array.isArray(policy.manifests) && + policy.manifests[0] === 'package.json' && + new Set(policy.manifests).size === policy.manifests.length && + policy.manifests.every((path) => /^(?:[a-zA-Z0-9_-]+\/)*package\.json$/.test(path)) && + [null, 'package-lock.json'].includes(policy.lockfile) && + ['managed', 'unchanged'].includes(policy.readme), + 'Invalid repository release policy' + ) + return policy +} +const retry = 'no release created; use a fresh qualifying main event or manual retry' +const requireValue = (condition, message) => { + if (!condition) throw new Error(message) +} +export const transport = (command, args) => { + const result = spawnSync(command, args, { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }) + // Never expose subprocess stderr: git can include an authenticated remote URL. + return { code: result.error ? -1 : result.status, stdout: result.stdout || '' } +} +const run = (exec, command, args) => { + const result = exec(command, args) + requireValue(result.code === 0, `${command} operation failed; reconcile identifiers manually`) + return result.stdout +} +const oidValue = (text) => { + const value = text.trim() + requireValue(shaPattern.test(value), 'Invalid commit identity') + return value +} +export const nextVersion = (before, after) => { + if (!versionPattern.test(`v${before}`) || !versionPattern.test(`v${after}`)) return false + if ( + ![...before.split('.'), ...after.split('.')].every((value) => + Number.isSafeInteger(Number(value)) + ) + ) + return false + const [a, b, c] = before.split('.').map(Number) + return [`${a + 1}.0.0`, `${a}.${b + 1}.0`, `${a}.${b}.${c + 1}`].includes(after) +} +const outsideBlock = (text) => { + const start = '' + const end = '' + requireValue( + text.split(start).length === 2 && text.split(end).length === 2, + 'Managed README block must be unique' + ) + const a = text.indexOf(start), + b = text.indexOf(end) + requireValue(a < b, 'Invalid managed README block') + return [text.slice(0, a), text.slice(b + end.length)] +} +export const validateMetadata = (before, after, paths, policy = defaultPolicy) => { + const allowed = [ + ...policy.manifests, + ...(policy.lockfile ? [policy.lockfile] : []), + ...(policy.readme === 'managed' ? ['README.md'] : []) + ] + requireValue( + paths.length > 0 && paths.every((path) => allowed.includes(path)), + 'Unrecognized release delta' + ) + const old = JSON.parse(before('package.json')), + fresh = JSON.parse(after('package.json')) + requireValue(nextVersion(old.version, fresh.version), 'Invalid version transition') + for (const path of policy.manifests) { + const a = JSON.parse(before(path)), + b = JSON.parse(after(path)) + requireValue( + a.version === old.version && b.version === fresh.version, + 'Shim version mismatch' + ) + delete a.version + delete b.version + if (path !== 'package.json') { + requireValue( + a.dependencies?.[old.name] === `^${old.version}` && + b.dependencies?.[old.name] === `^${fresh.version}`, + 'Shim dependency mismatch' + ) + Reflect.deleteProperty(a.dependencies, old.name) + Reflect.deleteProperty(b.dependencies, old.name) + } + requireValue(isDeepStrictEqual(a, b), 'Non-version manifest change') + } + if (policy.lockfile) { + const a = JSON.parse(before(policy.lockfile)), + b = JSON.parse(after(policy.lockfile)) + requireValue( + a.version === old.version && + b.version === fresh.version && + a.packages?.['']?.version === old.version && + b.packages?.['']?.version === fresh.version, + 'Lockfile root version mismatch' + ) + delete a.version + delete b.version + delete a.packages[''].version + delete b.packages[''].version + requireValue(isDeepStrictEqual(a, b), 'Non-version lockfile change') + } + if (policy.readme === 'managed') { + const a = before('README.md'), + b = after('README.md') + // An unchanged README is valid even if a consumer has not installed its footer yet. + if (a !== b) + requireValue( + isDeepStrictEqual(outsideBlock(a), outsideBlock(b)), + 'Unmanaged README change' + ) + } + return `v${fresh.version}` +} +const main = (exec) => { + run(exec, 'git', ['fetch', '--no-tags', 'origin', 'refs/heads/main']) + return oidValue(run(exec, 'git', ['rev-parse', 'FETCH_HEAD'])) +} +export const selectBaseline = (input, exec = transport, policy = defaultPolicy) => { + const noop = (outcome) => ({ outcome, ready: false, message: retry }) + if (input.skip || (['push', 'pull_request'].includes(input.event) && !input.merged)) + return noop('skipped') + if (input.event === 'workflow_dispatch' && input.ref !== 'refs/heads/main') + return noop('unsupported non-main manual request') + requireValue( + ['push', 'pull_request', 'workflow_dispatch'].includes(input.event), + 'Unsupported event' + ) + requireValue(shaPattern.test(input.eventSha), 'Invalid original event SHA') + const current = main(exec) + if (input.event !== 'workflow_dispatch' && current !== input.eventSha) { + if (exec('git', ['merge-base', '--is-ancestor', input.eventSha, current]).code !== 0) + return noop('stale') + try { + const commits = run(exec, 'git', [ + 'rev-list', + '--reverse', + '--first-parent', + `${input.eventSha}..${current}` + ]) + .trim() + .split('\n') + let parent = input.eventSha + for (const commit of commits) { + oidValue(commit) + // A release commit must have exactly its tested parent, never a merge. + requireValue( + run(exec, 'git', ['rev-list', '--parents', '-n', '1', commit]).trim() === + `${commit} ${parent}`, + 'Unexpected release ancestry' + ) + requireValue( + run(exec, 'git', ['diff', '--summary', parent, commit]).trim() === '', + 'Release file structure changed' + ) + const paths = run(exec, 'git', ['diff', '--name-only', parent, commit]) + .trim() + .split('\n') + validateMetadata( + (path) => run(exec, 'git', ['show', `${parent}:${path}`]), + (path) => run(exec, 'git', ['show', `${commit}:${path}`]), + paths, + policy + ) + parent = commit + } + requireValue(parent === current, 'Incomplete release ancestry') + } catch { + return noop('stale') + } + } + const version = JSON.parse(run(exec, 'git', ['show', `${current}:package.json`])).version + requireValue(versionPattern.test(`v${version}`), 'Invalid prepared version') + return { ready: true, outcome: 'ready', checkoutSha: current, version } +} + +export const selectTagBaseline = (input, exec = transport) => { + requireValue(['push', 'workflow_dispatch'].includes(input.event), 'Unsupported event') + requireValue(shaPattern.test(input.eventSha), 'Invalid original event SHA') + if (input.skip || input.ref !== 'refs/heads/main') return { ready: false, outcome: 'skipped' } + const current = main(exec) + if (input.event === 'push' && current !== input.eventSha) + return { ready: false, outcome: 'stale', message: retry } + return { ready: true, outcome: 'ready', checkoutSha: current } +} + +export const validateState = (state, identity) => { + requireValue( + state && + state.run === identity.run && + state.attempt === identity.attempt && + /^\d+$/.test(state.run) && + /^\d+$/.test(state.attempt), + 'Missing or foreign run state' + ) + requireValue( + shaPattern.test(state.base) && + (state.version === null || + (state.scheme === 'calver' + ? /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(state.version) + : versionPattern.test(state.version))) && + (state.commit === null || shaPattern.test(state.commit)) && + (state.tagOid === null || shaPattern.test(state.tagOid)), + 'Malformed artifact identity' + ) + requireValue( + state.scheme === undefined || state.scheme === 'calver', + 'Malformed release scheme' + ) + requireValue( + statuses.includes(state.tag) && + statuses.includes(state.release) && + ['not-attempted', 'unknown', 'published'].includes(state.registry) && + typeof state.canonical === 'boolean', + 'Malformed lifecycle state' + ) + requireValue( + state.releaseId === null || (Number.isSafeInteger(state.releaseId) && state.releaseId > 0), + 'Malformed release ID' + ) + requireValue( + state.tag !== 'created' || (state.tagOid && state.commit && state.version), + 'Incomplete tag ownership' + ) + requireValue( + state.release !== 'created' || (state.releaseId && state.tag === 'created'), + 'Incomplete release ownership' + ) + requireValue( + !state.canonical || state.registry === 'published', + 'Malformed publication barrier' + ) + return state +} +export const readState = (path, identity) => + validateState(JSON.parse(readFileSync(path, 'utf8')), identity) +export const writeState = (path, state, identity) => { + validateState(state, identity) + const temporary = `${path}.${process.pid}.tmp` + let failure + try { + writeFileSync(temporary, JSON.stringify(state) + '\n', { mode: 0o600, flag: 'wx' }) + renameSync(temporary, path) + } catch (error) { + failure = error + } + try { + unlinkSync(temporary) + } catch (error) { + if (error.code !== 'ENOENT') failure ||= error + } + if (failure) throw failure +} +export const initialize = (path, identity, base, exec = transport) => { + requireValue(shaPattern.test(base), 'Invalid tested SHA') + if (main(exec) !== base) return { ready: false, message: retry } + requireValue( + oidValue(run(exec, 'git', ['rev-parse', 'HEAD'])) === base, + 'Checkout differs from tested SHA' + ) + requireValue( + run(exec, 'git', ['status', '--porcelain']).trim() === '', + 'Dirty release checkout' + ) + const state = { + ...identity, + base, + version: null, + commit: null, + tagOid: null, + tag: 'not-attempted', + release: 'not-attempted', + releaseId: null, + registry: 'not-attempted', + canonical: false + } + writeState(path, state, identity) + run(exec, 'git', ['switch', '-c', `release-${identity.run}-${identity.attempt}`, base]) + return { ready: true } +} +const api = (exec, args) => { + const result = exec('gh', ['api', '--include', ...args]) + const match = result.stdout.match(/^HTTP\/\S+ (\d{3})[^\r\n]*\r?\n[\s\S]*?\r?\n\r?\n([\s\S]*)$/) + requireValue(match, 'Unknown GitHub API response') + const status = Number(match[1]) + requireValue( + (status >= 200 && status < 300 && result.code === 0) || + (status === 404 && result.code !== 0), + 'GitHub API operation failed' + ) + return { status, data: status === 204 || status === 404 ? null : JSON.parse(match[2]) } +} +const releasePath = 'repos/{owner}/{repo}/releases' +const remoteTag = (exec, version) => { + const text = run(exec, 'git', [ + 'ls-remote', + 'origin', + `refs/tags/${version}`, + `refs/tags/${version}^{}` + ]).trim() + if (!text) return null + const rows = text.split('\n').map((line) => line.split(/\s+/)) + requireValue( + rows.length === 2 && rows.every((row) => row.length === 2 && shaPattern.test(row[0])), + 'Unknown annotated tag response' + ) + const object = rows.find((row) => row[1] === `refs/tags/${version}`) + const peeled = rows.find((row) => row[1] === `refs/tags/${version}^{}`) + requireValue(object && peeled, 'Unknown tag identity') + return { oid: object[0], commit: peeled[0] } +} +export const validateDelta = ( + state, + exec = transport, + read = (path) => readFileSync(path, 'utf8'), + policy = defaultPolicy +) => { + requireValue( + oidValue(run(exec, 'git', ['rev-parse', 'HEAD'])) === state.base, + 'Untested release parent' + ) + requireValue( + run(exec, 'git', ['ls-files', '--others', '--exclude-standard']).trim() === '', + 'Untracked release content' + ) + requireValue( + run(exec, 'git', ['diff', '--summary', 'HEAD']).trim() === '', + 'Release file structure changed' + ) + const paths = run(exec, 'git', ['diff', '--name-only', 'HEAD']).trim().split('\n') + return validateMetadata( + (path) => run(exec, 'git', ['show', `${state.base}:${path}`]), + read, + paths, + policy + ) +} +export const pushArtifacts = (path, identity, exec = transport) => { + const state = readState(path, identity) + requireValue( + state.version && state.commit && state.tagOid && state.tag === 'not-attempted', + 'Invalid pre-push state' + ) + requireValue(remoteTag(exec, state.version) === null, 'Version tag collision') + requireValue( + api(exec, [`${releasePath}/tags/${state.version}`]).status === 404, + 'Version release collision' + ) + if (state.scheme === 'calver') + requireValue(state.commit === state.base, 'Untested CalVer commit') + else run(exec, 'git', ['push', 'origin', 'HEAD:refs/heads/main']) + state.tag = 'unknown' + writeState(path, state, identity) + const result = exec('git', [ + 'push', + '--porcelain', + 'origin', + `refs/tags/${state.version}:refs/tags/${state.version}` + ]) + // Exit zero also covers up-to-date. Only the porcelain new-ref flag proves creation. + const expected = `*\trefs/tags/${state.version}:refs/tags/${state.version}\t[new tag]` + requireValue( + result.code === 0 && result.stdout.split(/\r?\n/).includes(expected), + 'Tag creation unconfirmed; retain metadata' + ) + const observed = remoteTag(exec, state.version) + requireValue( + observed?.oid === state.tagOid && observed.commit === state.commit, + 'Created tag identity changed' + ) + state.tag = 'created' + writeState(path, state, identity) +} +export const createRelease = (path, identity, notes, exec = transport, generateNotes = false) => { + const state = readState(path, identity) + requireValue( + state.tag === 'created' && state.release === 'not-attempted', + 'Release requires confirmed tag creation' + ) + requireValue( + api(exec, [`${releasePath}/tags/${state.version}`]).status === 404, + 'Version release collision' + ) + state.release = 'unknown' + writeState(path, state, identity) + const request = `${path}.request.json` + writeFileSync( + request, + JSON.stringify({ + // eslint-disable-next-line camelcase -- GitHub release API requires this wire field. + tag_name: state.version, + // eslint-disable-next-line camelcase -- GitHub release API requires this wire field. + target_commitish: state.commit, + name: `Release ${state.version}`, + ...(generateNotes + ? { + // eslint-disable-next-line camelcase -- GitHub release API requires this wire field. + generate_release_notes: true + } + : { body: notes }), + // eslint-disable-next-line camelcase -- GitHub release API requires this wire field. + make_latest: 'true' + }), + { mode: 0o600 } + ) + try { + const response = api(exec, ['--method', 'POST', releasePath, '--input', request]) + requireValue( + response.status === 201 && + Number.isSafeInteger(response.data.id) && + response.data.id > 0 && + response.data.tag_name === state.version && + response.data.target_commitish === state.commit, + 'Release creation unconfirmed' + ) + state.releaseId = response.data.id + state.release = 'created' + writeState(path, state, identity) + } finally { + unlinkSync(request) + } +} +export const markRegistry = (path, identity, outcome) => { + const state = readState(path, identity) + requireValue( + state.tag === 'created' && state.release === 'created', + 'Publication requires owned artifacts' + ) + if (outcome === 'attempt') { + requireValue(state.registry === 'not-attempted', 'Publication already attempted') + state.registry = 'unknown' + } else { + requireValue( + outcome === 'success' && state.registry === 'unknown', + 'Invalid publication transition' + ) + state.registry = 'published' + state.canonical = true + } + writeState(path, state, identity) +} +export const cleanup = ( + path, + identity, + canonicalSuccess = false, + exec = transport, + warn = console.warn +) => { + if (canonicalSuccess) return + const caller = + typeof identity?.run === 'string' && + typeof identity?.attempt === 'string' && + /^\d+$/.test(identity.run) && + /^\d+$/.test(identity.attempt) + ? `run ${identity.run} attempt ${identity.attempt}` + : 'invalid caller identity' + const artifact = [caller] + const report = (message) => warn(`::warning::${artifact.join('; ')}: ${message}`) + try { + const state = readState(path, identity) + // Only validated state may contribute artifact identifiers to diagnostics. + if (state.version) artifact.push(`version ${state.version}`) + if (state.release === 'created') artifact.push(`release ID ${state.releaseId}`) + if (state.tagOid) artifact.push(`tag object ${state.tagOid}`) + if (state.commit) artifact.push(`commit ${state.commit}`) + if ( + state.canonical || + state.registry !== 'not-attempted' || + state.tag !== 'created' || + state.release === 'unknown' + ) { + report( + 'Retaining artifacts: publication or ownership is unconfirmed; manual reconciliation required' + ) + return + } + const tag = remoteTag(exec, state.version) + if (tag && (tag.oid !== state.tagOid || tag.commit !== state.commit)) { + report('Retaining replaced tag') + return + } + if (state.release === 'created') { + const stored = api(exec, [`${releasePath}/${state.releaseId}`]) + if (stored.status !== 404) { + requireValue( + tag && + stored.data.id === state.releaseId && + stored.data.tag_name === state.version && + stored.data.target_commitish === state.commit, + 'Release identity changed; retaining artifacts' + ) + } + // Even a missing stored ID must not allow deletion of a replacement's tag. + const named = api(exec, [`${releasePath}/tags/${state.version}`]) + if (named.status !== 404 && named.data.id !== state.releaseId) { + report('Retaining replacement release and tag') + return + } + if (stored.status !== 404) { + requireValue( + named.status === 200 && named.data.id === state.releaseId, + 'Release lookup inconsistent' + ) + requireValue( + api(exec, ['--method', 'DELETE', `${releasePath}/${state.releaseId}`]) + .status === 204, + 'Release deletion unconfirmed' + ) + } + } else { + requireValue( + api(exec, [`${releasePath}/tags/${state.version}`]).status === 404, + 'Unowned release exists; retaining tag' + ) + } + if (tag) + run(exec, 'git', [ + 'push', + `--force-with-lease=refs/tags/${state.version}:${state.tagOid}`, + 'origin', + `:refs/tags/${state.version}` + ]) + } catch (error) { + // Messages are our own identifiers/policy errors, never transport stderr. + report( + `Cleanup retained artifacts: ${error instanceof SyntaxError ? 'malformed state/response' : error.code ? 'state unavailable' : error.message}` + ) + } +} + +const cli = () => { + const env = process.env + const identity = { run: env.GITHUB_RUN_ID, attempt: env.GITHUB_RUN_ATTEMPT } + const path = env.RELEASE_STATE + const output = (key, value) => { + if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`) + } + switch (process.argv[2]) { + case 'prepare': { + const result = selectBaseline( + { + event: env.EVENT_NAME, + eventSha: env.EVENT_SHA, + ref: env.EVENT_REF, + merged: env.PR_FOUND === 'true', + skip: env.HAS_SKIP_LABEL === 'true' || env.INPUT_VERSION === 'skip' + }, + transport, + readPolicy() + ) + output('ready', result.ready) + output('checkout_sha', result.checkoutSha || '') + output('outcome', result.outcome) + const message = `${result.outcome}: event ${env.EVENT_SHA}; selected ${result.checkoutSha || 'none'}; version ${result.version || 'none'}; ${result.message || ''}` + console.log(message) + if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, message + '\n') + break + } + case 'begin': { + const result = initialize(path, identity, env.PREPARED_SHA) + output('ready', result.ready) + if (!result.ready) { + console.log(result.message) + if (env.GITHUB_STEP_SUMMARY) + appendFileSync(env.GITHUB_STEP_SUMMARY, result.message + '\n') + } + break + } + case 'validate': { + const state = readState(path, identity) + state.version = validateDelta(state, transport, undefined, readPolicy()) + writeState(path, state, identity) + break + } + case 'push': { + const state = readState(path, identity) + state.commit = oidValue(run(transport, 'git', ['rev-parse', 'HEAD'])) + requireValue( + state.scheme === 'calver' + ? state.commit === state.base + : run(transport, 'git', ['rev-list', '--parents', '-n', '1', 'HEAD']).trim() === + `${state.commit} ${state.base}`, + 'Version commit has untested parent' + ) + state.tagOid = oidValue( + run(transport, 'git', ['rev-parse', `refs/tags/${state.version}`]) + ) + requireValue( + oidValue(run(transport, 'git', ['rev-parse', `refs/tags/${state.version}^{}`])) === + state.commit, + 'Invalid annotated tag target' + ) + requireValue( + run(transport, 'git', ['cat-file', '-t', state.tagOid]).trim() === 'tag', + 'Annotated tag required' + ) + writeState(path, state, identity) + pushArtifacts(path, identity) + break + } + case 'release': { + let notes = `Changes in this Release\n${env.CUSTOM_MESSAGE || env.PR_TITLE || ''}` + if (['push', 'pull_request'].includes(env.EVENT_NAME) && env.PR_URL) + notes += `\n\nFor more details, see the [Pull Request](${env.PR_URL})` + createRelease(path, identity, notes) + break + } + case 'registry-attempt': + markRegistry(path, identity, 'attempt') + break + case 'registry-success': + markRegistry(path, identity, 'success') + break + case 'cleanup': + cleanup(path, identity, env.CANONICAL_SUCCESS === 'success') + break + default: + throw new Error('Unsupported release operation') + } +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + cli() + } catch (error) { + console.error( + `Release operation stopped: ${error instanceof SyntaxError ? 'malformed response' : error.code ? 'state operation failed' : error.message}` + ) + process.exitCode = 1 + } +} diff --git a/.github/scripts/release-publication.test.mjs b/.github/scripts/release-publication.test.mjs new file mode 100644 index 0000000..7f41321 --- /dev/null +++ b/.github/scripts/release-publication.test.mjs @@ -0,0 +1,1181 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import process from 'node:process' +import test from 'node:test' +import { fileURLToPath, URL } from 'node:url' +import { runInNewContext } from 'node:vm' + +const root = fileURLToPath(new URL('../../', import.meta.url)) +const policy = JSON.parse(readFileSync(join(root, '.github/release-policy.json'), 'utf8')) +const workflow = () => + policy.event === 'calver' + ? '' + : readFileSync(join(root, '.github/workflows/npm-publish.yml'), 'utf8') +const sha = 'a'.repeat(40) +const oid = 'b'.repeat(40) +const identity = { run: '17', attempt: '2' } +const initial = { + ...identity, + base: sha, + version: 'v1.2.3', + commit: sha, + tagOid: oid, + tag: 'created', + release: 'created', + releaseId: 42, + registry: 'not-attempted', + canonical: false +} + +const cleanupRun = () => { + if (policy.event === 'calver') return 'node .github/scripts/release-publication.mjs cleanup' + const text = workflow() + const marker = ' - name: Cleanup on failure\n' + assert.equal(text.split(marker).length, 2, 'Unique cleanup step required') + const step = text.split(marker)[1].split('\n - ')[0] + assert.ok(step.includes(' run: |\n'), 'Cleanup run body required') + return step + .split(' run: |\n')[1] + .split('\n') + .map((line) => { + if (!line.trim()) return '' + assert.ok(line.startsWith(' '), 'Cleanup indentation boundary') + return line.slice(18) + }) + .join('\n') +} + +const shellFixture = (t, state, expectations = null, command = cleanupRun(), extraEnv = {}) => { + const dir = mkdtempSync(join(tmpdir(), 'release-cleanup-')) + t.after(() => rmSync(dir, { recursive: true, force: true })) + mkdirSync(join(dir, '.github/scripts'), { recursive: true }) + const helper = join(root, '.github/scripts/release-publication.mjs') + if (existsSync(helper)) + copyFileSync(helper, join(dir, '.github/scripts/release-publication.mjs')) + const statePath = join(dir, 'state.json') + if (state) writeFileSync(statePath, JSON.stringify(state)) + const log = join(dir, 'calls.jsonl') + const progress = join(dir, 'progress.json') + writeFileSync(progress, '0') + for (const command of ['git', 'gh', 'pnpm', 'curl']) { + writeFileSync( + join(dir, command), + `#!${process.execPath} +const fs = require('node:fs'); const args = process.argv.slice(2); +fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify([${JSON.stringify(command)}, ...args]) + '\\n'); +const expectations = ${JSON.stringify(expectations)}; +if (expectations) { + const index = JSON.parse(fs.readFileSync(${JSON.stringify(progress)}, 'utf8')); + const expected = expectations[index]; + const actual = [${JSON.stringify(command)}, ...args]; + if (!expected || JSON.stringify(expected[0]) !== JSON.stringify(actual)) process.exit(97); + fs.writeFileSync(${JSON.stringify(progress)}, JSON.stringify(index + 1)); + process.stdout.write(expected[1]?.stdout || ''); + process.stderr.write(expected[1]?.stderr || ''); + process.exit(expected[1]?.code || 0); +} +const allowed = [['gh','release','delete','v1.2.3','--yes'], ['git','tag','-d','v1.2.3'], ['git','push','--delete','origin','v1.2.3']]; +if (!allowed.some(call => JSON.stringify(call) === JSON.stringify([${JSON.stringify(command)}, ...args]))) process.exit(97); + +`, + { mode: 0o755 } + ) + } + const result = spawnSync('/bin/bash', ['-e', '-c', command], { + cwd: dir, + encoding: 'utf8', + timeout: 10000, + env: { + PATH: `${dir}:${dirname(process.execPath)}:/usr/bin:/bin`, + HOME: dir, + RELEASE_VERSION: 'v1.2.3', + RELEASE_STATE: statePath, + GITHUB_RUN_ID: identity.run, + GITHUB_RUN_ATTEMPT: identity.attempt, + CANONICAL_SUCCESS: state?.canonical ? 'success' : '', + ...extraEnv + } + }) + assert.equal(result.error, undefined) + const calls = existsSync(log) + ? readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse) + : [] + assert.equal(result.status, 0, result.stderr) + assert.ok(!`${result.stdout}${result.stderr}`.includes('private transport diagnostic')) + if (expectations) { + assert.deepEqual( + calls, + expectations.map((entry) => entry[0]) + ) + assert.equal(JSON.parse(readFileSync(progress, 'utf8')), expectations.length) + } + return calls +} + +test('never deletes pre-existing release artifacts', (t) => { + assert.deepEqual(shellFixture(t), []) +}) +test('keeps published metadata after canonical registry success', (t) => { + assert.deepEqual(shellFixture(t, { ...initial, canonical: true, registry: 'published' }), []) +}) + +const fixturePolicy = { + manifests: [ + 'package.json', + 'tombstones/svelte-diff/package.json', + 'tombstones/svelte-diff-match-patch/package.json' + ], + lockfile: null, + readme: 'managed' +} +const load = async () => { + const helper = await import('./release-publication.mjs') + return { + ...helper, + validateMetadata: (before, after, paths, config = fixturePolicy) => + helper.validateMetadata(before, after, paths, config), + selectBaseline: (input, exec, config = fixturePolicy) => + helper.selectBaseline(input, exec, config), + validateDelta: (state, exec, read, config = fixturePolicy) => + helper.validateDelta(state, exec, read, config) + } +} +const fixtureState = (t, changes = {}) => { + const dir = mkdtempSync(join(tmpdir(), 'release-state-')) + t.after(() => rmSync(dir, { recursive: true, force: true })) + const path = join(dir, 'state.json') + writeFileSync(path, JSON.stringify({ ...initial, ...changes })) + return path +} +// Exact arrays, in order. An unexpected command never falls through to a real executable. +const scripted = (expectations) => { + const calls = [], + unexpected = [] + let index = 0 + const exec = (command, args) => { + const call = [command, ...args] + calls.push(call) + const expectation = expectations[index++] + if ( + !expectation || + !expectation[0].every((value, i) => value === call[i]) || + expectation[0].length !== call.length + ) { + unexpected.push(call) + throw new Error('Unexpected mock command') + } + return { code: 0, stdout: '', ...expectation[1] } + } + return { + exec, + calls, + done: () => { + assert.deepEqual(unexpected, []) + assert.equal(index, expectations.length) + } + } +} +const response = (status, data = null) => ({ + code: status === 404 ? 1 : 0, + stdout: `HTTP/2.0 ${status} Fixture\r\nContent-Type: application/json\r\n\r\n${data === null ? '' : JSON.stringify(data)}` +}) +const releaseApi = (suffix) => ['gh', 'api', '--include', `repos/{owner}/{repo}/releases/${suffix}`] +const tagLookup = ['git', 'ls-remote', 'origin', 'refs/tags/v1.2.3', 'refs/tags/v1.2.3^{}'] +const tagResponse = (object = oid, commit = sha) => ({ + stdout: `${object}\trefs/tags/v1.2.3\n${commit}\trefs/tags/v1.2.3^{}\n` +}) +const releaseData = (id = 42, commit = sha) => ({ + id, + // eslint-disable-next-line camelcase -- Fixture preserves required GitHub API wire fields. + tag_name: 'v1.2.3', + // eslint-disable-next-line camelcase -- Fixture preserves required GitHub API wire fields. + target_commitish: commit +}) +const lease = [ + 'git', + 'push', + `--force-with-lease=refs/tags/v1.2.3:${oid}`, + 'origin', + ':refs/tags/v1.2.3' +] +const owned = () => [ + [tagLookup, tagResponse()], + [releaseApi('42'), response(200, releaseData())], + [releaseApi('tags/v1.2.3'), response(200, releaseData())], + [ + ['gh', 'api', '--include', '--method', 'DELETE', 'repos/{owner}/{repo}/releases/42'], + response(204) + ], + [lease, {}] +] + +test('ownership state atomic replacement validates run and malformed fields', async (t) => { + const { readState, writeState } = await load() + const path = fixtureState(t) + writeState(path, initial, identity) + assert.deepEqual(readState(path, identity), initial) + assert.throws(() => readState(path, { run: '18', attempt: '2' })) + assert.throws(() => readState(path, { run: '17', attempt: '3' })) + for (const changes of [ + { tagOid: 'bad' }, + { releaseId: '42' }, + { releaseId: -1 }, + { tag: 'yes' }, + { canonical: true }, + { registry: 'absent' }, + { base: 'bad' } + ]) { + assert.throws(() => writeState(path, { ...initial, ...changes }, identity)) + assert.deepEqual(readState(path, identity), initial) + } +}) + +test('cleanup deletes only confirmed numeric release ID and leased annotated tag', async (t) => { + const { cleanup } = await load() + const mock = scripted(owned()) + cleanup(fixtureState(t), identity, false, mock.exec, (message) => assert.fail(message)) + mock.done() + assert.ok(!mock.calls.some((call) => call.includes('--delete') || call.includes('release'))) +}) + +test('cleanup twice is idempotent when owned artifacts are gone', async (t) => { + const { cleanup } = await load() + const path = fixtureState(t) + const mock = scripted([ + ...owned(), + [tagLookup, {}], + [releaseApi('42'), response(404)], + [releaseApi('tags/v1.2.3'), response(404)] + ]) + cleanup(path, identity, false, mock.exec, (message) => assert.fail(message)) + cleanup(path, identity, false, mock.exec, (message) => assert.fail(message)) + mock.done() +}) + +for (const [name, changes] of [ + ['current-run mismatch', { run: '99' }], + ['attempt mismatch', { attempt: '4' }], + ['malformed state', { releaseId: '42' }], + ['unknown registry attempt', { registry: 'unknown' }], + ['canonical published', { registry: 'published', canonical: true }], + ['unknown tag creation', { tag: 'unknown', release: 'unknown' }], + ['unknown release creation', { release: 'unknown', releaseId: null }], + [ + 'only local artifacts exist', + { tag: 'not-attempted', release: 'not-attempted', releaseId: null } + ] +]) { + test(`cleanup retention: ${name}`, async (t) => { + const { cleanup } = await load() + const mock = scripted([]), + warnings = [] + cleanup(fixtureState(t, changes), identity, false, mock.exec, (message) => + warnings.push(message) + ) + mock.done() + assert.equal(warnings.length, 1) + assert.ok(warnings[0].includes('run 17 attempt 2')) + if (['current-run mismatch', 'attempt mismatch', 'malformed state'].includes(name)) { + assert.ok(!warnings[0].includes('version v1.2.3')) + assert.ok(!warnings[0].includes('; release ID ')) + assert.ok(!warnings[0].includes(oid)) + } else { + assert.ok(warnings[0].includes('version v1.2.3')) + assert.ok(warnings[0].includes(`tag object ${oid}`)) + assert.ok(warnings[0].includes(`commit ${sha}`)) + if ((changes.release || initial.release) === 'created') + assert.ok(warnings[0].includes('release ID 42')) + else assert.ok(!warnings[0].includes('; release ID ')) + } + }) +} + +test('cleanup missing and invalid JSON state make no transport calls', async (t) => { + const { cleanup } = await load() + const path = fixtureState(t) + const mock = scripted([]), + warnings = [] + const warn = (message) => warnings.push(message) + writeFileSync(path, '{private state content') + cleanup(path, identity, false, mock.exec, warn) + rmSync(path) + cleanup(path, identity, false, mock.exec, warn) + mock.done() + assert.deepEqual(warnings, [ + '::warning::run 17 attempt 2: Cleanup retained artifacts: malformed state/response', + '::warning::run 17 attempt 2: Cleanup retained artifacts: state unavailable' + ]) +}) + +test('cleanup warnings exclude malformed, foreign and invalid caller identifiers', async (t) => { + const { cleanup } = await load() + const privateValue = 'private state content\n::error::untrusted' + const mock = scripted([]), + warnings = [] + for (const changes of [ + { run: '99', version: privateValue }, + { attempt: '4', commit: privateValue }, + { releaseId: privateValue }, + { tagOid: privateValue } + ]) { + cleanup(fixtureState(t, changes), identity, false, mock.exec, (message) => + warnings.push(message) + ) + } + cleanup(fixtureState(t), { run: privateValue, attempt: '2' }, false, mock.exec, (message) => + warnings.push(message) + ) + mock.done() + assert.equal(warnings.length, 5) + for (const warning of warnings) { + assert.ok(!warning.includes('private state content')) + assert.ok(!warning.includes('::error::')) + assert.ok(!warning.includes('version')) + assert.ok(!warning.includes('; release ID ')) + assert.ok(!warning.includes(sha)) + assert.ok(!warning.includes(oid)) + } + assert.ok(warnings.slice(0, 4).every((warning) => warning.includes('run 17 attempt 2'))) + assert.ok(warnings[4].includes('invalid caller identity')) +}) + +test('cleanup independent canonical success guard survives atomic state-write failure', async (t) => { + const { cleanup, markRegistry } = await load() + const path = fixtureState(t, { registry: 'unknown' }) + mkdirSync(`${path}.${process.pid}.tmp`) + assert.throws(() => markRegistry(path, identity, 'success')) + assert.equal(JSON.parse(readFileSync(path)).canonical, false) + const mock = scripted([]) + cleanup(path, identity, true, mock.exec, (message) => assert.fail(message)) + mock.done() + assert.deepEqual(shellFixture(t, { ...initial, canonical: true, registry: 'unknown' }), []) +}) + +test('cleanup retains run B replacement tag object or commit', async (t) => { + const { cleanup } = await load() + for (const output of [ + tagResponse('c'.repeat(40)), + tagResponse(oid, 'c'.repeat(40)), + { stdout: 'ambiguous' } + ]) { + const mock = scripted([[tagLookup, output]]) + cleanup(fixtureState(t), identity, false, mock.exec, () => undefined) + mock.done() + } +}) + +test('cleanup stored release ID 404 retains a different ID on same tag and OID', async (t) => { + const { cleanup } = await load() + const mock = scripted([ + [tagLookup, tagResponse()], + [releaseApi('42'), response(404)], + [releaseApi('tags/v1.2.3'), response(200, releaseData(99))] + ]) + cleanup(fixtureState(t), identity, false, mock.exec, () => undefined) + mock.done() +}) + +test('cleanup retains changed release ID or target and API errors', async (t) => { + const { cleanup } = await load() + for (const result of [ + response(200, releaseData(99)), + response(200, releaseData(42, 'c'.repeat(40))), + response(403), + { code: 1, stdout: '' } + ]) { + const mock = scripted([ + [tagLookup, tagResponse()], + [releaseApi('42'), result] + ]) + cleanup(fixtureState(t), identity, false, mock.exec, () => undefined) + mock.done() + } +}) + +test('cleanup deletion lease race preserves replacement tag and surfaces warning', async (t) => { + const { cleanup } = await load() + const expectations = owned() + expectations.at(-1)[1] = { code: 1, stdout: '' } + const mock = scripted(expectations), + warnings = [] + cleanup(fixtureState(t), identity, false, mock.exec, (message) => warnings.push(message)) + mock.done() + assert.equal(warnings.length, 1) + assert.match(warnings[0], /reconcile/) + for (const identifier of [ + 'run 17 attempt 2', + 'version v1.2.3', + 'release ID 42', + `tag object ${oid}`, + `commit ${sha}` + ]) + assert.ok(warnings[0].includes(identifier)) +}) + +test('cleanup owns tag only when no release was ever attempted or found', async (t) => { + const { cleanup } = await load() + for (const found of [false, true]) { + const expectations = [ + [tagLookup, tagResponse()], + [releaseApi('tags/v1.2.3'), found ? response(200, releaseData(99)) : response(404)] + ] + if (!found) expectations.push([lease, {}]) + const mock = scripted(expectations) + cleanup( + fixtureState(t, { release: 'not-attempted', releaseId: null }), + identity, + false, + mock.exec, + () => undefined + ) + mock.done() + } +}) + +const tagPush = ['git', 'push', '--porcelain', 'origin', 'refs/tags/v1.2.3:refs/tags/v1.2.3'] +const newTag = { stdout: 'To fixture\n*\trefs/tags/v1.2.3:refs/tags/v1.2.3\t[new tag]\nDone\n' } +const prePush = () => [ + [tagLookup, {}], + [releaseApi('tags/v1.2.3'), response(404)] +] +const mainPush = ['git', 'push', 'origin', 'HEAD:refs/heads/main'] + +test('creation pushes explicit fast-forward main and one confirmed annotated tag', async (t) => { + const { pushArtifacts, readState } = await load() + const path = fixtureState(t, { + tag: 'not-attempted', + release: 'not-attempted', + releaseId: null + }) + const mock = scripted([ + ...prePush(), + [mainPush, {}], + [tagPush, newTag], + [tagLookup, tagResponse()] + ]) + pushArtifacts(path, identity, mock.exec) + mock.done() + assert.equal(readState(path, identity).tag, 'created') +}) + +for (const [name, result] of [ + ['main push rejection', { code: 1 }], + ['failure after main push', { code: 1 }], + ['up-to-date tag response', { stdout: '=\trefs/tags/v1.2.3:refs/tags/v1.2.3\t[up to date]\n' }], + ['ambiguous tag response', { stdout: 'success\n' }] +]) { + test(`creation retention: ${name}`, async (t) => { + const { pushArtifacts, readState, cleanup } = await load() + const path = fixtureState(t, { + tag: 'not-attempted', + release: 'not-attempted', + releaseId: null + }) + const failureAtMain = name === 'main push rejection' + const mock = scripted([ + ...prePush(), + [mainPush, failureAtMain ? result : {}], + ...(failureAtMain ? [] : [[tagPush, result]]) + ]) + assert.throws(() => pushArtifacts(path, identity, mock.exec)) + mock.done() + assert.equal(readState(path, identity).tag, failureAtMain ? 'not-attempted' : 'unknown') + const noCalls = scripted([]) + cleanup(path, identity, false, noCalls.exec, () => undefined) + noCalls.done() + }) +} + +test('creation version collision preserves pre-existing tag and release', async (t) => { + const { pushArtifacts } = await load() + for (const expectations of [ + [[tagLookup, tagResponse()]], + [ + [tagLookup, {}], + [releaseApi('tags/v1.2.3'), response(200, releaseData())] + ] + ]) { + const mock = scripted(expectations) + assert.throws(() => + pushArtifacts( + fixtureState(t, { + tag: 'not-attempted', + release: 'not-attempted', + releaseId: null + }), + identity, + mock.exec + ) + ) + mock.done() + } +}) + +test('creation API records numeric identity from creation response with structured notes file', async (t) => { + const { createRelease, readState } = await load() + const path = fixtureState(t, { release: 'not-attempted', releaseId: null }) + const mock = scripted([ + [releaseApi('tags/v1.2.3'), response(404)], + [ + [ + 'gh', + 'api', + '--include', + '--method', + 'POST', + 'repos/{owner}/{repo}/releases', + '--input', + `${path}.request.json` + ], + response(201, releaseData()) + ] + ]) + const exec = (command, args) => { + if (args.includes('POST')) { + const request = JSON.parse(readFileSync(`${path}.request.json`, 'utf8')) + assert.equal(request.body, 'Notes "quotes"\n$(literal) `literal`') + assert.equal(request.target_commitish, sha) + assert.equal(readState(path, identity).release, 'unknown') + } + return mock.exec(command, args) + } + createRelease(path, identity, 'Notes "quotes"\n$(literal) `literal`', exec) + mock.done() + assert.equal(readState(path, identity).releaseId, 42) + assert.equal(readState(path, identity).release, 'created') + assert.equal(existsSync(`${path}.request.json`), false) +}) + +test('creation ambiguous release response retains unknown ownership without later lookup inference', async (t) => { + const { createRelease, readState, cleanup } = await load() + for (const result of [ + // eslint-disable-next-line camelcase -- Fixture preserves required GitHub API wire fields. + response(201, { tag_name: 'v1.2.3' }), + { code: 1, stdout: '' }, + response(200, releaseData()), + response(201, releaseData(42, 'c'.repeat(40))) + ]) { + const path = fixtureState(t, { release: 'not-attempted', releaseId: null }) + const mock = scripted([ + [releaseApi('tags/v1.2.3'), response(404)], + [ + [ + 'gh', + 'api', + '--include', + '--method', + 'POST', + 'repos/{owner}/{repo}/releases', + '--input', + `${path}.request.json` + ], + result + ] + ]) + assert.throws(() => createRelease(path, identity, 'notes', mock.exec)) + mock.done() + assert.equal(readState(path, identity).release, 'unknown') + const noCalls = scripted([]) + cleanup(path, identity, false, noCalls.exec, () => undefined) + noCalls.done() + } +}) + +test('publish outcome attempt and success are irreversible cleanup barriers', async (t) => { + const { markRegistry, readState, cleanup } = await load() + const path = fixtureState(t) + markRegistry(path, identity, 'attempt') + assert.equal(readState(path, identity).registry, 'unknown') + const mock = scripted([]) + cleanup(path, identity, false, mock.exec, () => undefined) + markRegistry(path, identity, 'success') + assert.equal(readState(path, identity).canonical, true) + cleanup(path, identity, false, mock.exec, () => undefined) + assert.throws(() => markRegistry(path, identity, 'attempt')) + mock.done() +}) + +const fetchMain = (current) => [ + [['git', 'fetch', '--no-tags', 'origin', 'refs/heads/main'], {}], + [['git', 'rev-parse', 'FETCH_HEAD'], { stdout: `${current}\n` }] +] +const baselineInput = { + event: 'push', + eventSha: sha, + ref: 'refs/heads/main', + merged: true, + skip: false +} + +test('baseline equal SHA selects immutable event checkout', async () => { + const { selectBaseline } = await load() + const mock = scripted([ + ...fetchMain(sha), + [['git', 'show', `${sha}:package.json`], { stdout: '{"version":"1.2.3"}' }] + ]) + assert.deepEqual(selectBaseline(baselineInput, mock.exec), { + ready: true, + outcome: 'ready', + checkoutSha: sha, + version: '1.2.3' + }) + mock.done() +}) + +test('baseline provenance skip labels, no merged PR, manual skip/nonmain make zero calls', async () => { + const { selectBaseline } = await load() + const mock = scripted([]) + for (const changes of [ + { merged: false }, + { skip: true }, + { event: 'workflow_dispatch', skip: true }, + { event: 'workflow_dispatch', ref: 'refs/heads/feature' } + ]) { + const result = selectBaseline({ ...baselineInput, ...changes }, mock.exec) + assert.equal(result.ready, false) + assert.match(result.message, /fresh qualifying main event or manual retry/) + } + mock.done() +}) + +test('baseline manual main selects current main before gates', async () => { + const { selectBaseline } = await load() + const current = 'c'.repeat(40) + const mock = scripted([ + ...fetchMain(current), + [['git', 'show', `${current}:package.json`], { stdout: '{"version":"1.2.4"}' }] + ]) + assert.equal( + selectBaseline({ ...baselineInput, event: 'workflow_dispatch' }, mock.exec).checkoutSha, + current + ) + mock.done() +}) + +test('baseline ancestry failure is stale; malformed remote response is conservative', async () => { + const { selectBaseline } = await load() + const current = 'c'.repeat(40) + const mock = scripted([ + ...fetchMain(current), + [['git', 'merge-base', '--is-ancestor', sha, current], { code: 1 }] + ]) + assert.equal(selectBaseline(baselineInput, mock.exec).outcome, 'stale') + mock.done() + for (const output of ['', 'main', `${sha}\n${current}`]) { + const bad = scripted(fetchMain(output)) + assert.throws(() => selectBaseline(baselineInput, bad.exec)) + bad.done() + } +}) + +const metadata = (version) => ({ + 'package.json': JSON.stringify({ + name: '@humanspeak/svelte-diff', + version, + dependencies: { example: '^1.0.0' }, + nested: { array: [1, { a: 2, b: 3 }] } + }), + 'tombstones/svelte-diff/package.json': JSON.stringify({ + version, + dependencies: { '@humanspeak/svelte-diff': `^${version}`, other: '1' } + }), + 'tombstones/svelte-diff-match-patch/package.json': JSON.stringify({ + version, + dependencies: { '@humanspeak/svelte-diff': `^${version}` } + }), + 'README.md': `# README\n\nversion ${version}\n\n` +}) +const metadataPaths = Object.keys(metadata('1.2.3')) + +test('baseline metadata structural JSON comparison preserves nested keys, arrays and dependencies', async () => { + const { validateMetadata } = await load() + const before = metadata('1.2.3'), + after = metadata('1.2.4') + const pkg = JSON.parse(after['package.json']) + after['package.json'] = JSON.stringify({ + nested: { array: [1, { b: 3, a: 2 }] }, + dependencies: pkg.dependencies, + version: pkg.version, + name: pkg.name + }) + assert.equal( + validateMetadata( + (path) => before[path], + (path) => after[path], + metadataPaths + ), + 'v1.2.4' + ) + for (const mutate of [ + (pkg) => (pkg.dependencies.example = '^2.0.0'), + (pkg) => pkg.nested.array.reverse(), + (pkg) => (pkg.nested.array[1].a = 9), + (pkg) => (pkg.newKey = true) + ]) { + const changed = JSON.parse(after['package.json']) + mutate(changed) + assert.throws(() => + validateMetadata( + (path) => before[path], + (path) => (path === 'package.json' ? JSON.stringify(changed) : after[path]), + metadataPaths + ) + ) + } +}) + +test('baseline metadata rejects unrelated README, duplicate/missing block, source and invalid version/range', async () => { + const { validateMetadata } = await load() + const before = metadata('1.2.3') + for (const changes of [ + { 'README.md': 'unrelated\n' + metadata('1.2.4')['README.md'] }, + { 'README.md': metadata('1.2.4')['README.md'] + '' }, + { 'README.md': '# missing' }, + { 'package.json': metadata('9.9.9')['package.json'] }, + { + 'tombstones/svelte-diff/package.json': + metadata('1.2.3')['tombstones/svelte-diff/package.json'] + }, + { + 'tombstones/svelte-diff/package.json': JSON.stringify({ + version: '1.2.4', + dependencies: { '@humanspeak/svelte-diff': '*', other: '1' } + }) + } + ]) { + const after = { ...metadata('1.2.4'), ...changes } + assert.throws(() => + validateMetadata( + (path) => before[path], + (path) => after[path], + metadataPaths + ) + ) + } + assert.throws(() => + validateMetadata( + (path) => before[path], + (path) => metadata('1.2.4')[path], + [...metadataPaths, 'src/lib/index.ts'] + ) + ) +}) + +test('baseline version-only advancement validates full delta before tests; source/dependency advancement stays stale', async () => { + const { selectBaseline } = await load() + const current = 'c'.repeat(40) + for (const scenario of ['metadata', 'source', 'dependency']) { + const before = metadata('1.2.3'), + after = metadata('1.2.4') + if (scenario === 'dependency') + after['package.json'] = after['package.json'].replace('^1.0.0', '^2.0.0') + const paths = scenario === 'source' ? ['src/lib/index.ts'] : metadataPaths + const expectations = [ + ...fetchMain(current), + [['git', 'merge-base', '--is-ancestor', sha, current], {}], + [ + ['git', 'rev-list', '--reverse', '--first-parent', `${sha}..${current}`], + { stdout: current } + ], + [['git', 'rev-list', '--parents', '-n', '1', current], { stdout: `${current} ${sha}` }], + [['git', 'diff', '--summary', sha, current], {}], + [['git', 'diff', '--name-only', sha, current], { stdout: paths.join('\n') }] + ] + if (scenario !== 'source') { + // Read canonical pair, then each manifest pair; dependency failure stops immediately. + const reads = [ + 'package.json', + ...(scenario === 'dependency' ? ['package.json'] : metadataPaths) + ] + for (const path of reads) { + expectations.push( + [['git', 'show', `${sha}:${path}`], { stdout: before[path] }], + [['git', 'show', `${current}:${path}`], { stdout: after[path] }] + ) + } + } + if (scenario === 'metadata') + expectations.push([ + ['git', 'show', `${current}:package.json`], + { stdout: after['package.json'] } + ]) + const mock = scripted(expectations) + const result = selectBaseline(baselineInput, mock.exec) + assert.equal(result.ready, scenario === 'metadata') + if (!result.ready) assert.equal(result.outcome, 'stale') + mock.done() + } +}) + +test('main advanced after testing causes no mutation or ownership state', async (t) => { + const { initialize } = await load() + const path = fixtureState(t) + rmSync(path) + const mock = scripted(fetchMain('c'.repeat(40))) + assert.equal(initialize(path, identity, sha, mock.exec).ready, false) + assert.equal(existsSync(path), false) + mock.done() +}) + +test('ownership initialization branches from exact tested parent and records no creations', async (t) => { + const { initialize, readState } = await load() + const path = fixtureState(t) + rmSync(path) + const mock = scripted([ + ...fetchMain(sha), + [['git', 'rev-parse', 'HEAD'], { stdout: sha }], + [['git', 'status', '--porcelain'], {}], + [['git', 'switch', '-c', 'release-17-2', sha], {}] + ]) + assert.equal(initialize(path, identity, sha, mock.exec).ready, true) + assert.equal(readState(path, identity).base, sha) + assert.equal(readState(path, identity).tag, 'not-attempted') + mock.done() +}) + +test('whole working-tree delta is validated before staging and commit', async () => { + const { validateDelta } = await load() + const before = metadata('1.2.3'), + after = metadata('1.2.4') + const expectations = [ + [['git', 'rev-parse', 'HEAD'], { stdout: sha }], + [['git', 'ls-files', '--others', '--exclude-standard'], {}], + [['git', 'diff', '--summary', 'HEAD'], {}], + [['git', 'diff', '--name-only', 'HEAD'], { stdout: metadataPaths.join('\n') }] + ] + for (const path of ['package.json', ...metadataPaths]) + expectations.push([['git', 'show', `${sha}:${path}`], { stdout: before[path] }]) + const mock = scripted(expectations) + assert.equal( + validateDelta(initial, mock.exec, (path) => after[path]), + 'v1.2.4' + ) + mock.done() + const untracked = scripted([ + [['git', 'rev-parse', 'HEAD'], { stdout: sha }], + [['git', 'ls-files', '--others', '--exclude-standard'], { stdout: 'unknown.file' }] + ]) + assert.throws(() => validateDelta(initial, untracked.exec)) + untracked.done() +}) + +test( + 'workflow concurrency and tested checkout cover every code job with sole read-only bootstrap exception', + { skip: policy.reference !== true }, + () => { + const text = workflow() + assert.match( + text, + /concurrency:\n {4}group: repository-release\n {4}cancel-in-progress: false\n\njobs:/ + ) + assert.equal(text.match(/^concurrency:/gm).length, 1) + assert.ok(!text.includes('queue:')) + const jobs = text.split(/^ {4}(?=[a-z][\w-]*:\n)/m).slice(1) + const checkoutJobs = [] + for (const job of jobs) { + const name = job.split(':')[0] + const checkouts = job.split(/uses: actions\/checkout@[^\n]+\n/).slice(1) + for (const checkout of checkouts) { + const config = checkout.split(/\n {12}- /)[0] + if (name === 'prepare') { + assert.match(config, /ref: \$\{\{ github.sha \}\}/) + assert.match(config, /persist-credentials: false/) + assert.match(job, /Sole bootstrap exception: read-only/) + assert.ok(!/pnpm|\.mjs (begin|push|release|cleanup)/.test(job)) + } else { + assert.match( + config, + /ref: \$\{\{ needs.prepare.outputs.checkout_sha \}\}/, + name + ) + assert.match(job, /needs: \[[^\n]*prepare[^\n]*\]/, name) + assert.match(job, /needs.prepare.outputs.ready == 'true'/, name) + } + checkoutJobs.push(name) + } + } + assert.deepEqual(checkoutJobs, [ + 'prepare', + 'debug-check', + 'build', + 'playwright-tests', + 'publish-github-packages' + ]) + const build = jobs.find((job) => job.startsWith('build:')) + assert.match(build, /node-version: \[22, 24\]/) + assert.equal(text.match(/name: Check library types/g).length, 1) + assert.match(build, /- name: Check library types\n {14}run: pnpm run check/) + assert.match( + build, + /node --test \.github\/scripts\/refresh-release-readme.test.mjs \.github\/scripts\/release-publication.test.mjs\n {18}pnpm build\n {18}pnpm test/ + ) + const publish = jobs.find((job) => job.startsWith('publish-github-packages:')) + for (const gate of [ + 'prepare', + 'debug-check', + 'build', + 'playwright-tests', + 'coverage-report' + ]) + assert.match(publish, new RegExp(`needs: \\[[^\\n]*${gate}[^\\n]*\\]`)) + assert.match(publish, /needs.build.result == 'success'/) + assert.match(publish, /needs.debug-check.result == 'success'/) + assert.match(publish, /needs.playwright-tests.result == 'success'/) + assert.match(publish, /needs.coverage-report.result == 'success'/) + assert.ok( + !/git (reset|rebase)|git push --tags|git push --delete|gh release delete/.test(text) + ) + } +) + +test( + 'workflow provenance preserves original merged PR, labels, bump rules and skip policy', + { skip: policy.reference !== true }, + () => { + const text = workflow() + assert.match(text, /commit_sha: context.sha/) + assert.match(text, /prs.find\(\(candidate\) => candidate.merged_at\)/) + for (const label of ['skip-publish', 'major', 'minor']) + assert.ok(text.includes(`labels.includes('${label}')`)) + assert.match(text, /EVENT_SHA: \$\{\{ github.sha \}\}/) + assert.match(text, /EVENT_REF: \$\{\{ github.ref \}\}/) + assert.match(text, /\[\[ "\$EVENT_NAME" == "push" && "\$PR_FOUND" != "true" \]\]/) + assert.match(text, /elif \[ "\$HAS_MAJOR" = "true" \]; then\n {20}BUMP_TYPE="major"/) + assert.match(text, /elif \[ "\$HAS_MINOR" = "true" \]; then\n {20}BUMP_TYPE="minor"/) + assert.match(text, /BUMP_TYPE="patch"/) + assert.match(text, /INPUT_VERSION: \$\{\{ github.event.inputs.version_bump \}\}/) + } +) + +test( + 'workflow publication barrier is pure canonical step, independently guarded before state write and shims', + { skip: policy.reference !== true }, + () => { + const text = workflow() + assert.match( + text, + /- name: Publish\n {14}id: canonical\n {14}if: steps.ownership.outputs.ready == 'true'\n {14}run: pnpm publish --provenance --access public --no-git-checks\n/ + ) + assert.ok(text.indexOf('.mjs registry-attempt') < text.indexOf('- name: Publish\n')) + assert.ok( + text.indexOf('- name: Record canonical publication') > text.indexOf('- name: Publish\n') + ) + assert.match( + text, + /- name: Record canonical publication\n {14}if: steps.canonical.outcome == 'success'/ + ) + assert.match( + text, + /- name: Cleanup on failure\n {14}if: failure\(\) && steps.ownership.outputs.ready == 'true' && steps.canonical.outcome != 'success'/ + ) + assert.match(text, /CANONICAL_SUCCESS: \$\{\{ steps.canonical.outcome \}\}/) + assert.match(text, /environment: production/) + assert.match(text, /id-token: write/) + assert.match(text, /git_commit_gpgsign: true/) + assert.match(text, /git_tag_gpgsign: true/) + assert.equal(text.match(/bash \.github\/scripts\/refresh-release-readme.sh/g).length, 1) + assert.match( + text, + /pnpm --dir tombstones\/svelte-diff publish --provenance --access public --no-git-checks \|\|/ + ) + assert.ok(text.indexOf('.mjs validate') < text.indexOf('git add package.json README.md')) + assert.ok(text.indexOf('.mjs begin') < text.indexOf('pnpm version "$BUMP_TYPE"')) + } +) + +test('configured cleanup entry point deletes owned numeric ID and leased tag using mock-only PATH', (t) => { + assert.deepEqual( + shellFixture(t, initial, owned()), + owned().map((entry) => entry[0]) + ) +}) + +test('configured cleanup entry point retains replacement release and lease race, never prints transport stderr', (t) => { + const replacement = [ + [tagLookup, tagResponse()], + [releaseApi('42'), response(404)], + [releaseApi('tags/v1.2.3'), response(200, releaseData(99))] + ] + shellFixture(t, initial, replacement) + const race = owned() + race.at(-1)[1] = { code: 1, stderr: 'private transport diagnostic' } + shellFixture(t, initial, race) +}) + +const stepBody = (name, key = 'run') => { + const text = workflow() + const marker = ` - name: ${name}\n` + assert.equal(text.split(marker).length, 2) + const step = text.split(marker)[1].split('\n - ')[0] + const body = + step.split(` ${key}: |\n`)[1] || step.split(` ${key}: |\n`)[1] + assert.ok(body, `Missing ${key} body: ${name}`) + const width = key === 'script' ? 22 : 18 + return body + .split('\n') + .map((line) => { + if (!line.trim()) return '' + assert.ok(line.startsWith(' '.repeat(width))) + return line.slice(width) + }) + .join('\n') +} + +test( + 'provenance actual lookup uses original trigger SHA and merged PR labels despite a newer prepared SHA', + { skip: policy.event !== 'push' }, + async () => { + const text = workflow() + const section = text + .split(' - id: pr\n')[1] + .split(' - id: check\n')[0] + assert.ok(section) + const script = section + .split(' script: |\n')[1] + .split('\n') + .map((line) => (line.trim() ? line.slice(22) : '')) + .join('\n') + for (const labels of [['major', 'minor'], ['minor'], ['skip-publish'], []]) { + const outputs = {}, + calls = [] + await runInNewContext(`(async () => {${script}\n})()`, { + context: { sha, repo: { owner: 'fixture', repo: 'fixture' } }, + github: { + rest: { + repos: { + listPullRequestsAssociatedWithCommit: async (args) => { + calls.push(args) + return { + data: [ + // eslint-disable-next-line camelcase -- Fixture preserves required GitHub API wire fields. + { merged_at: null, number: 1, labels: [] }, + { + // eslint-disable-next-line camelcase -- Fixture preserves required GitHub API wire fields. + merged_at: 'fixture-time', + number: 2, + title: 'original PR', + // eslint-disable-next-line camelcase -- Fixture preserves required GitHub API wire fields. + html_url: 'fixture PR', + labels: labels.map((name) => ({ name })) + } + ] + } + } + } + } + }, + core: { info: () => undefined, setOutput: (key, value) => (outputs[key] = value) } + }) + assert.equal(calls.length, 1) + assert.equal(calls[0].commit_sha, sha) + assert.equal(outputs.found, 'true') + assert.equal(outputs.title, 'original PR') + assert.equal(outputs.has_major, String(labels.includes('major'))) + assert.equal(outputs.has_minor, String(labels.includes('minor'))) + assert.equal(outputs.has_skip_label, String(labels.includes('skip-publish'))) + } + } +) + +test( + 'provenance actual bump shell preserves major/minor/patch and manual skip', + { skip: policy.event === 'calver' }, + (t) => { + const dir = mkdtempSync(join(tmpdir(), 'release-bump-')) + t.after(() => rmSync(dir, { recursive: true, force: true })) + const run = stepBody('Determine version bump type') + for (const [event, input, major, minor, expected] of [ + ['push', '', 'true', 'true', 'major'], + ['push', '', 'false', 'true', 'minor'], + ['push', '', 'false', 'false', 'patch'], + ['workflow_dispatch', 'major', 'false', 'false', 'major'], + ['workflow_dispatch', 'minor', 'false', 'false', 'minor'], + ['workflow_dispatch', 'patch', 'false', 'false', 'patch'], + ['workflow_dispatch', 'skip', 'false', 'false', 'skip'] + ]) { + const output = join(dir, 'output') + writeFileSync(output, '') + const result = spawnSync( + '/bin/bash', + ['-e', '-c', run.replaceAll('${{ github.event_name }}', event)], + { + cwd: dir, + encoding: 'utf8', + env: { + PATH: '/usr/bin:/bin', + HOME: dir, + GITHUB_OUTPUT: output, + INPUT_VERSION: input, + HAS_MAJOR: major, + HAS_MINOR: minor + } + } + ) + assert.equal(result.status, 0) + assert.equal( + readFileSync(output, 'utf8'), + expected === 'skip' ? 'should_publish=false\n' : `bump=${expected}\n` + ) + } + } +) + +test('baseline rejects file mode changes before reading release contents', async () => { + const { selectBaseline, validateDelta } = await load() + const current = 'c'.repeat(40) + const mock = scripted([ + ...fetchMain(current), + [['git', 'merge-base', '--is-ancestor', sha, current], {}], + [ + ['git', 'rev-list', '--reverse', '--first-parent', `${sha}..${current}`], + { stdout: current } + ], + [['git', 'rev-list', '--parents', '-n', '1', current], { stdout: `${current} ${sha}` }], + [ + ['git', 'diff', '--summary', sha, current], + { stdout: ' mode change 100644 => 100755 package.json\n' } + ] + ]) + assert.equal(selectBaseline(baselineInput, mock.exec).outcome, 'stale') + mock.done() + const delta = scripted([ + [['git', 'rev-parse', 'HEAD'], { stdout: sha }], + [['git', 'ls-files', '--others', '--exclude-standard'], {}], + [['git', 'diff', '--summary', 'HEAD'], { stdout: ' mode change' }] + ]) + assert.throws(() => validateDelta(initial, delta.exec)) + delta.done() +}) + +test('ownership CLI push rejects untested version parent before any remote calls', (t) => { + shellFixture( + t, + { + ...initial, + commit: null, + tagOid: null, + tag: 'not-attempted', + release: 'not-attempted', + releaseId: null + }, + [ + [['git', 'rev-parse', 'HEAD'], { stdout: 'c'.repeat(40) }], + [ + ['git', 'rev-list', '--parents', '-n', '1', 'HEAD'], + { stdout: `${'c'.repeat(40)} ${'d'.repeat(40)}` } + ] + ], + 'if node .github/scripts/release-publication.mjs push; then exit 1; fi' + ) +}) diff --git a/.github/scripts/release-workflow.test.mjs b/.github/scripts/release-workflow.test.mjs new file mode 100644 index 0000000..c49b097 --- /dev/null +++ b/.github/scripts/release-workflow.test.mjs @@ -0,0 +1,277 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { fileURLToPath, URL } from 'node:url' +import { selectBaseline, validateMetadata } from './release-publication.mjs' + +const root = fileURLToPath(new URL('../../', import.meta.url)) +const read = (path) => readFileSync(`${root}${path}`, 'utf8') +const policy = JSON.parse(read('.github/release-policy.json')) +const workflow = policy.event === 'calver' ? null : read('.github/workflows/npm-publish.yml') + +test('consumer policy rejects unowned manifest, dependency, lockfile, and README changes', () => { + for (const config of [ + { manifests: ['package.json'], lockfile: null, readme: 'unchanged' }, + { manifests: ['package.json'], lockfile: null, readme: 'managed' }, + { manifests: ['package.json'], lockfile: 'package-lock.json', readme: 'unchanged' }, + policy + ]) { + const before = {}, + after = {} + for (const version of ['1.2.3', '1.2.4']) { + const files = version === '1.2.3' ? before : after + for (const path of config.manifests) + files[path] = JSON.stringify({ + name: path === 'package.json' ? 'canonical' : path, + version, + dependencies: + path === 'package.json' + ? { dependency: '^1' } + : { canonical: `^${version}` } + }) + files['README.md'] = + config.readme === 'managed' + ? `# Same\n${version}\n` + : '# Same\n' + if (config.lockfile) + files[config.lockfile] = JSON.stringify({ + name: 'canonical', + version, + lockfileVersion: 3, + packages: { + '': { name: 'canonical', version }, + 'node_modules/example': { version: '2.0.0' } + } + }) + } + const paths = [ + ...config.manifests, + ...(config.lockfile ? [config.lockfile] : []), + ...(config.readme === 'managed' ? ['README.md'] : []) + ] + const validate = (files, changed = paths) => + validateMetadata( + (path) => before[path], + (path) => files[path], + changed, + config + ) + assert.equal(validate(after), 'v1.2.4') + assert.throws(() => validate(after, [...paths, 'src/index.ts'])) + assert.throws(() => validate(after, [...paths, '.github/workflows/npm-publish.yml'])) + assert.throws(() => validate(after, [...paths, 'pnpm-lock.yaml'])) + const dependency = JSON.parse(after['package.json']) + dependency.dependencies.dependency = '^2' + assert.throws(() => validate({ ...after, 'package.json': JSON.stringify(dependency) })) + if (config.lockfile) { + const lock = JSON.parse(after[config.lockfile]) + lock.packages['node_modules/example'].version = '3.0.0' + assert.throws(() => validate({ ...after, [config.lockfile]: JSON.stringify(lock) })) + } + if (config.readme === 'managed') + assert.throws(() => validate({ ...after, 'README.md': `changed${after['README.md']}` })) + else assert.throws(() => validate(after, [...paths, 'README.md'])) + } +}) + +test('unchanged README without managed markers remains valid after best-effort updater failure', () => { + const before = { + 'package.json': '{"name":"example","version":"1.2.3"}', + 'README.md': '# Same\n' + } + const after = { ...before, 'package.json': '{"name":"example","version":"1.2.4"}' } + const config = { manifests: ['package.json'], lockfile: null, readme: 'managed' } + assert.equal( + validateMetadata( + (path) => before[path], + (path) => after[path], + ['package.json'], + config + ), + 'v1.2.4' + ) + assert.throws(() => + validateMetadata( + (path) => before[path], + (path) => (path === 'README.md' ? '# Changed\n' : after[path]), + ['package.json', 'README.md'], + config + ) + ) +}) + +test('PR-close baselines preserve original merge commit and never execute on unmerged events', () => { + const calls = [] + const config = { manifests: ['package.json'], lockfile: null, readme: 'unchanged' } + const input = { + event: 'pull_request', + eventSha: 'a'.repeat(40), + ref: 'refs/heads/main', + merged: false, + skip: false + } + const exec = (command, args) => { + calls.push([command, ...args]) + throw new Error('Unexpected transport') + } + assert.equal(selectBaseline(input, exec, config).ready, false) + assert.deepEqual(calls, []) +}) + +test( + 'every consumer code checkout uses the tested SHA and release gates cannot be bypassed', + { skip: !workflow }, + () => { + assert.match( + workflow, + /concurrency:\n {4}group: repository-release\n {4}cancel-in-progress: false/ + ) + const jobs = workflow.split(/(?=^ {4}[a-z][\w-]*:\n)/m).slice(1) + for (const job of jobs) { + const name = job.split(':')[0].trim() + if (!job.includes('uses: actions/checkout@')) continue + if (name === 'prepare') { + assert.match(job, /persist-credentials: false/) + assert.match(job, /fetch-depth: 0/) + assert.ok(!job.includes('secrets.')) + } else { + assert.match(job, /needs: \[[^\n]*prepare[^\n]*\]/, name) + assert.match(job, /needs.prepare.outputs.ready == 'true'/, name) + for (const checkout of job.split(/uses: actions\/checkout@[^\n]+\n/).slice(1)) + assert.match( + checkout.split(/\n {12}- /)[0], + /ref: \$\{\{ needs.prepare.outputs.checkout_sha \}\}/, + name + ) + } + } + const build = jobs.find((job) => job.startsWith(' build:')) + const publish = jobs.find((job) => job.startsWith(' publish-github-packages:')) + assert.ok(build) + const check = build.indexOf(`${policy.manager} run check`) + assert.ok(check >= 0) + const command = new RegExp(`(?:run: |\\n\\s*)${policy.manager} (?:run build|build|test)\\b`) + assert.ok(check < build.search(command), 'Source check must precede build and tests') + assert.ok(!build.slice(0, check).includes('continue-on-error: true')) + assert.ok(build.includes('node --test .github/scripts/')) + assert.match(publish, /needs.build.result == 'success'/) + for (const gate of ['debug-check', 'playwright-tests', 'coverage-report']) { + if (jobs.some((job) => job.startsWith(` ${gate}:`))) { + assert.match(publish, new RegExp(`needs: \\[[^\\n]*${gate}[^\\n]*\\]`)) + assert.match(publish, new RegExp(`needs\\.${gate}\\.result == 'success'`)) + } + } + assert.ok( + !/git push --tags|git push --delete|gh release delete|git (reset|rebase)/.test(workflow) + ) + const bump = publish + .split(' - name: Bump version\n')[1] + .split('\n - name: Create Release')[0] + assert.ok(publish.indexOf('.mjs begin') < publish.indexOf(`${policy.manager} version`)) + assert.ok(bump.indexOf('.mjs validate') < bump.indexOf('git add ')) + assert.ok(bump.includes('.mjs push')) + if (policy.readme === 'managed') + assert.ok( + bump.indexOf('refresh-release-readme.sh') < bump.indexOf('git remote set-url') + ) + else assert.ok(!bump.includes('refresh-release-readme')) + for (const path of policy.manifests) assert.ok(bump.includes(path)) + if (policy.lockfile) assert.ok(bump.includes(policy.lockfile)) + if (!policy.reference) assert.ok(!publish.includes('tombstones/')) + } +) + +test( + 'canonical publication is an independent success barrier with unknown-outcome retention', + { skip: !workflow }, + () => { + const publish = workflow + .split(' - name: Publish\n')[1] + .split('\n - name: Record canonical publication')[0] + assert.match(publish, /id: canonical/) + assert.match(publish, /if: steps.ownership.outputs.ready == 'true'/) + assert.match(publish, /run: (?:npm|pnpm) publish/) + assert.ok(!publish.includes('registry-success')) + assert.ok( + workflow.indexOf('registry-attempt') < workflow.indexOf(' - name: Publish\n') + ) + assert.match(workflow, /if: steps.canonical.outcome == 'success'/) + assert.match( + workflow, + /if: failure\(\) && steps.ownership.outputs.ready == 'true' && steps.canonical.outcome != 'success'/ + ) + assert.match(workflow, /CANONICAL_SUCCESS: \$\{\{ steps.canonical.outcome \}\}/) + assert.match(workflow, /git_commit_gpgsign: true/) + assert.match(workflow, /git_tag_gpgsign: true/) + assert.match(workflow, /id-token: write/) + if (policy.manager === 'npm') + assert.match(publish, /NODE_AUTH_TOKEN: \$\{\{ secrets.NPM_GITHUB_TOKEN \}\}/) + else assert.ok(!publish.includes('NODE_AUTH_TOKEN')) + } +) + +test('overlapping PR-close runs accept only a fully verified chain of release metadata', () => { + const a = 'a'.repeat(40), + b = 'b'.repeat(40), + c = 'c'.repeat(40) + const config = { + manifests: ['package.json'], + lockfile: 'package-lock.json', + readme: 'unchanged' + } + const files = (version) => ({ + 'package.json': JSON.stringify({ name: 'example', version, dependencies: { source: '1' } }), + 'package-lock.json': JSON.stringify({ + version, + packages: { '': { version }, dependency: { version: '1' } } + }) + }) + const commits = { [a]: files('1.2.3'), [b]: files('1.2.4'), [c]: files('1.2.5') } + const paths = ['package.json', 'package-lock.json'] + for (const sourceChange of [false, true]) { + const expected = [ + ['git', 'fetch', '--no-tags', 'origin', 'refs/heads/main'], + ['git', 'rev-parse', 'FETCH_HEAD'], + ['git', 'merge-base', '--is-ancestor', a, c], + ['git', 'rev-list', '--reverse', '--first-parent', `${a}..${c}`] + ] + let index = 0 + const exec = (command, args) => { + const call = [command, ...args] + if (index < expected.length) assert.deepEqual(call, expected[index]) + index++ + if (args[0] === 'fetch' || args[0] === 'merge-base') return { code: 0, stdout: '' } + if (args[0] === 'rev-parse') return { code: 0, stdout: c } + if (args[0] === 'rev-list' && args[1] === '--reverse') + return { code: 0, stdout: `${b}\n${c}` } + if (args[0] === 'rev-list') + return { code: 0, stdout: `${args.at(-1)} ${args.at(-1) === b ? a : b}` } + if (args[0] === 'diff' && args[1] === '--summary') return { code: 0, stdout: '' } + if (args[0] === 'diff') + return { + code: 0, + stdout: sourceChange && args.at(-1) === c ? 'src/index.ts' : paths.join('\n') + } + if (args[0] === 'show') { + const [commit, path] = args[1].split(':') + assert.ok(commits[commit]?.[path]) + return { code: 0, stdout: commits[commit][path] } + } + assert.fail('Unexpected mock-only transport') + } + const result = selectBaseline( + { + event: 'pull_request', + eventSha: a, + ref: 'refs/heads/main', + merged: true, + skip: false + }, + exec, + config + ) + assert.equal(result.ready, !sourceChange) + assert.equal(result.outcome, sourceChange ? 'stale' : 'ready') + if (result.ready) assert.equal(result.checkoutSha, c) + } +}) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f005b80..14dd58f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,4 @@ +# Release safety managed by humanspeak/svelte-diff .github/release-workflows/sync.py name: Release on: @@ -7,62 +8,108 @@ on: paths: - src/** - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - tsconfig*.json + - svelte.config.* + - .github/scripts/** + - .github/release-policy.json + - .github/release-source.json + - .github/workflows/release.yml permissions: - contents: write + contents: read + pull-requests: read + +# Default pending-run replacement guarantees neither every event nor trigger order. +concurrency: + group: repository-release + cancel-in-progress: false jobs: - release: + prepare: runs-on: ubuntu-latest + outputs: + ready: ${{ steps.baseline.outputs.ready }} + checkout_sha: ${{ steps.baseline.outputs.checkout_sha }} steps: - - uses: actions/checkout@v7 + # Sole bootstrap exception: read-only helper loading at original event SHA. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + ref: ${{ github.sha }} + persist-credentials: false fetch-depth: 0 - token: ${{ secrets.ACTIONS_KEY }} - - - uses: pnpm/action-setup@v6 - - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 24 + - name: Select tested main baseline + id: baseline + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT_NAME: ${{ github.event_name }} + EVENT_SHA: ${{ github.sha }} + EVENT_REF: ${{ github.ref }} + run: node .github/scripts/calver-release.mjs prepare - - name: Install and build + release: + needs: [prepare] + if: needs.prepare.outputs.ready == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + env: + RELEASE_STATE: ${{ runner.temp }}/release-${{ github.run_id }}-${{ github.run_attempt }}.json + PREPARED_SHA: ${{ needs.prepare.outputs.checkout_sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.prepare.outputs.checkout_sha }} + persist-credentials: false + fetch-depth: 0 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + - name: Install + run: pnpm install --prefer-offline --frozen-lockfile --ignore-scripts + - name: Check library types + run: pnpm run check + - name: Test release safety offline + run: node --test .github/scripts/*.test.mjs + - name: Build and test run: | - pnpm install --prefer-offline --frozen-lockfile --ignore-scripts pnpm build - - - name: Generate CalVer tag - id: calver - run: | - YEAR=$(date +%Y) - MONTH=$(date +%-m) - PREFIX="${YEAR}.${MONTH}" - - # Find highest micro for this month - LATEST=$(git tag --list "${PREFIX}.*" --sort=-version:refname | head -n1) - if [ -z "$LATEST" ]; then - MICRO=1 - else - MICRO=$(echo "$LATEST" | awk -F. '{print $3 + 1}') - fi - - TAG="${PREFIX}.${MICRO}" - echo "tag=$TAG" >> $GITHUB_OUTPUT - + pnpm test - name: Import GPG key - id: import_gpg - uses: crazy-max/ghaction-import-gpg@v7 + uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7 with: gpg_private_key: ${{ secrets.ACTIONS_GPG_PRIVATE_KEY }} passphrase: ${{ secrets.ACTIONS_GPG_PASSPHRASE }} git_user_signingkey: true git_tag_gpgsign: true - - - name: Create tag and release + - name: Recheck tested main and initialize ownership + id: ownership + run: node .github/scripts/calver-release.mjs begin + - name: Generate and sign owned CalVer tag + if: steps.ownership.outputs.ready == 'true' + id: tag + run: node .github/scripts/calver-release.mjs tag + - name: Push intended tag + if: steps.ownership.outputs.ready == 'true' env: GITHUB_TOKEN: ${{ secrets.ACTIONS_KEY }} - TAG: ${{ steps.calver.outputs.tag }} run: | - git tag -a "$TAG" -m "Release $TAG" - git push origin "$TAG" - gh release create "$TAG" --title "Release $TAG" --generate-notes + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git" + node .github/scripts/calver-release.mjs push + - name: Create owned release with generated notes + id: created + if: steps.ownership.outputs.ready == 'true' + env: + GH_TOKEN: ${{ secrets.ACTIONS_KEY }} + run: node .github/scripts/calver-release.mjs release + - name: Cleanup on failure + if: failure() && steps.ownership.outputs.ready == 'true' && steps.created.outcome != 'success' + env: + GH_TOKEN: ${{ secrets.ACTIONS_KEY }} + RELEASE_SUCCESS: ${{ steps.created.outcome }} + run: node .github/scripts/calver-release.mjs cleanup diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..62aafa7 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,57 @@ +# Test workflow managed by humanspeak/svelte-diff shared release source +name: Run tests + +permissions: + contents: read + +on: + workflow_call: + pull_request: + branches: [main] + paths: + - src/** + - tests/** + - package.json + - pnpm-lock.yaml + - tsconfig*.json + - svelte.config.* + - vite.config.* + - vitest.config.* + - .github/scripts/** + - .github/release-policy.json + - .github/release-source.json + - .github/workflows/run-tests.yml + - pnpm-workspace.yaml + - vitest.setup.* + - playwright.config.* + - eslint.config.* + - .npmrc + +concurrency: + group: run-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [22, 24] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: ${{ matrix.node-version }} + - name: Install + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Check library types + run: pnpm run check + - name: Test release safety offline + run: node --test .github/scripts/*.test.mjs + - name: Build and test + run: | + pnpm run build + pnpm run test From 7f0b6accf796ba55812c6ec2be31b4cbe05a6105 Mon Sep 17 00:00:00 2001 From: Jason Kummerl Date: Fri, 2 Oct 2026 13:58:41 -0400 Subject: [PATCH 2/4] ci: pin final shared release source revision --- .github/release-source.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/release-source.json b/.github/release-source.json index 644a587..fbf06e6 100644 --- a/.github/release-source.json +++ b/.github/release-source.json @@ -9,5 +9,5 @@ ".github/workflows/run-tests.yml": "5bb1940c60ed3857c313a221f32c208d537302852a2d4ea706337380ff40a1f4" }, "repository": "humanspeak/svelte-diff", - "revision": "23ff1d373da36e28c13d41280571101be0e14a4e" + "revision": "25fd09929575554caa70e7c8d4d65c26bb602087" } From 5a425da3de1a24d8eeae1f0df77ff0719aa11dfc Mon Sep 17 00:00:00 2001 From: Jason Kummerl Date: Fri, 2 Oct 2026 15:05:48 -0400 Subject: [PATCH 3/4] ci: retain version tags for workflow actions --- .github/release-source.json | 6 +++--- .github/workflows/release.yml | 12 ++++++------ .github/workflows/run-tests.yml | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/release-source.json b/.github/release-source.json index fbf06e6..1e1c14a 100644 --- a/.github/release-source.json +++ b/.github/release-source.json @@ -5,9 +5,9 @@ ".github/scripts/release-publication.mjs": "c03d90062b6fcb38e11f9bb4cb5fd8c9a14a34a56bb67870f64d81af43f56e8e", ".github/scripts/release-publication.test.mjs": "a6176fdcf61b0d118670ce1d43a09f39a12d4171e1ab6fed7960dfe048145ebf", ".github/scripts/release-workflow.test.mjs": "7a9e393cad156ac86e19b4b6aa1c72afedd5a6012652c6459e288f501ab43609", - ".github/workflows/release.yml": "194fa090c71821afac12674ed0b78228c18ce82d5a4757b56301aaecd5af0b10", - ".github/workflows/run-tests.yml": "5bb1940c60ed3857c313a221f32c208d537302852a2d4ea706337380ff40a1f4" + ".github/workflows/release.yml": "b2c16ae1a2032fc44d8709c755f157cf2b4435950108d271e309565f0247587b", + ".github/workflows/run-tests.yml": "864a40380d182ada668b6133668c4c61aefe5997ad0d3f25893f3294584a4fbe" }, "repository": "humanspeak/svelte-diff", - "revision": "25fd09929575554caa70e7c8d4d65c26bb602087" + "revision": "1f23be02075a83577e06d40de496d2e2409335bf" } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14dd58f..a119d15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,12 +34,12 @@ jobs: checkout_sha: ${{ steps.baseline.outputs.checkout_sha }} steps: # Sole bootstrap exception: read-only helper loading at original event SHA. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@v7 # zizmor: ignore[unpinned-uses] with: ref: ${{ github.sha }} persist-credentials: false fetch-depth: 0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/setup-node@v7 # zizmor: ignore[unpinned-uses] with: node-version: 24 - name: Select tested main baseline @@ -61,13 +61,13 @@ jobs: RELEASE_STATE: ${{ runner.temp }}/release-${{ github.run_id }}-${{ github.run_attempt }}.json PREPARED_SHA: ${{ needs.prepare.outputs.checkout_sha }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@v7 # zizmor: ignore[unpinned-uses] with: ref: ${{ needs.prepare.outputs.checkout_sha }} persist-credentials: false fetch-depth: 0 - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: pnpm/action-setup@v6 # zizmor: ignore[unpinned-uses] + - uses: actions/setup-node@v7 # zizmor: ignore[unpinned-uses] with: node-version: 24 - name: Install @@ -81,7 +81,7 @@ jobs: pnpm build pnpm test - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7 + uses: crazy-max/ghaction-import-gpg@v7 # zizmor: ignore[unpinned-uses] with: gpg_private_key: ${{ secrets.ACTIONS_GPG_PRIVATE_KEY }} passphrase: ${{ secrets.ACTIONS_GPG_PASSPHRASE }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 62aafa7..786a8f7 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -38,11 +38,11 @@ jobs: matrix: node-version: [22, 24] steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@v7 # zizmor: ignore[unpinned-uses] with: persist-credentials: false - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: pnpm/action-setup@v6 # zizmor: ignore[unpinned-uses] + - uses: actions/setup-node@v7 # zizmor: ignore[unpinned-uses] with: node-version: ${{ matrix.node-version }} - name: Install From 1ba2cdbd682d0c6c11e0937147919565d88b9949 Mon Sep 17 00:00:00 2001 From: Jason Kummerl Date: Fri, 2 Oct 2026 15:30:39 -0400 Subject: [PATCH 4/4] ci: own shared package release workflows in docs-kit --- .github/release-source.json | 13 - .github/release-workflows/.gitignore | 1 + .github/release-workflows/README.md | 113 ++++++ .github/release-workflows/consumers.json | 283 +++++++++++++++ .github/release-workflows/sync.py | 339 ++++++++++++++++++ .github/release-workflows/sync_test.py | 212 +++++++++++ .../release-workflows/templates/calver.yml | 115 ++++++ .../release-workflows/templates/cleanup.yml | 7 + .../release-workflows/templates/ownership.yml | 4 + .../templates/package-tests.yml | 52 +++ .../release-workflows/templates/prepare.yml | 29 ++ .../release-workflows/templates/release.yml | 12 + .github/scripts/refresh-release-readme.sh | 18 + .../scripts/refresh-release-readme.test.mjs | 190 ++++++++++ .github/workflows/release.yml | 7 +- .github/workflows/run-tests.yml | 7 +- 16 files changed, 1387 insertions(+), 15 deletions(-) delete mode 100644 .github/release-source.json create mode 100644 .github/release-workflows/.gitignore create mode 100644 .github/release-workflows/README.md create mode 100644 .github/release-workflows/consumers.json create mode 100644 .github/release-workflows/sync.py create mode 100644 .github/release-workflows/sync_test.py create mode 100644 .github/release-workflows/templates/calver.yml create mode 100644 .github/release-workflows/templates/cleanup.yml create mode 100644 .github/release-workflows/templates/ownership.yml create mode 100644 .github/release-workflows/templates/package-tests.yml create mode 100644 .github/release-workflows/templates/prepare.yml create mode 100644 .github/release-workflows/templates/release.yml create mode 100644 .github/scripts/refresh-release-readme.sh create mode 100644 .github/scripts/refresh-release-readme.test.mjs diff --git a/.github/release-source.json b/.github/release-source.json deleted file mode 100644 index 1e1c14a..0000000 --- a/.github/release-source.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "files": { - ".github/scripts/calver-release.mjs": "323d9c692a449383410e267dee5e00b29e3a6f7a1b8a20bc9c26f87d973bb73f", - ".github/scripts/calver-release.test.mjs": "5ad6c3bfb830df8ba656815a0748f47bf20f0d878e5fa1f1811bdc278ed3b132", - ".github/scripts/release-publication.mjs": "c03d90062b6fcb38e11f9bb4cb5fd8c9a14a34a56bb67870f64d81af43f56e8e", - ".github/scripts/release-publication.test.mjs": "a6176fdcf61b0d118670ce1d43a09f39a12d4171e1ab6fed7960dfe048145ebf", - ".github/scripts/release-workflow.test.mjs": "7a9e393cad156ac86e19b4b6aa1c72afedd5a6012652c6459e288f501ab43609", - ".github/workflows/release.yml": "b2c16ae1a2032fc44d8709c755f157cf2b4435950108d271e309565f0247587b", - ".github/workflows/run-tests.yml": "864a40380d182ada668b6133668c4c61aefe5997ad0d3f25893f3294584a4fbe" - }, - "repository": "humanspeak/svelte-diff", - "revision": "1f23be02075a83577e06d40de496d2e2409335bf" -} diff --git a/.github/release-workflows/.gitignore b/.github/release-workflows/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.github/release-workflows/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/.github/release-workflows/README.md b/.github/release-workflows/README.md new file mode 100644 index 0000000..9ce19c0 --- /dev/null +++ b/.github/release-workflows/README.md @@ -0,0 +1,113 @@ +# Shared release safety + +Docs-kit owns the shared publishing source by the maintainer’s explicit choice. +The source is this directory and the release helpers in `../scripts/`. It extends +the svelte-diff reference from +PR #216 (`51fe85747719a5ca0c1223198f55607956b2e67f`). Repository and Git history +inspection found no earlier publishing template, generator, reusable workflow, +or publishing sync mechanism in Humanspeak's packages, docs-kit, dotfiles, or +DevOps repository. The existing DevOps sync jobs manage labels, rulesets, +environments, and secrets. The earlier docs-kit reference identified the README +updater; the maintainer subsequently chose docs-kit to own publishing as well. + +## Sync a consumer + +Use an isolated checkout of an immutable source commit. Review the consumer's +`.github/release-policy.json` and instructions, then run: + +```sh +python3 .github/release-workflows/sync.py /path/to/consumer --revision SOURCE_COMMIT_SHA +python3 .github/release-workflows/sync.py /path/to/consumer --revision SOURCE_COMMIT_SHA --check +``` + +`--revision` must be the full 40-character SHA of the source checkout. The tool +vendors dependency-free helpers, adapts workflow fragments, and records hashes +of the resulting scripts and workflows in `.github/release-source.json`. +`--check` detects helper and workflow drift without writing. The source checkout +does not carry a circular source stamp; every consumer, including svelte-diff, +records its docs-kit source revision. Unsupported initial +workflow layouts fail before any files are written. Repeated syncs are +idempotent. Existing jobs, gates, permissions, managers, signing, registry +commands, authentication, labels, and event provenance stay in the consumer. +Shared prepare, ownership, release creation, and cleanup fragments are refreshed +on subsequent syncs; repository-specific mutation/publication steps remain local +and are checked by the offline integration suite. + +There is no runtime dependency on an unmerged source branch. Review consumer +changes and their checks, commit them, and open PRs labeled `skip-publish`. +Do not publish, merge, deploy, or dispatch a live release as part of syncing. + +## Repository policy + +- `manager`: retain npm or pnpm and the version selected by existing setup steps. +- `event`: `push` keeps original-commit merged-PR lookup; `pull_request` uses the + original merge commit and event labels; `manual` permits main-only manual + releases; `calver` retains signed, tag-only GitHub releases. +- `manifests`: canonical `package.json` followed only by actual lockstep aliases. + Every manifest change is structurally limited to versions and alias ranges. +- `lockfile`: npm permits only the top-level and root-package version changes + in `package-lock.json`. Dependency entries remain structurally identical. + pnpm versioning does not change its lockfile; any such advancement is stale. +- `readme`: `managed` permits only changes inside one existing docs-kit marker + pair. An unchanged README remains valid if a best-effort updater fails. + `unchanged` forbids README changes and installs no updater. First-time footer + installation must happen in a separate reviewed source commit. + +Only svelte-diff has two tombstone/alias manifests. Ordinary consumers configure +one manifest. Svelte-diff's `reference` flag enables its original integration assertions; it +does not designate shared-source ownership. The helper's default policy contains no package-specific aliases +or README assumptions; the CLI requires the repository policy. + +The updater is pinned to docs-kit commit +`882b87e6a73c408c6b31fe8a185e8d0ea397fa37`, which is the inspected docs-kit main +revision for this rollout. It uses built-in Node APIs, detects the current +package name without environment variables, and owns an existing MIT ecosystem +footer. It is suitable for the consumers already using that footer. Other +packages and docs-kit itself retain their existing README policy. + +## Release guarantees and limits + +All release workflows use `repository-release` with `cancel-in-progress: false`. +Under GitHub's default pending-run policy, a newer pending run replaces the older +pending run. Serialization guarantees neither execution of every event nor +original trigger order. See [GitHub's concurrency documentation](https://docs.github.com/en/actions/concepts/workflows-and-actions/concurrency). + +Prepare resolves one SHA before testing. Every later code job consumes that +SHA. Push and PR-close events accept intervening commits only after proving each +is a single-parent, structurally verified metadata change; unrelated source, +configuration, dependency, file-mode, or ancestry changes skip the stale run. +Manual main requests select current main before testing. CalVer automatic +requests reject all main advancement. Immediately before mutation, main is +rechecked; no untested rebase or reset is allowed. + +SemVer releases use an ordinary fast-forward main push followed by one explicit +annotated tag push. CalVer releases mutate no manifests or main ref. A rejected +main push prevents all later publication. Existing tag/release collisions and +ambiguous creation outcomes are retained for reconciliation. + +Run identity, release commit, exact annotated tag object, and numeric release ID +are written atomically outside the checkout. Cleanup requires proven ownership, +protects replacement artifacts, uses an exact-object tag deletion lease, and +uses the recorded numeric release ID. Unknown registry outcomes retain metadata. +Canonical publication has its own step-outcome barrier even if recording state +subsequently fails. Successful CalVer release creation similarly prevents later +cleanup. Cleanup is safe to repeat. + +## Validation + +```sh +python3 -m unittest discover -s .github/release-workflows -p '*test.py' +node --test .github/scripts/*.test.mjs +``` + +Tests use temporary fixtures and mocked Git, GitHub, download, and registry +transports. They cover overlapping and stale runs, metadata chains, rejected +pushes, collisions, replacements, malformed and foreign state, ambiguous +outcomes, successful publication followed by failure, and repeated cleanup. +The shared sync tests cover npm/pnpm differences, manual tag selection, +idempotence, drift, and failure before partial writes. Run each consumer's source +check, build, units, and configured lint checks before committing. + +Local validation does not exercise GitHub-hosted release execution, production +environments, actual GPG credentials, trusted publishing, or live publication. +Those remain an explicitly authorized rollout step after review. diff --git a/.github/release-workflows/consumers.json b/.github/release-workflows/consumers.json new file mode 100644 index 0000000..b713ba2 --- /dev/null +++ b/.github/release-workflows/consumers.json @@ -0,0 +1,283 @@ +[ + { + "repository": "humanspeak/docs-kit", + "baseline": "882b87e6a73c408c6b31fe8a185e8d0ea397fa37", + "package": "@humanspeak/docs-kit", + "packageManager": "pnpm@12.6.0", + "policy": { + "manager": "pnpm", + "event": "calver", + "manifests": ["package.json"], + "lockfile": null, + "readme": "unchanged" + }, + "registry": null, + "authentication": "none (GitHub releases only)", + "signing": "signed annotated tags", + "sourceCheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "publishCommands": [] + }, + { + "repository": "humanspeak/memory-cache", + "baseline": "9eaa6c0ac94d80e2e16d9228bf4115e0d6237757", + "package": "@humanspeak/memory-cache", + "packageManager": "pnpm@12.6.0", + "policy": { + "manager": "pnpm", + "event": "pull_request", + "manifests": ["package.json"], + "lockfile": null, + "readme": "managed" + }, + "registry": "https://registry.npmjs.org/", + "authentication": "OIDC", + "signing": "signed commits and annotated tags", + "sourceCheck": "tsc --noEmit", + "publishCommands": ["pnpm publish --provenance --access public --no-git-checks"] + }, + { + "repository": "humanspeak/svelte-diff", + "baseline": "068080b400bc9f73f778affe5d8c13c87c2b77db", + "package": "@humanspeak/svelte-diff", + "packageManager": "pnpm@12.6.0", + "policy": { + "manager": "pnpm", + "event": "push", + "manifests": [ + "package.json", + "tombstones/svelte-diff/package.json", + "tombstones/svelte-diff-match-patch/package.json" + ], + "lockfile": null, + "readme": "managed", + "reference": true + }, + "registry": "https://registry.npmjs.org/", + "authentication": "OIDC", + "signing": "signed commits and annotated tags", + "sourceCheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "publishCommands": [ + "pnpm publish --provenance --access public --no-git-checks", + "pnpm --dir tombstones/svelte-diff-match-patch publish --provenance --access public --no-git-checks ||", + "pnpm --dir tombstones/svelte-diff publish --provenance --access public --no-git-checks ||" + ] + }, + { + "repository": "humanspeak/svelte-headless-table", + "baseline": "66fe99c03956ae94f49876aca038c73f60e254f5", + "package": "@humanspeak/svelte-headless-table", + "packageManager": "pnpm@12.6.0", + "policy": { + "manager": "pnpm", + "event": "push", + "manifests": ["package.json"], + "lockfile": null, + "readme": "managed" + }, + "registry": "https://registry.npmjs.org/", + "authentication": "OIDC", + "signing": "signed commits and annotated tags", + "sourceCheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && svelte-check --tsconfig ./tsconfig.lib.json", + "publishCommands": ["pnpm publish --provenance --access public --no-git-checks"] + }, + { + "repository": "humanspeak/svelte-json-view-lite", + "baseline": "0c30e16288a323279b3005c9e6b678a8a5b0bd6b", + "package": "@humanspeak/svelte-json-view-lite", + "packageManager": "pnpm@12.6.0", + "policy": { + "manager": "pnpm", + "event": "push", + "manifests": ["package.json"], + "lockfile": null, + "readme": "managed" + }, + "registry": "https://registry.npmjs.org/", + "authentication": "OIDC", + "signing": "signed commits and annotated tags", + "sourceCheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "publishCommands": ["pnpm publish --provenance --access public --no-git-checks"] + }, + { + "repository": "humanspeak/svelte-keyed", + "baseline": "5246a42a28e92f2133941118aeaf5a00e390eb41", + "package": "@humanspeak/svelte-keyed", + "packageManager": "npm (package-lock.json)", + "policy": { + "manager": "npm", + "event": "pull_request", + "manifests": ["package.json"], + "lockfile": "package-lock.json", + "readme": "unchanged" + }, + "registry": "https://registry.npmjs.org/", + "authentication": "NPM_GITHUB_TOKEN", + "signing": "signed commits and annotated tags", + "sourceCheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "publishCommands": ["npm publish --provenance --access public"] + }, + { + "repository": "humanspeak/svelte-markdown", + "baseline": "550ff8b7e92fd163dbf025991caaca1312efb3d9", + "package": "@humanspeak/svelte-markdown", + "packageManager": "pnpm@12.6.0", + "policy": { + "manager": "pnpm", + "event": "push", + "manifests": ["package.json"], + "lockfile": null, + "readme": "managed" + }, + "registry": "https://registry.npmjs.org/", + "authentication": "OIDC", + "signing": "signed commits and annotated tags", + "sourceCheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "publishCommands": ["pnpm publish --provenance --access public --no-git-checks"] + }, + { + "repository": "humanspeak/svelte-motion", + "baseline": "3df9244894dd81b69a3869bebed597ec24cd357e", + "package": "@humanspeak/svelte-motion", + "packageManager": "pnpm@12.6.0", + "policy": { + "manager": "pnpm", + "event": "push", + "manifests": ["package.json"], + "lockfile": null, + "readme": "managed" + }, + "registry": "https://registry.npmjs.org/", + "authentication": "OIDC", + "signing": "signed commits and annotated tags", + "sourceCheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "publishCommands": [ + "pnpm publish --provenance --access public --no-git-checks --tag latest" + ] + }, + { + "repository": "humanspeak/svelte-purify", + "baseline": "0d016f7f1049ff9e40d2462a25d25a74a8e2b44e", + "package": "@humanspeak/svelte-purify", + "packageManager": "pnpm 10 (workflow setup)", + "policy": { + "manager": "pnpm", + "event": "pull_request", + "manifests": ["package.json"], + "lockfile": null, + "readme": "managed" + }, + "registry": "https://registry.npmjs.org/", + "authentication": "OIDC", + "signing": "signed commits and annotated tags", + "sourceCheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "publishCommands": ["pnpm publish --provenance --access public --no-git-checks"] + }, + { + "repository": "humanspeak/svelte-render", + "baseline": "1a729adb8e752d731b306ccc9122862edcbd0d8a", + "package": "@humanspeak/svelte-render", + "packageManager": "npm (package-lock.json)", + "policy": { + "manager": "npm", + "event": "pull_request", + "manifests": ["package.json"], + "lockfile": "package-lock.json", + "readme": "unchanged" + }, + "registry": "https://registry.npmjs.org/", + "authentication": "NPM_GITHUB_TOKEN", + "signing": "signed commits and annotated tags", + "sourceCheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "publishCommands": ["npm publish --provenance --access public"] + }, + { + "repository": "humanspeak/svelte-satori-fix", + "baseline": "1289dc5f3fef6af17598496c5e18f5627ad48ab6", + "package": "@humanspeak/svelte-satori-fix", + "packageManager": "pnpm 10 (workflow setup)", + "policy": { + "manager": "pnpm", + "event": "pull_request", + "manifests": ["package.json"], + "lockfile": null, + "readme": "unchanged" + }, + "registry": "https://registry.npmjs.org/", + "authentication": "OIDC", + "signing": "signed commits and annotated tags", + "sourceCheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "publishCommands": ["pnpm publish --provenance --access public --no-git-checks"] + }, + { + "repository": "humanspeak/svelte-scoped-props", + "baseline": "b3034f24c146efeff9edb0beec9bfcc9bd5d78c1", + "package": "@humanspeak/svelte-scoped-props", + "packageManager": "pnpm@12.6.0", + "policy": { + "manager": "pnpm", + "event": "pull_request", + "manifests": ["package.json"], + "lockfile": null, + "readme": "managed" + }, + "registry": "https://registry.npmjs.org/", + "authentication": "OIDC", + "signing": "signed commits and annotated tags", + "sourceCheck": "pnpm run typecheck && pnpm run build && pnpm run test:unit && pnpm run test:pages:build", + "publishCommands": ["pnpm publish --provenance --access public --no-git-checks"] + }, + { + "repository": "humanspeak/svelte-subscribe", + "baseline": "594c0464d3584cd8527f3deee911ef72b4803117", + "package": "@humanspeak/svelte-subscribe", + "packageManager": "npm (package-lock.json)", + "policy": { + "manager": "npm", + "event": "pull_request", + "manifests": ["package.json"], + "lockfile": "package-lock.json", + "readme": "unchanged" + }, + "registry": "https://registry.npmjs.org/", + "authentication": "NPM_GITHUB_TOKEN", + "signing": "signed commits and annotated tags", + "sourceCheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "publishCommands": ["npm publish --provenance --access public"] + }, + { + "repository": "humanspeak/svelte-virtual-chat", + "baseline": "edf902ff633ed6d03d0f0aa42117e6f882455193", + "package": "@humanspeak/svelte-virtual-chat", + "packageManager": "pnpm@12.6.0", + "policy": { + "manager": "pnpm", + "event": "push", + "manifests": ["package.json"], + "lockfile": null, + "readme": "managed" + }, + "registry": "https://registry.npmjs.org/", + "authentication": "OIDC", + "signing": "signed commits and annotated tags", + "sourceCheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "publishCommands": ["pnpm publish --provenance --access public --no-git-checks"] + }, + { + "repository": "humanspeak/svelte-virtual-list", + "baseline": "4e431b1b3053deb8f081e67a2cd9233173abdfc0", + "package": "@humanspeak/svelte-virtual-list", + "packageManager": "pnpm@12.6.0", + "policy": { + "manager": "pnpm", + "event": "push", + "manifests": ["package.json"], + "lockfile": null, + "readme": "managed" + }, + "registry": "https://registry.npmjs.org/", + "authentication": "OIDC", + "signing": "signed commits and annotated tags", + "sourceCheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "publishCommands": ["pnpm publish --provenance --access public --no-git-checks"] + } +] diff --git a/.github/release-workflows/sync.py b/.github/release-workflows/sync.py new file mode 100644 index 0000000..b3b4435 --- /dev/null +++ b/.github/release-workflows/sync.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""Sync release safety from docs-kit without replacing consumer jobs.""" + +import argparse +import hashlib +import json +import re +import subprocess +import textwrap +from pathlib import Path + +SOURCE = Path(__file__).resolve().parents[2] +BUNDLE = [ + 'release-publication.mjs', + 'release-publication.test.mjs', + 'refresh-release-readme.sh', + 'refresh-release-readme.test.mjs', + 'release-workflow.test.mjs', + 'calver-release.mjs', + 'calver-release.test.mjs', +] +HEADER = '# Release safety managed by humanspeak/docs-kit .github/release-workflows/sync.py\n' + + +def once(text, old, new): + if text.count(old) != 1: + raise ValueError(f'Expected unique workflow boundary: {old!r}') + return text.replace(old, new, 1) + + +def fragment(name): + text = (SOURCE / '.github/release-workflows/templates' / f'{name}.yml').read_text() + width = 0 if name in ['calver', 'package-tests'] else (4 if name == 'prepare' else 12) + return textwrap.indent(text, ' ' * width) + + +def step(text, name): + marker = f' - name: {name}\n' + start = text.index(marker) + match = re.search(r'\n - (?:name:|uses:)', text[start + len(marker):]) + end = start + len(marker) + match.start() + 1 if match else len(text) + return text[start:end] + + +def replace_step(text, name, value): + return once(text, step(text, name), value) + + +def prepare_fragment(policy): + prepare = fragment('prepare') + if policy['event'] != 'push': + prepare = prepare.replace(" needs: check-if-merged\n if: needs.check-if-merged.outputs.should_run == 'true'\n", '') + prepare = prepare.replace('${{ github.sha }}', '${{ github.event.pull_request.merge_commit_sha || github.sha }}') + prepare = prepare.replace('${{ needs.check-if-merged.outputs.should_run }}', '${{ github.event.pull_request.merged }}') + prepare = prepare.replace('${{ needs.check-if-merged.outputs.has_skip_label }}', "${{ contains(github.event.pull_request.labels.*.name, 'skip-publish') }}") + return prepare + '\n' + + +def release_fragment(policy): + release = fragment('release') + if policy['event'] != 'push': + release = release.replace('${{ needs.check-if-merged.outputs.pr_title }}', '${{ github.event.pull_request.title }}') + release = release.replace('${{ needs.check-if-merged.outputs.pr_url }}', '${{ github.event.pull_request.html_url }}') + return release + '\n' + + +def paths(text, manager, publish): + # Preserve trigger policy, exclusions, and all consumer configuration filters. + keys = ['package-lock.json'] if manager == 'npm' else ['pnpm-lock.yaml', 'pnpm-workspace.yaml'] + keys += ['tsconfig*.json', 'svelte.config.*', 'vite.config.*', 'vitest.config.*', + 'vitest.setup.*', 'playwright.config.*', 'eslint.config.*', '.npmrc', + '.github/scripts/**', '.github/release-policy.json', '.github/release-source.json'] + if publish: + keys += ['.github/workflows/npm-publish.yml'] + else: + keys += ['.github/workflows/run-tests.yml'] + if ' paths-ignore:\n' in text: + # Removing the broad GitHub exclusion lets helper/workflow changes reach CI. + text = text.replace(' - .github/**\n', '') + return text + if ' paths:\n' not in text: + return text + start = text.index(' paths:\n') + match = re.search(r'\n(?: [a-z]|[a-z])', text[start + 1:]) + end = start + 1 + match.start() if match else len(text) + old = text[start:end] + new = old.rstrip('\n') + '\n' + if manager == 'pnpm': + new = new.replace(' - package-lock.json\n', '') + for key in keys: + if f' - {key}\n' not in new: + new += f' - {key}\n' + return text[:start] + new + text[end:] + + +def type_gate(text, manager, offline): + if 'name: Check library types\n' in text: + return text + # Find the first build/test command, split a combined install/build step if necessary. + lines = text.splitlines(keepends=True) + index = next(i for i, line in enumerate(lines) + if re.match(r'\s+(?:run: )?(?:pnpm (?:build|test)|npm (?:run build|test))\b', line)) + start = max(i for i in range(index + 1) if lines[i].startswith(' - ')) + if 'name: Install' in lines[start] and 'run: |' in ''.join(lines[start:index]): + # npm's legacy workflow builds inside Install; move only that command after check. + command = lines.pop(index).strip() + index = next(i for i in range(start + 1, len(lines)) if lines[i].startswith(' - ')) + lines[index:index] = [' - name: Build\n', f' run: {command}\n', '\n'] + start = index + gate = [' - name: Check library types\n', f' run: {manager} run check\n', '\n'] + if offline: + gate += [' - name: Test release safety offline\n', + ' run: node --test .github/scripts/*.test.mjs\n', '\n'] + lines[start:start] = gate + return ''.join(lines) + + +def harden(text, policy): + manager = policy['manager'] + text = text.replace('# Release safety managed by humanspeak/svelte-diff .github/release-workflows/sync.py\n', HEADER) + if HEADER in text: + match = re.search(r'^ prepare:\n[\s\S]*?(?=^ [a-z][\w-]*:\n|\Z)', text, re.M) + if not match: + raise ValueError('Missing prepared baseline job') + text = once(text, match.group(0), prepare_fragment(policy)) + text = replace_step(text, 'Recheck tested main and initialize ownership', fragment('ownership') + '\n') + text = replace_step(text, 'Create Release', release_fragment(policy)) + text = replace_step(text, 'Cleanup on failure', fragment('cleanup') + '\n') + return paths(text, manager, True) + # The source workflow is already hardened; retain its shim and integration policy. + if ' prepare:\n' in text: + return HEADER + paths(text, manager, True) + if policy['event'] == 'manual': + start = text.index(' pull_request:\n') + end = text.index(' workflow_dispatch:\n', start) + text = text[:start] + text[end:] + suppression = ' # trunk-ignore(checkov/CKV_GHA_7): We need manual version control for releases\n' + present = suppression in text + text = text.replace(suppression, '') + text = once(text, ' inputs:\n', ' inputs:\n' + (suppression if present else '') + + ' dist_tag:\n description: npm distribution tag\n' + ' required: true\n type: choice\n' + ' default: alpha\n options: [alpha, beta, latest]\n') + text = paths(text, manager, True) + text = once(text, 'jobs:\n', '# Pending runs may be replaced; neither every event nor trigger order is guaranteed.\n' + 'concurrency:\n group: repository-release\n cancel-in-progress: false\n\njobs:\n') + prepare = prepare_fragment(policy) + event = policy['event'] + first = re.search(r'^ [a-z][\w-]*:\n', text[text.index('jobs:\n') + 6:], re.M) + # Place prepare before existing jobs without moving or replacing those jobs. + location = text.index('jobs:\n') + 6 + first.start() + text = text[:location] + prepare + text[location:] + # Every code-consuming job shares prepare's SHA and retains its existing dependencies. + chunks = re.split(r'(?=^ [a-z][\w-]*:\n)', text, flags=re.M) + for i, chunk in enumerate(chunks): + if chunk.startswith(' prepare:') or 'uses: actions/checkout@' not in chunk: + continue + name = chunk.split(':')[0].strip() + needs = re.search(r'^ needs: (.+)$', chunk, re.M) + if needs: + original = needs.group(1).strip('[]') + chunk = once(chunk, needs.group(0), f' needs: [{original}, prepare]') + else: + chunk = once(chunk, f' {name}:\n', f' {name}:\n needs: [prepare]\n') + if ' if: |\n' in chunk: + chunk = once(chunk, ' if: |\n', " if: |\n needs.prepare.outputs.ready == 'true' &&\n") + elif re.search(r'^ if: ', chunk, re.M): + chunk = re.sub(r'^ if: (.+)$', r" if: needs.prepare.outputs.ready == 'true' && \1", chunk, count=1, flags=re.M) + else: + chunk = once(chunk, f' {name}:\n', f" {name}:\n if: needs.prepare.outputs.ready == 'true'\n") + if name == 'publish-github-packages': + # Legacy jobs lacked explicit failure gates. A successful prepare alone cannot authorize publish. + chunk = re.sub(r"^ if: needs.prepare.outputs.ready == 'true'$", + " if: needs.prepare.outputs.ready == 'true' && needs.build.result == 'success' && needs.coverage-report.result == 'success'", + chunk, count=1, flags=re.M) + if ' if: |\n' not in chunk: + gates = [gate for gate in ['build', 'playwright-tests', 'coverage-report'] if f' {gate}:\n' in text and f'needs.{gate}.result' not in chunk] + extra = ''.join(f" && needs.{gate}.result == 'success'" for gate in gates) + chunk = re.sub(r'^ if: (.+)$', lambda match: match.group(0) + extra, chunk, count=1, flags=re.M) + if 'debug-check' in text and 'needs.debug-check.result' not in chunk: + chunk = chunk.replace('build,', 'debug-check, build,', 1) + if ' if: |\n' in chunk: + chunk = once(chunk, ' if: |\n', " if: |\n needs.debug-check.result == 'success' &&\n") + else: + chunk = re.sub(r'^ if: (.+)$', r" if: needs.debug-check.result == 'success' && \1", chunk, count=1, flags=re.M) + chunk = once(chunk, ' permissions:\n', ' env:\n' + ' RELEASE_STATE: ${{ runner.temp }}/release-${{ github.run_id }}-${{ github.run_attempt }}.json\n' + ' PREPARED_SHA: ${{ needs.prepare.outputs.checkout_sha }}\n permissions:\n') + chunk = re.sub(r'(uses: actions/checkout@[^\n]+\n with:\n)', + r'\1 ref: ${{ needs.prepare.outputs.checkout_sha }}\n', chunk) + chunks[i] = chunk + text = ''.join(chunks) + # Recheck main immediately before any version mutation and establish run ownership. + text = once(text, ' - name: Bump version\n', fragment('ownership') + '\n' + ' - name: Bump version\n') + bump = step(text, 'Bump version') + bump = bump.replace("if: steps.publish-check.outputs.should_publish == 'true'", "if: steps.ownership.outputs.ready == 'true'") + auth_start = bump.index(' # Set up authentication for push\n') + auth_end = bump.index('\n\n', auth_start) + 2 + authentication = bump[auth_start:auth_end] + bump = bump[:auth_start] + bump[auth_end:] + # Keep the manager and manifest mutations; use package.json rather than version command stdout. + bump = re.sub(r' NEW_VERSION=\$\((?:pnpm|npm) version "\$BUMP_TYPE" --no-git-tag-version\)\n', + f' {manager} version "$BUMP_TYPE" --no-git-tag-version\n' + ' PACKAGE_VERSION=$(node -p "require(\'./package.json\').version")\n' + ' NEW_VERSION="v${PACKAGE_VERSION}"\n', bump) + if ' # Refresh the managed README footer' in bump: + start = bump.index(' # Refresh the managed README footer') + end = bump.index(' # Commit the version changes', start) + tail = bump[end:] + bump = bump[:start] + ' # Run the immutable updater with only PATH before Git write authentication.\n' + bump += ' bash .github/scripts/refresh-release-readme.sh\n\n' + tail + # The original tail above may contain removed authentication only before refresh, never after it. + commit = bump.index(' # Commit the version changes') + bump = bump[:commit] + authentication + ' node .github/scripts/release-publication.mjs validate\n\n' + bump[commit:] + bump = bump.replace(' git push\n git push --tags\n', + ' node .github/scripts/release-publication.mjs push\n') + text = replace_step(text, 'Bump version', bump) + text = replace_step(text, 'Create Release', release_fragment(policy)) + publish = step(text, 'Publish') + match = re.search(r'^\s+(?:run: )?((?:npm|pnpm) publish[^\n]*)$', publish, re.M) + if not match: + raise ValueError('Unrecognized canonical publication command') + command = match.group(1) + if event == 'manual': + command += ' --tag "$DIST_TAG"' + setup = publish[publish.index(' run: |\n') + len(' run: |\n'):match.start()] + # Credentials on npm's original publication step must remain scoped to that step. + env = publish[publish.index(' env:\n'):] if ' env:\n' in publish else '' + prepared = ' - name: Prepare canonical publication\n' + prepared += " if: steps.ownership.outputs.ready == 'true'\n run: |\n" + if event == 'manual': + setup += ' case "$DIST_TAG" in alpha|beta|latest) ;; *) exit 1 ;; esac\n' + prepared += ' env:\n DIST_TAG: ${{ github.event.inputs.dist_tag }}\n' + # env must precede run, rather than becoming part of a block scalar. + prepared = prepared.replace(' run: |\n env:', ' env:') + prepared += ' run: |\n' + env = ' env:\n DIST_TAG: ${{ github.event.inputs.dist_tag }}\n' + prepared += setup + ' node .github/scripts/release-publication.mjs registry-attempt\n\n' + prepared += ' - name: Publish\n id: canonical\n' + prepared += " if: steps.ownership.outputs.ready == 'true'\n" + prepared += f' run: {command}\n' + env + '\n' + prepared += ' - name: Record canonical publication\n' + prepared += " if: steps.canonical.outcome == 'success'\n" + prepared += ' run: node .github/scripts/release-publication.mjs registry-success\n\n' + text = replace_step(text, 'Publish', prepared) + text = replace_step(text, 'Cleanup on failure', fragment('cleanup') + '\n') + text = type_gate(text, manager, True) + return HEADER + text + + +def sync(target, revision, check=False): + policy_path = target / '.github/release-policy.json' + policy = json.loads(policy_path.read_text()) + if policy['manager'] not in ['npm', 'pnpm'] or policy['event'] not in ['push', 'pull_request', 'manual', 'calver']: + raise ValueError('Unsupported consumer policy') + outputs = {} + for name in BUNDLE: + if policy['event'] != 'calver' and name.startswith('calver-release'): + continue + if policy['readme'] != 'managed' and name.startswith('refresh-release-readme'): + continue + outputs[f'.github/scripts/{name}'] = (SOURCE / '.github/scripts' / name).read_bytes() + if policy['event'] == 'calver': + outputs['.github/workflows/release.yml'] = fragment('calver').encode() + else: + workflow = target / '.github/workflows/npm-publish.yml' + outputs[str(workflow.relative_to(target))] = harden(workflow.read_text(), policy).encode() + tests = target / '.github/workflows/run-tests.yml' + if tests.exists(): + text = type_gate(paths(tests.read_text(), policy['manager'], False), policy['manager'], False) + # Verify workflow helper changes during ordinary PR CI as well as release CI. + if 'node --test .github/scripts/*.test.mjs' not in text: + gate = f' run: {policy["manager"]} run check\n' + text = once(text, gate, gate + '\n - name: Test release safety offline\n' + ' run: node --test .github/scripts/*.test.mjs\n') + outputs[str(tests.relative_to(target))] = text.encode() + tests = target / '.github/workflows/run-tests.yml' + if not tests.exists() or tests.read_text().startswith(('# Test workflow managed by humanspeak/svelte-diff', '# Test workflow managed by humanspeak/docs-kit')): + text = fragment('package-tests') + manager = policy['manager'] + text = text.replace('__LOCKFILE__', 'package-lock.json' if manager == 'npm' else 'pnpm-lock.yaml') + text = text.replace('__NODE_MATRIX__', '[20, 22]' if manager == 'npm' else '[22, 24]') + text = text.replace(' # __MANAGER_SETUP__\n', + ' - uses: pnpm/action-setup@v6 # zizmor: ignore[unpinned-uses]\n' + if manager == 'pnpm' else '') + text = text.replace('__INSTALL__', 'npm ci' if manager == 'npm' else 'pnpm install --frozen-lockfile --ignore-scripts') + text = text.replace('__MANAGER__', manager) + outputs['.github/workflows/run-tests.yml'] = paths(text, manager, False).encode() + coverage = target / '.github/workflows/coveralls.yml' + if coverage.exists() and policy['manager'] == 'npm': + outputs['.github/workflows/coveralls.yml'] = type_gate(coverage.read_text(), 'npm', True).encode() + source_owner = target.resolve() == SOURCE.resolve() + if source_owner: + for path in ['.github/workflows/release.yml', '.github/workflows/run-tests.yml']: + text = outputs[path].decode() + text = once(text, ' run: node --test .github/scripts/*.test.mjs\n', + ' env:\n RELEASE_UPDATER_TEST_TARGET: helper\n' + ' run: node --test .github/scripts/*.test.mjs\n' + ' - name: Test shared workflow synchronization\n' + ' run: python3 -m unittest discover -s .github/release-workflows -p \'*test.py\'\n') + text = once(text, ' - .github/scripts/**\n', + ' - .github/scripts/**\n - .github/release-workflows/**\n') + outputs[path] = text.encode() + source = {'repository': 'humanspeak/docs-kit', 'revision': revision, + 'files': {path: hashlib.sha256(data).hexdigest() for path, data in outputs.items()}} + if not source_owner: + outputs['.github/release-source.json'] = (json.dumps(source, indent=4, sort_keys=True) + '\n').encode() + drift = [] + for path, data in outputs.items(): + file = target / path + if not file.exists() or file.read_bytes() != data: + drift.append(path) + if not check: + file.parent.mkdir(parents=True, exist_ok=True) + file.write_bytes(data) + if check and drift: + raise ValueError(f'Release source drift: {", ".join(drift)}') + return drift + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('checkout', type=Path) + parser.add_argument('--revision', required=True, help='Immutable source commit SHA') + parser.add_argument('--check', action='store_true') + args = parser.parse_args() + if not re.fullmatch('[a-f0-9]{40}', args.revision): + parser.error('--revision requires a full commit SHA') + current = subprocess.run(['git', 'rev-parse', 'HEAD'], cwd=SOURCE, capture_output=True, text=True, check=True).stdout.strip() + dirty = subprocess.run(['git', 'status', '--porcelain', '--', '.github/scripts', '.github/release-workflows', + '.github/release-policy.json', '.github/workflows/release.yml', '.github/workflows/npm-publish.yml', + '.github/workflows/run-tests.yml'], cwd=SOURCE, capture_output=True, text=True, check=True).stdout.strip() + if args.revision != current or dirty: + parser.error('--revision must identify this clean, committed source checkout') + try: + for changed in sync(args.checkout.resolve(), args.revision, args.check): + print(changed) + except (ValueError, KeyError, StopIteration) as error: + parser.exit(1, f'{error}\n') diff --git a/.github/release-workflows/sync_test.py b/.github/release-workflows/sync_test.py new file mode 100644 index 0000000..db77ec0 --- /dev/null +++ b/.github/release-workflows/sync_test.py @@ -0,0 +1,212 @@ +"""Offline sync regressions for manager variants, trigger policies, and workflow drift.""" +import importlib.util +import json +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('release_sync', Path(__file__).with_name('sync.py')) +sync = importlib.util.module_from_spec(spec) +spec.loader.exec_module(sync) + + +def legacy(manager='pnpm', managed=True): + install = 'pnpm install --frozen-lockfile' if manager == 'pnpm' else 'npm ci\n npm run build' + test = 'pnpm build\n pnpm test' if manager == 'pnpm' else 'npm test' + updater = ''' # Refresh the managed README footer + UPDATER="$RUNNER_TEMP/updater.mjs" + curl -fsSL https://example.invalid/main/updater.mjs -o "$UPDATER" + node "$UPDATER" + +''' if managed else '' + return '''name: Publish +on: + pull_request: + types: [closed] + paths: + - src/** + - package.json + - package-lock.json + workflow_dispatch: + inputs: + version_bump: + type: choice + options: [skip, patch, minor, major] +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Install + run: | + INSTALL + - name: Test + run: | + TEST + coverage-report: + needs: [build] + runs-on: ubuntu-latest + steps: + - run: echo coverage + publish-github-packages: + needs: [build, coverage-report] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + fetch-depth: 0 + - name: Check Publishing Status + id: publish-check + run: echo ready + - name: Determine version bump type + id: version-type + run: echo patch + - name: Bump version + if: steps.publish-check.outputs.should_publish == 'true' + id: version + run: | + # Set up authentication for push + git remote set-url origin "$GITHUB_TOKEN" + + NEW_VERSION=$(MANAGER version "$BUMP_TYPE" --no-git-tag-version) +UPDATER # Commit the version changes + git add package.json + git commit -m bump + git tag -a "$NEW_VERSION" -m release + git push + git push --tags + + - name: Create Release + run: gh release create "$NEW_VERSION" + + - name: Publish + run: | + MANAGER config set registry https://registry.npmjs.org/ + MANAGER publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_GITHUB_TOKEN }} + + - name: Cleanup on failure + if: failure() + run: gh release delete "$NEW_VERSION" + + - name: Notify on failure + if: failure() + run: echo failed +'''.replace('INSTALL', install).replace('TEST', test).replace('MANAGER', manager).replace('UPDATER', updater) + + +class SyncTests(unittest.TestCase): + def policy(self, manager='pnpm', managed=True, event='pull_request'): + return {'manager': manager, 'event': event, 'manifests': ['package.json'], + 'lockfile': 'package-lock.json' if manager == 'npm' else None, + 'readme': 'managed' if managed else 'unchanged'} + + def test_manager_variants_are_idempotent_and_preserve_publication(self): + for manager in ['npm', 'pnpm']: + for managed in [False, True]: + with self.subTest(manager=manager, managed=managed): + policy = self.policy(manager, managed) + text = sync.harden(legacy(manager, managed), policy) + self.assertEqual(sync.harden(text, policy), text) + self.assertIn('needs.build.result', text) + self.assertIn('needs.coverage-report.result', text) + self.assertIn('NODE_AUTH_TOKEN: ${{ secrets.NPM_GITHUB_TOKEN }}', text) + self.assertIn(f'run: {manager} publish --provenance --access public', text) + self.assertLess(text.index(f'{manager} run check'), text.index(f'{manager} ' + ('build' if manager == 'pnpm' else 'run build'))) + self.assertEqual(text.count('bash .github/scripts/refresh-release-readme.sh'), int(managed)) + self.assertNotIn('git push --tags', text) + self.assertNotIn('gh release delete', text) + + def test_generated_ci_uses_version_tags_and_has_no_whitespace_only_lines(self): + for manager in ['npm', 'pnpm']: + with self.subTest(manager=manager), tempfile.TemporaryDirectory(prefix='release-sync-') as directory: + target = Path(directory) + (target / '.github/workflows').mkdir(parents=True) + (target / '.github/release-policy.json').write_text(json.dumps(self.policy(manager))) + (target / '.github/workflows/npm-publish.yml').write_text(legacy(manager)) + sync.sync(target, 'a' * 40) + text = (target / '.github/workflows/run-tests.yml').read_text() + self.assertFalse(any(line and not line.strip() for line in text.splitlines())) + self.assertNotRegex(text, r'uses: [^\s]+@[0-9a-f]{40}') + self.assertIn('actions/setup-node@v7 # zizmor: ignore[unpinned-uses]', text) + self.assertEqual('pnpm/action-setup@v6 # zizmor: ignore[unpinned-uses]' in text, + manager == 'pnpm') + self.assertEqual(sync.sync(target, 'a' * 40, check=True), []) + + def test_source_ownership_is_docs_kit_and_reference_consumers_record_provenance(self): + original_source = sync.SOURCE + with tempfile.TemporaryDirectory(prefix='release-source-') as directory: + target = Path(directory) + shutil.copytree(original_source / '.github/release-workflows', target / '.github/release-workflows', + ignore=shutil.ignore_patterns('__pycache__')) + shutil.copytree(original_source / '.github/scripts', target / '.github/scripts') + policy = self.policy(managed=False, event='calver') + (target / '.github/release-policy.json').write_text(json.dumps(policy)) + with patch.object(sync, 'SOURCE', target): + sync.sync(target, 'a' * 40) + self.assertEqual(sync.sync(target, 'a' * 40, check=True), []) + self.assertFalse((target / '.github/release-source.json').exists()) + for name in ['release.yml', 'run-tests.yml']: + workflow = (target / '.github/workflows' / name).read_text() + self.assertIn('Test shared workflow synchronization', workflow) + self.assertIn('RELEASE_UPDATER_TEST_TARGET: helper', workflow) + self.assertIn('.github/release-workflows/**', workflow) + with tempfile.TemporaryDirectory(prefix='release-reference-') as directory: + target = Path(directory) + (target / '.github/workflows').mkdir(parents=True) + policy = self.policy() + policy['reference'] = True + (target / '.github/release-policy.json').write_text(json.dumps(policy)) + (target / '.github/workflows/npm-publish.yml').write_text(legacy()) + sync.sync(target, 'a' * 40) + provenance = json.loads((target / '.github/release-source.json').read_text()) + self.assertEqual(provenance['repository'], 'humanspeak/docs-kit') + self.assertEqual(provenance['revision'], 'a' * 40) + + def test_manual_policy_restores_tag_choices_and_removes_automatic_release(self): + policy = self.policy(event='manual') + text = sync.harden(legacy(), policy) + self.assertNotIn(' pull_request:\n', text) + self.assertIn('default: alpha', text) + self.assertIn('options: [alpha, beta, latest]', text) + self.assertIn('--tag "$DIST_TAG"', text) + self.assertLess(text.index('case "$DIST_TAG"'), text.index('.mjs registry-attempt')) + self.assertEqual(sync.harden(text, policy), text) + + def test_sync_check_detects_workflow_drift_and_failure_writes_no_partial_bundle(self): + with tempfile.TemporaryDirectory(prefix='release-sync-') as directory: + target = Path(directory) + (target / '.github/workflows').mkdir(parents=True) + (target / '.github/release-policy.json').write_text(json.dumps(self.policy())) + workflow = target / '.github/workflows/npm-publish.yml' + workflow.write_text(legacy()) + sync.sync(target, 'a' * 40) + self.assertEqual(sync.sync(target, 'a' * 40, check=True), []) + original = workflow.read_text() + workflow.write_text(original.replace('cancel-in-progress: false', 'cancel-in-progress: true')) + with self.assertRaisesRegex(ValueError, 'Release source drift'): + sync.sync(target, 'a' * 40, check=True) + with tempfile.TemporaryDirectory(prefix='release-sync-') as directory: + target = Path(directory) + (target / '.github/workflows').mkdir(parents=True) + (target / '.github/release-policy.json').write_text(json.dumps(self.policy())) + workflow = target / '.github/workflows/npm-publish.yml' + workflow.write_text(legacy().replace(' - name: Bump version', ' - name: Unexpected versioning')) + with self.assertRaises(ValueError): + sync.sync(target, 'a' * 40) + self.assertFalse((target / '.github/scripts').exists()) + self.assertIn('Unexpected versioning', workflow.read_text()) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/release-workflows/templates/calver.yml b/.github/release-workflows/templates/calver.yml new file mode 100644 index 0000000..7584bf4 --- /dev/null +++ b/.github/release-workflows/templates/calver.yml @@ -0,0 +1,115 @@ +# Release safety managed by humanspeak/docs-kit .github/release-workflows/sync.py +name: Release + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - src/** + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - tsconfig*.json + - svelte.config.* + - .github/scripts/** + - .github/release-policy.json + - .github/release-source.json + - .github/workflows/release.yml + +permissions: + contents: read + pull-requests: read + +# Default pending-run replacement guarantees neither every event nor trigger order. +concurrency: + group: repository-release + cancel-in-progress: false + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + ready: ${{ steps.baseline.outputs.ready }} + checkout_sha: ${{ steps.baseline.outputs.checkout_sha }} + steps: + # Sole bootstrap exception: read-only helper loading at original event SHA. + - uses: actions/checkout@v7 # zizmor: ignore[unpinned-uses] + with: + ref: ${{ github.sha }} + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-node@v7 # zizmor: ignore[unpinned-uses] + with: + node-version: 24 + - name: Select tested main baseline + id: baseline + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT_NAME: ${{ github.event_name }} + EVENT_SHA: ${{ github.sha }} + EVENT_REF: ${{ github.ref }} + run: node .github/scripts/calver-release.mjs prepare + + release: + needs: [prepare] + if: needs.prepare.outputs.ready == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + env: + RELEASE_STATE: ${{ runner.temp }}/release-${{ github.run_id }}-${{ github.run_attempt }}.json + PREPARED_SHA: ${{ needs.prepare.outputs.checkout_sha }} + steps: + - uses: actions/checkout@v7 # zizmor: ignore[unpinned-uses] + with: + ref: ${{ needs.prepare.outputs.checkout_sha }} + persist-credentials: false + fetch-depth: 0 + - uses: pnpm/action-setup@v6 # zizmor: ignore[unpinned-uses] + - uses: actions/setup-node@v7 # zizmor: ignore[unpinned-uses] + with: + node-version: 24 + - name: Install + run: pnpm install --prefer-offline --frozen-lockfile --ignore-scripts + - name: Check library types + run: pnpm run check + - name: Test release safety offline + run: node --test .github/scripts/*.test.mjs + - name: Build and test + run: | + pnpm build + pnpm test + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@v7 # zizmor: ignore[unpinned-uses] + with: + gpg_private_key: ${{ secrets.ACTIONS_GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.ACTIONS_GPG_PASSPHRASE }} + git_user_signingkey: true + git_tag_gpgsign: true + - name: Recheck tested main and initialize ownership + id: ownership + run: node .github/scripts/calver-release.mjs begin + - name: Generate and sign owned CalVer tag + if: steps.ownership.outputs.ready == 'true' + id: tag + run: node .github/scripts/calver-release.mjs tag + - name: Push intended tag + if: steps.ownership.outputs.ready == 'true' + env: + GITHUB_TOKEN: ${{ secrets.ACTIONS_KEY }} + run: | + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git" + node .github/scripts/calver-release.mjs push + - name: Create owned release with generated notes + id: created + if: steps.ownership.outputs.ready == 'true' + env: + GH_TOKEN: ${{ secrets.ACTIONS_KEY }} + run: node .github/scripts/calver-release.mjs release + - name: Cleanup on failure + if: failure() && steps.ownership.outputs.ready == 'true' && steps.created.outcome != 'success' + env: + GH_TOKEN: ${{ secrets.ACTIONS_KEY }} + RELEASE_SUCCESS: ${{ steps.created.outcome }} + run: node .github/scripts/calver-release.mjs cleanup diff --git a/.github/release-workflows/templates/cleanup.yml b/.github/release-workflows/templates/cleanup.yml new file mode 100644 index 0000000..09f6194 --- /dev/null +++ b/.github/release-workflows/templates/cleanup.yml @@ -0,0 +1,7 @@ +- name: Cleanup on failure + if: failure() && steps.ownership.outputs.ready == 'true' && steps.canonical.outcome != 'success' + env: + CANONICAL_SUCCESS: ${{ steps.canonical.outcome }} + GITHUB_TOKEN: ${{ secrets.ACTIONS_KEY }} + run: | + node .github/scripts/release-publication.mjs cleanup diff --git a/.github/release-workflows/templates/ownership.yml b/.github/release-workflows/templates/ownership.yml new file mode 100644 index 0000000..d79fdb2 --- /dev/null +++ b/.github/release-workflows/templates/ownership.yml @@ -0,0 +1,4 @@ +- name: Recheck tested main and initialize ownership + if: steps.publish-check.outputs.should_publish == 'true' + id: ownership + run: node .github/scripts/release-publication.mjs begin diff --git a/.github/release-workflows/templates/package-tests.yml b/.github/release-workflows/templates/package-tests.yml new file mode 100644 index 0000000..fdae09e --- /dev/null +++ b/.github/release-workflows/templates/package-tests.yml @@ -0,0 +1,52 @@ +# Test workflow managed by humanspeak/docs-kit shared release source +name: Run tests + +permissions: + contents: read + +on: + workflow_call: + pull_request: + branches: [main] + paths: + - src/** + - tests/** + - package.json + - __LOCKFILE__ + - tsconfig*.json + - svelte.config.* + - vite.config.* + - vitest.config.* + - .github/scripts/** + - .github/release-policy.json + - .github/release-source.json + - .github/workflows/run-tests.yml + +concurrency: + group: run-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: __NODE_MATRIX__ + steps: + - uses: actions/checkout@v7 # zizmor: ignore[unpinned-uses] + with: + persist-credentials: false + # __MANAGER_SETUP__ + - uses: actions/setup-node@v7 # zizmor: ignore[unpinned-uses] + with: + node-version: ${{ matrix.node-version }} + - name: Install + run: __INSTALL__ + - name: Check library types + run: __MANAGER__ run check + - name: Test release safety offline + run: node --test .github/scripts/*.test.mjs + - name: Build and test + run: | + __MANAGER__ run build + __MANAGER__ run test diff --git a/.github/release-workflows/templates/prepare.yml b/.github/release-workflows/templates/prepare.yml new file mode 100644 index 0000000..0c76305 --- /dev/null +++ b/.github/release-workflows/templates/prepare.yml @@ -0,0 +1,29 @@ +prepare: + needs: check-if-merged + if: needs.check-if-merged.outputs.should_run == 'true' + runs-on: ubuntu-latest + outputs: + checkout_sha: ${{ steps.baseline.outputs.checkout_sha }} + ready: ${{ steps.baseline.outputs.ready }} + outcome: ${{ steps.baseline.outputs.outcome }} + steps: + # Sole bootstrap exception: read-only helper loading at original event SHA. + - name: Bootstrap baseline helper + uses: actions/checkout@v7 # zizmor: ignore[unpinned-uses] + with: + ref: ${{ github.sha }} + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-node@v7 # zizmor: ignore[unpinned-uses] + with: + node-version: 24 + - name: Select tested baseline + id: baseline + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_SHA: ${{ github.sha }} + EVENT_REF: ${{ github.ref }} + PR_FOUND: ${{ needs.check-if-merged.outputs.should_run }} + HAS_SKIP_LABEL: ${{ needs.check-if-merged.outputs.has_skip_label }} + INPUT_VERSION: ${{ github.event.inputs.version_bump }} + run: node .github/scripts/release-publication.mjs prepare diff --git a/.github/release-workflows/templates/release.yml b/.github/release-workflows/templates/release.yml new file mode 100644 index 0000000..e7a01c9 --- /dev/null +++ b/.github/release-workflows/templates/release.yml @@ -0,0 +1,12 @@ +- name: Create Release + if: steps.ownership.outputs.ready == 'true' + id: create_release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEW_VERSION: ${{ steps.version.outputs.new_version }} + EVENT_NAME: ${{ github.event_name }} + CUSTOM_MESSAGE: ${{ github.event.inputs.custom_message }} + PR_TITLE: ${{ needs.check-if-merged.outputs.pr_title }} + PR_URL: ${{ needs.check-if-merged.outputs.pr_url }} + run: | + node .github/scripts/release-publication.mjs release diff --git a/.github/scripts/refresh-release-readme.sh b/.github/scripts/refresh-release-readme.sh new file mode 100644 index 0000000..baf1fe2 --- /dev/null +++ b/.github/scripts/refresh-release-readme.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Keep the authenticated download private even if the caller enables tracing. +set +x +set -euo pipefail + +if [[ -z ${GITHUB_TOKEN-} ]]; then + echo "::warning::missing ecosystem updater download credential; skipping README refresh" + exit 0 +fi + +UPDATER="${RUNNER_TEMP}/update-ecosystem-readme.mjs" +if curl -fsSL -H "Authorization: token ${GITHUB_TOKEN}" \ + https://raw.githubusercontent.com/humanspeak/docs-kit/882b87e6a73c408c6b31fe8a185e8d0ea397fa37/scripts/update-ecosystem-readme.mjs \ + -o "${UPDATER}"; then + env -i PATH="${PATH}" node "${UPDATER}" || echo "::warning::ecosystem updater errored; README left unchanged" +else + echo "::warning::could not fetch ecosystem updater; skipping README refresh" +fi diff --git a/.github/scripts/refresh-release-readme.test.mjs b/.github/scripts/refresh-release-readme.test.mjs new file mode 100644 index 0000000..5780b8c --- /dev/null +++ b/.github/scripts/refresh-release-readme.test.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import process from 'node:process' +import test from 'node:test' +import { fileURLToPath, URL } from 'node:url' + +const checkout = fileURLToPath(new URL('../../', import.meta.url)) +const helper = join(checkout, '.github/scripts/refresh-release-readme.sh') +const pinnedUrl = + 'https://raw.githubusercontent.com/humanspeak/docs-kit/882b87e6a73c408c6b31fe8a185e8d0ea397fa37/scripts/update-ecosystem-readme.mjs' +const movingUrl = + 'https://raw.githubusercontent.com/humanspeak/docs-kit/main/scripts/update-ecosystem-readme.mjs' +const invocation = 'bash .github/scripts/refresh-release-readme.sh' +const original = '# Fixture README\n' + +function uniqueIndex(text, marker) { + const index = text.indexOf(marker) + assert.ok(index >= 0, `Missing harness boundary: ${marker}`) + assert.equal(text.indexOf(marker, index + marker.length), -1, `Ambiguous boundary: ${marker}`) + return index +} + +function bumpRun() { + const workflow = readFileSync(join(checkout, '.github/workflows/npm-publish.yml'), 'utf8') + const start = uniqueIndex(workflow, '- name: Bump version') + const end = uniqueIndex(workflow, '- name: Create Release') + assert.ok(end > start, 'Release step must follow version step') + const step = workflow.slice(start, end) + const run = uniqueIndex(step, ' run: |\n') + return step + .slice(run + ' run: |\n'.length) + .split('\n') + .map((line) => { + if (!line.trim()) return '' + assert.ok(line.startsWith(' '), 'Invalid run indentation') + return line.slice(18) + }) + .join('\n') +} + +function implementation() { + const target = process.env.RELEASE_UPDATER_TEST_TARGET + assert.ok(target === undefined || target === 'helper', 'Invalid implementation selector') + if (target === 'helper') { + assert.ok(existsSync(helper), 'Source helper must exist') + return [helper] + } + const run = bumpRun() + const count = run.split(invocation).length - 1 + assert.ok(count <= 1, 'Ambiguous helper invocation') + if (count === 1) { + assert.ok(existsSync(helper), 'Workflow helper must exist') + return [helper] + } + const start = uniqueIndex(run, '# Refresh the managed README footer') + const end = uniqueIndex(run, '# Commit the version changes') + assert.ok(end > start, 'Commit must follow updater') + return ['-c', run.slice(start, end)] +} + +function fixture(t, { curlFails = false, updaterFails = false, credential = true } = {}) { + const root = mkdtempSync(join(tmpdir(), 'release-readme-')) + t.after(() => rmSync(root, { recursive: true, force: true })) + writeFileSync(join(root, 'README.md'), original) + const observation = join(root, 'observation.json') + const urlLog = join(root, 'url.json') + const updater = ` +import { appendFileSync, writeFileSync } from 'node:fs' +// Node initializes this macOS runtime key even when exec receives only PATH. +const keys = Object.keys(process.env) + .filter((key) => process.platform !== 'darwin' || key !== '__CF_USER_TEXT_ENCODING') + .sort() +writeFileSync(${JSON.stringify(observation)}, JSON.stringify(keys)) +if (keys.length !== 1 || keys[0] !== 'PATH') process.exit(7) +if (${updaterFails}) process.exit(9) +appendFileSync('README.md', 'refreshed\\n') +` + // Fake transport records only the URL, never headers, credential values, or argv. + const curl = `#!${process.execPath} +const fs = require('node:fs') +const args = process.argv.slice(2) +const url = args.find((arg) => arg.startsWith('https://')) +if (![${JSON.stringify(pinnedUrl)}, ${JSON.stringify(movingUrl)}].includes(url)) process.exit(2) +fs.writeFileSync(${JSON.stringify(urlLog)}, JSON.stringify(url)) +const output = args[args.indexOf('-o') + 1] +if (!args.includes('-o') || !output) process.exit(2) +fs.writeFileSync(output, ${JSON.stringify(updater)}) +process.exit(${curlFails ? 22 : 0}) +` + writeFileSync(join(root, 'curl'), curl, { mode: 0o755 }) + // Construct the entire parent environment; never copy developer credentials/config. + const env = { + PATH: `${root}:${dirname(process.execPath)}:/usr/bin:/bin`, + RUNNER_TEMP: root, + GH_TOKEN: 'synthetic-gh', + NODE_AUTH_TOKEN: 'synthetic-node', + NPM_TOKEN: 'synthetic-npm', + RELEASE_SENTINEL: 'synthetic-sentinel', + HOME: root + } + if (credential) env.GITHUB_TOKEN = 'synthetic-github' + const result = spawnSync('/bin/bash', implementation(), { + cwd: root, + env, + encoding: 'utf8', + timeout: 10000 + }) + assert.equal(result.error, undefined, 'Fixture subprocess must start and finish') + assert.equal(result.status, 0, 'README refresh must remain best-effort') + // Keep diagnostics key-only even if a future regression logs synthetic secrets. + for (const [key, value] of Object.entries(env)) { + if (key.endsWith('TOKEN') || key === 'RELEASE_SENTINEL') { + assert.ok( + !`${result.stdout}${result.stderr}`.includes(value), + `Logged value for ${key}` + ) + } + } + return { + keys: existsSync(observation) ? JSON.parse(readFileSync(observation, 'utf8')) : null, + url: existsSync(urlLog) ? JSON.parse(readFileSync(urlLog, 'utf8')) : null, + readme: readFileSync(join(root, 'README.md'), 'utf8'), + output: `${result.stdout}${result.stderr}` + } +} + +test('download uses the exact immutable revision', (t) => { + assert.equal(fixture(t).url, pinnedUrl) +}) + +test('updater receives only PATH, without credentials or sentinels', (t) => { + assert.deepEqual(fixture(t).keys, ['PATH']) +}) + +test('successful isolation refreshes the fixture README exactly once', (t) => { + assert.equal(fixture(t).readme, `${original}refreshed\n`) +}) + +test('failed curl never executes even a partially downloaded updater', (t) => { + const result = fixture(t, { curlFails: true }) + assert.equal(result.keys, null) + assert.equal(result.readme, original) + assert.match(result.output, /::warning::could not fetch ecosystem updater/) +}) + +test('failed updater warns and preserves the fixture README', (t) => { + const result = fixture(t, { updaterFails: true }) + assert.ok(result.keys, 'Downloaded updater must actually execute') + assert.equal(result.readme, original) + assert.match(result.output, /::warning::ecosystem updater errored/) +}) + +test('missing download credential skips download and execution with a warning', (t) => { + const result = fixture(t, { credential: false }) + assert.equal(result.url, null) + assert.equal(result.keys, null) + assert.equal(result.readme, original) + assert.match(result.output, /::warning::/) +}) + +test( + 'workflow refreshes after versioning and shim updates, before Git write authentication', + { skip: process.env.RELEASE_UPDATER_TEST_TARGET === 'helper' }, + () => { + const run = bumpRun() + const refresh = uniqueIndex(run, invocation) + const policy = JSON.parse( + readFileSync(join(checkout, '.github/release-policy.json'), 'utf8') + ) + const version = uniqueIndex( + run, + `${policy.manager} version "$BUMP_TYPE" --no-git-tag-version` + ) + const shims = run.includes('for SHIM in ') ? uniqueIndex(run, 'done\n') : version + 1 + const authentication = uniqueIndex( + run, + 'git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/' + ) + const staging = uniqueIndex(run, 'git add package.json README.md') + assert.ok(version < shims && shims < refresh, 'Versioning and shims must precede refresh') + assert.ok( + refresh < authentication && authentication < staging, + 'Authenticate after refresh' + ) + assert.ok(!run.includes(movingUrl), 'Workflow must not download from a moving branch') + } +) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a119d15..7b96f65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -# Release safety managed by humanspeak/svelte-diff .github/release-workflows/sync.py +# Release safety managed by humanspeak/docs-kit .github/release-workflows/sync.py name: Release on: @@ -13,6 +13,7 @@ on: - tsconfig*.json - svelte.config.* - .github/scripts/** + - .github/release-workflows/** - .github/release-policy.json - .github/release-source.json - .github/workflows/release.yml @@ -75,7 +76,11 @@ jobs: - name: Check library types run: pnpm run check - name: Test release safety offline + env: + RELEASE_UPDATER_TEST_TARGET: helper run: node --test .github/scripts/*.test.mjs + - name: Test shared workflow synchronization + run: python3 -m unittest discover -s .github/release-workflows -p '*test.py' - name: Build and test run: | pnpm build diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 786a8f7..72a5353 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,4 +1,4 @@ -# Test workflow managed by humanspeak/svelte-diff shared release source +# Test workflow managed by humanspeak/docs-kit shared release source name: Run tests permissions: @@ -18,6 +18,7 @@ on: - vite.config.* - vitest.config.* - .github/scripts/** + - .github/release-workflows/** - .github/release-policy.json - .github/release-source.json - .github/workflows/run-tests.yml @@ -50,7 +51,11 @@ jobs: - name: Check library types run: pnpm run check - name: Test release safety offline + env: + RELEASE_UPDATER_TEST_TARGET: helper run: node --test .github/scripts/*.test.mjs + - name: Test shared workflow synchronization + run: python3 -m unittest discover -s .github/release-workflows -p '*test.py' - name: Build and test run: | pnpm run build