From c413e21b218bf23df5abef749d6b4d885f398925 Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 5 Nov 2026 14:48:55 +0100 Subject: [PATCH 1/4] feat(release): share GitHub publication and recovery checks --- release/github-release.py | 106 +++++++++++++++++++++++++++++++++ release/test_github_release.py | 71 ++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 release/github-release.py create mode 100644 release/test_github_release.py diff --git a/release/github-release.py b/release/github-release.py new file mode 100644 index 0000000..c587823 --- /dev/null +++ b/release/github-release.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Publish the validated tag's changelog; fail closed on API errors.""" + +import logging +import os +from pathlib import Path +import re +import runpy +import sys +from urllib.parse import quote + +DIRECTORY = Path(__file__).resolve().parent +TAGGING = runpy.run_path(str(DIRECTORY / 'create-tag.py')) +VALIDATION = runpy.run_path(str(DIRECTORY / 'validate-release.py')) +APIError = TAGGING['APIError'] +GitHub = TAGGING['GitHub'] + + +def release_notes(changelog, tag): + if not re.fullmatch(r'v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)', tag): + raise ValueError('Expected a stable vX.Y.Z tag') + lines = changelog.splitlines() + starts = [i for i, line in enumerate(lines) if re.fullmatch(r'## \[' + re.escape(tag[1:]) + r'\](?: - \d{4}-\d{2}-\d{2})?', line)] + if len(starts) != 1: + raise ValueError('Expected one version changelog section') + end = next((i for i in range(starts[0] + 1, len(lines)) if lines[i].startswith('## ')), len(lines)) + body = '\n'.join(lines[starts[0] + 1:end]).strip() + if not re.sub(r'^#{1,6}\s.*$', '', body, flags=re.MULTILINE).strip(): + raise ValueError('Version changelog section is empty') + return body + + +def verify_release(release, tag): + if (release['tag_name'] != tag or release['draft'] is not False or + release['prerelease'] is not False or not release['published_at']): + raise ValueError('Existing release is not the expected published stable release') + + +def publish(api, tag, commit, notes): + if not re.fullmatch(r'[0-9a-f]{40}', commit) or commit == '0' * 40: + raise ValueError('Expected the exact tag commit SHA') + path = '/releases/tags/' + quote(tag, safe='') + try: + release = api.request('GET', path) + except APIError as error: + if error.status != 404: + raise + else: + verify_release(release, tag) + return 'existing' + try: + release = api.request('POST', '/releases', { + 'tag_name': tag, 'target_commitish': commit, 'name': tag, 'body': notes, + 'draft': False, 'prerelease': False, 'make_latest': 'legacy', + }) + except APIError as error: + if error.status != 422: + raise + # A concurrent successful publisher is the only acceptable collision. + release = api.request('GET', path) + verify_release(release, tag) + return 'existing' + verify_release(release, tag) + return 'created' + + +def tag_context(tag): + git = VALIDATION['git'] + commit = git('rev-parse', 'HEAD').strip() + if git('rev-parse', f'refs/tags/{tag}^{{commit}}').strip() != commit: + raise ValueError('Checkout does not match the dereferenced tag') + git('merge-base', '--is-ancestor', commit, 'refs/remotes/origin/main') + policy = VALIDATION['policy_at'](commit, '.github/release-policy.json') + version = VALIDATION['check_current'](commit, policy) + if tag != policy['tag_prefix'] + version or not policy['outputs']['github_release']: + raise ValueError('Tag or GitHub Release output differs from the policy') + changelog = VALIDATION['read_file'](commit, policy['changelog']) + notes = release_notes(changelog, 'v' + version) + return commit, policy, notes + + +def main(): + if len(sys.argv) not in (2, 3) or sys.argv[1] not in ('publish', 'notes'): + raise ValueError('Usage: github-release.py publish | notes OUTPUT_PATH') + tag = os.environ['GITHUB_REF_NAME'] + commit, policy, notes = tag_context(tag) + if sys.argv[1] == 'notes': + if len(sys.argv) != 3: + raise ValueError('Notes output path is required') + Path(sys.argv[2]).write_text(notes + '\n', encoding='utf-8') + return + if len(sys.argv) != 2 or policy['outputs']['packages']: + raise ValueError('Standalone publisher requires a GitHub-only release policy') + api = GitHub(os.environ['GITHUB_REPOSITORY'], os.environ['GH_TOKEN'], os.environ['GITHUB_API_URL']) + if TAGGING['existing_commit'](api, tag) != commit: + raise ValueError('Remote tag does not match the validated commit') + logging.info('%s release %s', publish(api, tag, commit, notes), tag) + + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO, format='%(message)s') + try: + main() + except (ValueError, KeyError, TypeError, OSError) as error: + logging.error('Release publication failed: %s', error) + sys.exit(1) diff --git a/release/test_github_release.py b/release/test_github_release.py new file mode 100644 index 0000000..f66168a --- /dev/null +++ b/release/test_github_release.py @@ -0,0 +1,71 @@ +import importlib.util +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('publish_release', ROOT / 'release/github-release.py') +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +TAG = 'v2.3.0' +COMMIT = 'a' * 40 +RELEASE = {'tag_name': TAG, 'draft': False, 'prerelease': False, 'published_at': '2026-11-05'} + + +class FakeAPI: + def __init__(self, responses): + self.responses = iter(responses) + self.calls = [] + + def request(self, method, path, data=None): + self.calls.append((method, path, data)) + response = next(self.responses) + if isinstance(response, Exception): + raise response + return response + + +class PublishReleaseTests(unittest.TestCase): + def test_changelog(self): + text = '## [Unreleased]\n\n- Next\n\n## [2.3.0] - 2026-11-04\n\n### Added\n- Feature\n\n## [2.2.0]\n- Old\n' + self.assertEqual(module.release_notes(text, TAG), '### Added\n- Feature') + for text in ('', '## [2.3.0]\n### Added\n', '## [2.3.0]\nA\n## [2.3.0]\nB'): + with self.subTest(text=text), self.assertRaises(ValueError): + module.release_notes(text, TAG) + with self.assertRaises(ValueError): + module.release_notes('## [2.3.0]\nA', 'v2.3.0-rc.1') + + def test_create_then_retry_without_duplicate(self): + api = FakeAPI([module.APIError(404), RELEASE]) + self.assertEqual(module.publish(api, TAG, COMMIT, 'Notes'), 'created') + self.assertEqual(api.calls[1], ('POST', '/releases', { + 'tag_name': TAG, 'target_commitish': COMMIT, 'name': TAG, 'body': 'Notes', + 'draft': False, 'prerelease': False, 'make_latest': 'legacy', + })) + api = FakeAPI([RELEASE]) + self.assertEqual(module.publish(api, TAG, COMMIT, 'Notes'), 'existing') + self.assertEqual(len(api.calls), 1) + + def test_api_errors_never_mean_absence(self): + for error in (module.APIError(401), module.APIError(403), module.APIError(429), module.APIError(500), OSError('network')): + api = FakeAPI([error]) + with self.subTest(error=error), self.assertRaises((ValueError, OSError)): + module.publish(api, TAG, COMMIT, 'Notes') + self.assertEqual(len(api.calls), 1) + + def test_reject_unpublished_or_wrong_release(self): + for change in ({'tag_name': 'v2.4.0'}, {'draft': True}, {'prerelease': True}, {'published_at': None}): + with self.subTest(change=change), self.assertRaises(ValueError): + module.publish(FakeAPI([{**RELEASE, **change}]), TAG, COMMIT, 'Notes') + + def test_race_requires_a_published_release_and_creation_errors_fail(self): + api = FakeAPI([module.APIError(404), module.APIError(422), RELEASE]) + self.assertEqual(module.publish(api, TAG, COMMIT, 'Notes'), 'existing') + for status in (403, 500): + with self.subTest(status=status), self.assertRaises(module.APIError): + module.publish(FakeAPI([module.APIError(404), module.APIError(status)]), TAG, COMMIT, 'Notes') + with self.assertRaises(module.APIError): + module.publish(FakeAPI([module.APIError(404), module.APIError(422), module.APIError(404)]), TAG, COMMIT, 'Notes') + + +if __name__ == '__main__': + unittest.main() From 8ae00ab34e74ffe16977cf28bdc93ecd6724ed6d Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 5 Nov 2026 14:50:09 +0100 Subject: [PATCH 2/4] test(release): cover shared publisher tag provenance --- release/github-release.py | 3 ++- release/test_github_release.py | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/release/github-release.py b/release/github-release.py index c587823..fe58081 100644 --- a/release/github-release.py +++ b/release/github-release.py @@ -20,11 +20,12 @@ def release_notes(changelog, tag): if not re.fullmatch(r'v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)', tag): raise ValueError('Expected a stable vX.Y.Z tag') lines = changelog.splitlines() - starts = [i for i, line in enumerate(lines) if re.fullmatch(r'## \[' + re.escape(tag[1:]) + r'\](?: - \d{4}-\d{2}-\d{2})?', line)] + starts = [i for i, line in enumerate(lines) if re.fullmatch(r'## \[' + re.escape(tag[1:]) + r'\](?:[^\S\n][^\n]*)?', line)] if len(starts) != 1: raise ValueError('Expected one version changelog section') end = next((i for i in range(starts[0] + 1, len(lines)) if lines[i].startswith('## ')), len(lines)) body = '\n'.join(lines[starts[0] + 1:end]).strip() + body = re.sub(r'', '', body, flags=re.DOTALL).strip() if not re.sub(r'^#{1,6}\s.*$', '', body, flags=re.MULTILINE).strip(): raise ValueError('Version changelog section is empty') return body diff --git a/release/test_github_release.py b/release/test_github_release.py index f66168a..f839f2a 100644 --- a/release/test_github_release.py +++ b/release/test_github_release.py @@ -1,6 +1,7 @@ import importlib.util from pathlib import Path import unittest +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] spec = importlib.util.spec_from_file_location('publish_release', ROOT / 'release/github-release.py') @@ -25,6 +26,30 @@ def request(self, method, path, data=None): class PublishReleaseTests(unittest.TestCase): + def test_tag_context_requires_tag_commit_main_policy_and_mirrors(self): + policy = {'tag_prefix': 'v', 'changelog': 'CHANGELOG.md', + 'outputs': {'github_release': True, 'packages': []}} + calls = [] + def git(*args): + calls.append(args) + if args[0] == 'rev-parse': + return COMMIT + '\n' + return '' + validation = {'git': git, 'policy_at': lambda *args: policy, + 'check_current': lambda *args: '2.3.0', + 'read_file': lambda *args: '## [2.3.0] - 2026-11-04\n\n- Notes\n'} + with patch.dict(module.VALIDATION, validation): + self.assertEqual(module.tag_context(TAG), (COMMIT, policy, '- Notes')) + self.assertIn(('merge-base', '--is-ancestor', COMMIT, 'refs/remotes/origin/main'), calls) + with self.assertRaises(ValueError): + module.tag_context('v2.4.0') + with patch.dict(module.VALIDATION, {'check_current': lambda *args: (_ for _ in ()).throw(ValueError('mirror mismatch'))}): + with self.assertRaises(ValueError): + module.tag_context(TAG) + with patch.dict(module.VALIDATION, {'git': lambda *args: COMMIT if args == ('rev-parse', 'HEAD') else 'b' * 40}): + with self.assertRaises(ValueError): + module.tag_context(TAG) + def test_changelog(self): text = '## [Unreleased]\n\n- Next\n\n## [2.3.0] - 2026-11-04\n\n### Added\n- Feature\n\n## [2.2.0]\n- Old\n' self.assertEqual(module.release_notes(text, TAG), '### Added\n- Feature') From e66789a8cc2e6070ef2ca0bb79c0eec5f3a4456c Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 5 Nov 2026 14:55:00 +0100 Subject: [PATCH 3/4] feat(release): expose GitHub-only publisher workflow --- .github/workflows/release-github.yml | 38 ++++++++++++++++++++++++++++ CHANGELOG.md | 4 +++ README.md | 17 +++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 .github/workflows/release-github.yml diff --git a/.github/workflows/release-github.yml b/.github/workflows/release-github.yml new file mode 100644 index 0000000..addeb0a --- /dev/null +++ b/.github/workflows/release-github.yml @@ -0,0 +1,38 @@ +name: Publish GitHub-only release + +on: + workflow_call: + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Require a tag push + env: + EVENT: ${{ github.event_name }} + REF_TYPE: ${{ github.ref_type }} + DELETED: ${{ github.event.deleted }} + run: | + test "$EVENT" = push + test "$REF_TYPE" = tag + test "$DELETED" = false + - uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + path: caller + - uses: actions/checkout@v5 + with: + repository: BehindTheMusicTree/github-workflows + ref: 8ae00ab34e74ffe16977cf28bdc93ecd6724ed6d + persist-credentials: false + path: foundation + - name: Validate and publish the existing tag + working-directory: caller + env: + GH_TOKEN: ${{ github.token }} + run: python3 ../foundation/release/github-release.py publish diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c0d2f..26b6dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,10 @@ All contributors (including maintainers) should update `CHANGELOG.md` when creat ### Added +- Share GitHub-only release publication with tag provenance, changelog notes and fail-closed retries; package publishers can extract validated notes without moving their OIDC jobs. + +### Added + - Add a reusable release validator and atomic App-based annotated tagging workflow, with explicit version contracts, migration safeguards and retry tests. ## [5.0.1] - 2026-11-02 diff --git a/README.md b/README.md index d798e44..34d49de 100644 --- a/README.md +++ b/README.md @@ -414,3 +414,20 @@ Each run creates an annotated tag object, then atomically creates its ref. An ex `outputs` records the publisher contract; this tagger does not assert that those artifacts are published. For a partial publication, rerun the existing tag publisher after checking each required artifact. In a multi-package project the publisher must skip an already published identical version and distinguish registry errors from an absent package. GitHub Release creation must have one owner and wait for every required package. Do not replace a missing publisher run with an arbitrary workflow dispatch. Run `python3 -m unittest discover -s release -p 'test_*.py'`, `bash .github/scripts/tests/release.sh`, `shellcheck .github/scripts/release.sh .github/scripts/tests/release.sh`, and `actionlint`. Tests simulate tagging and API errors without credentials or remote writes. Python 3.11+ is required. Repository-specific publisher recovery and a live App run are adoption checks, beyond the local foundation tests. + +### GitHub-only publisher and package recovery + +Repositories whose policy declares `github_release: true` and an empty `packages` list can +call `.github/workflows/release-github.yml` from their tag workflow. Pin the caller to a full +merged SHA, grant `contents: write`, and keep repository-specific validation jobs before it +with `needs`. The publisher checks the exact dereferenced tag, main ancestry, canonical +version, mirrors and populated changelog, then uses the ordinary workflow token to create +one stable GitHub Release. It never creates the release tag. API errors fail; only a 404 +means absent. A concurrent 422 is accepted only after reading a published stable release. + +A package publisher keeps its current workflow file and OIDC identity. After checking out +this foundation at an immutable SHA, it can run `release/github-release.py notes OUTPUT` +from its own checkout to validate provenance and extract notes before publishing packages. +Its existing GitHub Release owner publishes those notes after every required artifact +succeeds. The standalone publisher refuses policies listing packages so it cannot bypass +that ordering. Neither entry point receives the release App key. From 98ba53e558128faad655abcb8e8e83994a7a1b30 Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 5 Nov 2026 14:56:17 +0100 Subject: [PATCH 4/4] feat(release): verify npm artifacts before partial-publication retry --- .github/workflows/test-actions.yml | 2 ++ CHANGELOG.md | 1 + README.md | 10 ++++++++ release/npm-artifact.cjs | 30 +++++++++++++++++++++++ release/test_npm_artifact.cjs | 39 ++++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+) create mode 100644 release/npm-artifact.cjs create mode 100644 release/test_npm_artifact.cjs diff --git a/.github/workflows/test-actions.yml b/.github/workflows/test-actions.yml index c6d0b1c..f3cef20 100644 --- a/.github/workflows/test-actions.yml +++ b/.github/workflows/test-actions.yml @@ -58,5 +58,7 @@ jobs: - uses: actions/checkout@v5 - name: Verify validation, collisions and retries without remote writes run: python3 -m unittest discover -s release -p 'test_*.py' + - name: Verify npm partial-publication recovery with a simulated registry + run: node --test release/test_npm_artifact.cjs - name: Lint release shell scripts run: shellcheck .github/scripts/release.sh .github/scripts/tests/release.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 26b6dae..0910976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ All contributors (including maintainers) should update `CHANGELOG.md` when creat ### Added +- Verify existing npm artifacts by name, version and archive integrity when resuming partial multi-package publications. - Share GitHub-only release publication with tag provenance, changelog notes and fail-closed retries; package publishers can extract validated notes without moving their OIDC jobs. ### Added diff --git a/README.md b/README.md index 34d49de..2004eb8 100644 --- a/README.md +++ b/README.md @@ -431,3 +431,13 @@ from its own checkout to validate provenance and extract notes before publishing Its existing GitHub Release owner publishes those notes after every required artifact succeeds. The standalone publisher refuses policies listing packages so it cannot bypass that ordering. Neither entry point receives the release App key. + +For npm recovery, invoke `node PATH/TO/foundation/release/npm-artifact.cjs PACKAGE VERSION +ARCHIVE` in a step with an `id`, then publish only when `steps.ID.outputs.exists != 'true'`. +The helper uses npmjs.org, requires stable SemVer, and accepts an existing artifact only +when its name, version and SHA-512 archive integrity match. Every HTTP error except 404, +invalid JSON and network failures stop the job. Run this separately for each package in +publication order, then create the GitHub Release only after all packages succeed. A +changed build environment or non-reproducible archive may require investigation; an +existing version alone is never treated as success. Keep this check in the existing +publisher job and retain its npm OIDC configuration and runtime. diff --git a/release/npm-artifact.cjs b/release/npm-artifact.cjs new file mode 100644 index 0000000..57a2eb9 --- /dev/null +++ b/release/npm-artifact.cjs @@ -0,0 +1,30 @@ +const crypto = require('node:crypto'); +const fs = require('node:fs'); + +async function artifactExists(name, version, archive, fetchImpl = fetch) { + if (!/^(@[a-z0-9_-]+\/)?[a-z0-9_.-]+$/.test(name) || + !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) { + throw new Error('Expected an npm package name and stable SemVer version'); + } + const integrity = `sha512-${crypto.createHash('sha512').update(fs.readFileSync(archive)).digest('base64')}`; + const response = await fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(name)}/${version}`, + { signal: AbortSignal.timeout(30000) }); + if (response.status === 404) return false; + if (!response.ok) throw new Error(`npm registry lookup failed: HTTP ${response.status}`); + const metadata = await response.json(); + if (metadata.name !== name || metadata.version !== version || metadata.dist?.integrity !== integrity) { + throw new Error('Existing npm artifact differs in name, version or integrity; investigate without moving the tag'); + } + return true; +} + +module.exports = { artifactExists }; +if (require.main === module) { + (async () => { + if (process.argv.length !== 5 || !process.env.GITHUB_OUTPUT) { + throw new Error('Usage: npm-artifact.cjs PACKAGE VERSION ARCHIVE; GITHUB_OUTPUT is required'); + } + const exists = await artifactExists(...process.argv.slice(2)); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `exists=${exists}\n`); + })().catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; }); +} diff --git a/release/test_npm_artifact.cjs b/release/test_npm_artifact.cjs new file mode 100644 index 0000000..1956fd4 --- /dev/null +++ b/release/test_npm_artifact.cjs @@ -0,0 +1,39 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { artifactExists } = require('./npm-artifact.cjs'); + +test('two-package partial publication accepts the matching first artifact and leaves the second to publish', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-recovery-')); + const archive = path.join(dir, 'package.tgz'); + fs.writeFileSync(archive, 'first'); + const integrity = `sha512-${crypto.createHash('sha512').update('first').digest('base64')}`; + const metadata = { name: '@org/first', version: '1.2.3', dist: { integrity } }; + const response = (status, body = metadata) => async () => ({ status, ok: status === 200, json: async () => body }); + try { + const registry = async url => { + if (url === 'https://registry.npmjs.org/%40org%2Ffirst/1.2.3') return response(200)(); + assert.equal(url, 'https://registry.npmjs.org/%40org%2Fsecond/1.2.3'); + return response(404)(); + }; + assert.equal(await artifactExists('@org/first', '1.2.3', archive, registry), true); + assert.equal(await artifactExists('@org/second', '1.2.3', archive, registry), false); + for (const status of [401, 403, 429, 500]) { + await assert.rejects(artifactExists('@org/first', '1.2.3', archive, response(status))); + } + for (const body of [{ ...metadata, name: 'wrong' }, { ...metadata, version: '1.2.4' }, { ...metadata, dist: {} }]) { + await assert.rejects(artifactExists('@org/first', '1.2.3', archive, response(200, body))); + } + fs.writeFileSync(archive, 'different archive'); + await assert.rejects(artifactExists('@org/first', '1.2.3', archive, response(200))); + await assert.rejects(artifactExists('@org/first', '1.2.3', archive, async () => { throw new Error('network'); })); + await assert.rejects(artifactExists('@org/first', '1.2.3', archive, async () => ({ status: 200, ok: true, json: async () => { throw new Error('invalid JSON'); } }))); + await assert.rejects(artifactExists('https://bad', '1.2.3', archive, registry)); + await assert.rejects(artifactExists('@org/first', 'v1.2.3', archive, registry)); + } finally { + fs.rmSync(dir, { recursive: true }); + } +});