Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/release-github.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .github/workflows/test-actions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ 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

- 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
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,3 +414,30 @@ 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.

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.
107 changes: 107 additions & 0 deletions release/github-release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/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'\](?:[^\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


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)
30 changes: 30 additions & 0 deletions release/npm-artifact.cjs
Original file line number Diff line number Diff line change
@@ -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; });
}
96 changes: 96 additions & 0 deletions release/test_github_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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')
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_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')
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()
39 changes: 39 additions & 0 deletions release/test_npm_artifact.cjs
Original file line number Diff line number Diff line change
@@ -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 });
}
});
Loading