diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000000..2e9e8e2da4 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,4 @@ +omit-from-release-notes: + - changed-files: + - all-globs-to-all-files: + - '{docs/**,tests/**,.github/ISSUE_TEMPLATE/**,.github/pull_request_template.md,.github/dependabot.yml,.github/labeler.yml,.github/release.yml,.github/workflows/{coverage,docs-pr,docs-pages,integration-tests,numpy-import-contract,release-automation-tests,label-release-notes,call_jira_sync}.yml,.github/scripts/test_*.py,.github/tests/**,.github/scripts/release-notes-guard.cjs,.github/package{,-lock}.json,README.rst,CONTRIBUTING.rst,.gitignore,.gitattributes,scripts/coverage.sh,scripts/check_numpy_imports.py}' diff --git a/.github/package-lock.json b/.github/package-lock.json new file mode 100644 index 0000000000..98e2ad825f --- /dev/null +++ b/.github/package-lock.json @@ -0,0 +1,88 @@ +{ + "name": "python-driver-release-automation", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "python-driver-release-automation", + "dependencies": { + "js-yaml": "5.5.0", + "minimatch": "10.2.5" + } + }, + "node_modules/argparse": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.4.tgz", + "integrity": "sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "PSF-2.0" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/js-yaml": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.5.0.tgz", + "integrity": "sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^3.0.3" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + } + } +} diff --git a/.github/package.json b/.github/package.json new file mode 100644 index 0000000000..3e381822ab --- /dev/null +++ b/.github/package.json @@ -0,0 +1,8 @@ +{ + "name": "python-driver-release-automation", + "private": true, + "dependencies": { + "js-yaml": "5.5.0", + "minimatch": "10.2.5" + } +} diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000000..c5a1495fe4 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,4 @@ +changelog: + exclude: + labels: + - omit-from-release-notes diff --git a/.github/scripts/release-notes-guard.cjs b/.github/scripts/release-notes-guard.cjs new file mode 100644 index 0000000000..2f5c209a9a --- /dev/null +++ b/.github/scripts/release-notes-guard.cjs @@ -0,0 +1,90 @@ +// Copyright ScyllaDB, Inc. SPDX-License-Identifier: Apache-2.0 +const {createHash} = require('node:crypto'); +const {readFileSync} = require('node:fs'); +const {join} = require('node:path'); +const yaml = require('js-yaml'); +const {Minimatch} = require('minimatch'); + +const label = 'omit-from-release-notes'; + +function parseNumber(value) { + if (typeof value !== 'string' || !/^[1-9][0-9]*$/.test(value) || + !Number.isSafeInteger(Number(value))) { + throw new Error('Expected a canonical positive safe PR number'); + } + return Number(value); +} + +function policy(source = readFileSync(join(__dirname, '../labeler.yml'), 'utf8')) { + const config = yaml.load(source); + const onlyKey = (object, key) => object && !Array.isArray(object) && + typeof object === 'object' && Object.keys(object).length === 1 && + Object.hasOwn(object, key); + const rules = config?.[label]; + const files = rules?.[0]?.['changed-files']; + const globs = files?.[0]?.['all-globs-to-all-files']; + if (!onlyKey(config, label) || !Array.isArray(rules) || rules.length !== 1 || + !onlyKey(rules[0], 'changed-files') || !Array.isArray(files) || files.length !== 1 || + !onlyKey(files[0], 'all-globs-to-all-files') || !Array.isArray(globs) || + globs.length === 0 || !globs.every(glob => typeof glob === 'string' && glob.length > 0)) { + throw new Error('Unsupported labeler configuration; update the guard before changing its shape'); + } + const matchers = globs.map(glob => new Minimatch(glob, {dot: true})); + return filename => matchers.every(matcher => matcher.match(filename)); +} + +function revision(pr) { + if (!pr.head?.sha || !pr.base?.sha || !Number.isSafeInteger(pr.changed_files) || + pr.changed_files <= 0) throw new Error('Invalid PR revision or changed-file count'); + return {head: pr.head.sha, base: pr.base.sha, count: pr.changed_files}; +} + +async function readRevision(github, params) { + return revision((await github.rest.pulls.get(params)).data); +} + +async function snapshot(github, repo, number) { + const params = {...repo, pull_number: number}; + const before = await readRevision(github, params); + const files = await github.paginate(github.rest.pulls.listFiles, {...params, per_page: 100}); + const seen = new Set(); + const records = files.map(file => { + if (typeof file.filename !== 'string' || !file.filename || seen.has(file.filename) || + !['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged'].includes(file.status) || + (file.previous_filename !== undefined && + (typeof file.previous_filename !== 'string' || !file.previous_filename)) || + (file.status === 'renamed' && !file.previous_filename)) { + throw new Error('Incomplete or duplicate changed-file metadata'); + } + seen.add(file.filename); + return {filename: file.filename, status: file.status, previous_filename: file.previous_filename ?? null}; + }).sort((a, b) => a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0); + const after = await readRevision(github, params); + if (JSON.stringify(before) !== JSON.stringify(after)) throw new Error('PR revisions changed during file collection'); + if (records.length !== before.count) throw new Error('Changed-file listing does not match expected count'); + const matches = policy(); + const unsafeRename = records.some(file => file.previous_filename && !matches(file.previous_filename)); + const omit = !unsafeRename && records.every(file => matches(file.filename)); + const hash = createHash('sha256').update(JSON.stringify({...before, files: records})).digest('hex'); + return {...before, hash, omit, unsafeRename}; +} + +async function verify(github, repo, number, expectedHash) { + const current = await snapshot(github, repo, number); + if (current.hash !== expectedHash) throw new Error('PR file snapshot changed since classification'); + const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, + {...repo, issue_number: number, per_page: 100}); + if (!labels.every(item => typeof item?.name === 'string' && item.name.length > 0)) { + throw new Error('Invalid label metadata'); + } + if (labels.some(item => item.name.toLowerCase() === label) !== current.omit) { + throw new Error('Actual omission label does not match verified file policy'); + } + const after = await readRevision(github, {...repo, pull_number: number}); + if (JSON.stringify(after) !== JSON.stringify({head: current.head, base: current.base, count: current.count})) { + throw new Error('PR revisions changed during label verification'); + } + return current; +} + +module.exports = {parseNumber, policy, snapshot, verify}; diff --git a/.github/scripts/release_notes.py b/.github/scripts/release_notes.py new file mode 100644 index 0000000000..16193f82fc --- /dev/null +++ b/.github/scripts/release_notes.py @@ -0,0 +1,80 @@ +"""Trusted GitHub release automation; no pull request code is executed.""" + +import argparse +import json +import os +import re +import urllib.error +import urllib.parse +import urllib.request + + +class GitHub: + def __init__(self): + self.base = os.environ['GITHUB_API_URL'].rstrip('/') + self.repo = os.environ['GITHUB_REPOSITORY'] + + def request(self, method, path, data=None): + request = urllib.request.Request( + f'{self.base}/repos/{self.repo}/{path}', + data=None if data is None else json.dumps(data).encode(), + headers={'Authorization': f"Bearer {os.environ['GH_TOKEN']}", + 'Accept': 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28'}, + method=method, + ) + with urllib.request.urlopen(request, timeout=60) as response: + body = response.read() + return json.loads(body) if body else None + + +def create_release(api, tag, source_sha): + if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+-scylla', tag): + raise ValueError('Release tag must have form X.Y.Z-scylla') + encoded = urllib.parse.quote(tag, safe='') + reference = api.request('GET', f'git/ref/tags/{encoded}') + target = reference['object'] + while target['type'] == 'tag': + target = api.request('GET', f"git/tags/{target['sha']}")['object'] + if target['type'] != 'commit' or target['sha'] != source_sha: + raise ValueError('Release tag does not match the built and published commit') + try: + release = api.request('GET', f'releases/tags/{encoded}') + except urllib.error.HTTPError as error: + if error.code != 404: + raise + # The tag endpoint does not find drafts with a pending tag. The list + # endpoint includes drafts for authenticated users with push access. + page = 1 + while True: + releases = api.request('GET', f'releases?per_page=100&page={page}') + release = next( + (item for item in releases if item['tag_name'] == tag), None, + ) + if release is not None or len(releases) < 100: + break + page += 1 + if release is not None: + if release['draft'] or not release['published_at']: + raise ValueError('Existing release is unpublished; publish it before rerunning') + print(f"Release already exists: {release['html_url']}") + return + if api.request('GET', f'git/ref/tags/{encoded}') != reference: + raise ValueError('Release tag changed during release creation') + release = api.request('POST', 'releases', { + 'tag_name': tag, 'name': f'Release {tag.removesuffix("-scylla")}', + 'generate_release_notes': True, + }) + print(release['html_url']) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest='command', required=True) + release_parser = subparsers.add_parser('release') + release_parser.add_argument('tag') + release_parser.add_argument('source_sha') + args = parser.parse_args() + api = GitHub() + create_release(api, args.tag, args.source_sha) diff --git a/.github/scripts/test_release_notes.py b/.github/scripts/test_release_notes.py new file mode 100644 index 0000000000..dab1d5e368 --- /dev/null +++ b/.github/scripts/test_release_notes.py @@ -0,0 +1,128 @@ +"""Offline regression checks for release notes automation.""" + +import unittest +import urllib.error +from unittest.mock import Mock + +from release_notes import create_release + + +class ReleaseTest(unittest.TestCase): + reference = {'object': {'type': 'commit', 'sha': 'published-sha'}} + + def test_existing_release_is_preserved(self): + api = Mock() + api.request.side_effect = [self.reference, { + 'html_url': 'existing', 'draft': False, + 'published_at': '2026-10-25T15:00:00Z', + }] + create_release(api, '3.29.12-scylla', 'published-sha') + self.assertEqual(api.request.call_count, 2) + api.request.assert_any_call('GET', 'git/ref/tags/3.29.12-scylla') + + def test_unpublished_existing_release_is_rejected(self): + for draft, published_at in ((True, None), (False, None), (True, 'date')): + with self.subTest(draft=draft, published_at=published_at): + api = Mock() + api.request.side_effect = [self.reference, { + 'html_url': 'unpublished', 'draft': draft, + 'published_at': published_at, + }] + with self.assertRaisesRegex(ValueError, 'unpublished'): + create_release(api, '3.29.12-scylla', 'published-sha') + self.assertEqual(api.request.call_count, 2) + + def test_new_release_uses_generated_notes(self): + api = Mock() + api.request.side_effect = [ + self.reference, urllib.error.HTTPError('', 404, 'missing', {}, None), + [], self.reference, {'html_url': 'created'}, + ] + create_release(api, '3.29.12-scylla', 'published-sha') + api.request.assert_called_with('POST', 'releases', { + 'tag_name': '3.29.12-scylla', 'name': 'Release 3.29.12', + 'generate_release_notes': True, + }) + + def test_pending_draft_is_found_after_published_lookup_misses(self): + api = Mock() + api.request.side_effect = [ + self.reference, urllib.error.HTTPError('', 404, 'missing', {}, None), + [{'tag_name': '3.29.12-scylla', 'draft': True, 'published_at': None}], + ] + with self.assertRaisesRegex(ValueError, 'unpublished'): + create_release(api, '3.29.12-scylla', 'published-sha') + api.request.assert_called_with('GET', 'releases?per_page=100&page=1') + self.assertEqual(api.request.call_count, 3) + + def test_release_discovery_reads_later_pages(self): + api = Mock() + api.request.side_effect = [ + self.reference, urllib.error.HTTPError('', 404, 'missing', {}, None), + [{'tag_name': f'other-{number}'} for number in range(100)], + [{'tag_name': '3.29.12-scylla', 'draft': False, + 'published_at': 'date', 'html_url': 'existing'}], + ] + create_release(api, '3.29.12-scylla', 'published-sha') + api.request.assert_called_with('GET', 'releases?per_page=100&page=2') + self.assertEqual(api.request.call_count, 4) + + def test_failed_draft_discovery_prevents_creation(self): + api = Mock() + api.request.side_effect = [ + self.reference, urllib.error.HTTPError('', 404, 'missing', {}, None), + urllib.error.HTTPError('', 403, 'denied', {}, None), + ] + with self.assertRaises(urllib.error.HTTPError): + create_release(api, '3.29.12-scylla', 'published-sha') + api.request.assert_called_with('GET', 'releases?per_page=100&page=1') + self.assertEqual(api.request.call_count, 3) + + def test_annotated_tag_is_peeled(self): + api = Mock() + api.request.side_effect = [ + {'object': {'type': 'tag', 'sha': 'annotation-sha'}}, + self.reference, { + 'html_url': 'existing', 'draft': False, + 'published_at': '2026-10-25T15:00:00Z', + }, + ] + create_release(api, '3.29.12-scylla', 'published-sha') + api.request.assert_any_call('GET', 'git/tags/annotation-sha') + + def test_wrong_or_moving_tag_prevents_release(self): + api = Mock() + api.request.return_value = self.reference + with self.assertRaises(ValueError): + create_release(api, '3.29.12-scylla', 'other-source-sha') + self.assertEqual(api.request.call_count, 1) + api.reset_mock() + api.request.side_effect = [ + self.reference, urllib.error.HTTPError('', 404, 'missing', {}, None), + [], {'object': {'type': 'commit', 'sha': 'moved-sha'}}, + ] + with self.assertRaises(ValueError): + create_release(api, '3.29.12-scylla', 'published-sha') + self.assertEqual(api.request.call_count, 4) + + def test_tag_and_api_failures_prevent_creation(self): + api = Mock() + for tag in ('master', '3.30.1', 'v3.29.12-scylla', '3.29.12-scylla-extra'): + with self.assertRaises(ValueError): + create_release(api, tag, 'published-sha') + api.request.assert_not_called() + api.request.side_effect = urllib.error.HTTPError('', 404, 'missing', {}, None) + with self.assertRaises(urllib.error.HTTPError): + create_release(api, '3.29.12-scylla', 'published-sha') + self.assertEqual(api.request.call_count, 1) + api.reset_mock() + api.request.side_effect = [ + self.reference, urllib.error.HTTPError('', 403, 'denied', {}, None), + ] + with self.assertRaises(urllib.error.HTTPError): + create_release(api, '3.29.12-scylla', 'published-sha') + self.assertEqual(api.request.call_count, 2) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/tests/release-notes-guard.test.cjs b/.github/tests/release-notes-guard.test.cjs new file mode 100644 index 0000000000..5979567141 --- /dev/null +++ b/.github/tests/release-notes-guard.test.cjs @@ -0,0 +1,412 @@ +// Copyright ScyllaDB, Inc. SPDX-License-Identifier: Apache-2.0 +const assert = require('node:assert/strict'); +const {readFileSync} = require('node:fs'); +const {join} = require('node:path'); +const {test} = require('node:test'); +const {spawnSync} = require('node:child_process'); +const yaml = require('js-yaml'); +const guard = require('../scripts/release-notes-guard.cjs'); + +const repo = {owner: 'scylladb', repo: 'python-driver'}; +const label = 'omit-from-release-notes'; +const file = (filename, extra = {}) => ({filename, status: 'modified', ...extra}); +const workflowText = readFileSync(join(__dirname, '../workflows/label-release-notes.yml'), 'utf8'); +const workflow = yaml.load(workflowText); +const steps = workflow.jobs.classify.steps; +const scripts = steps.filter(step => step.with?.script).map(step => step.with.script); +const collectScript = steps.find(step => step.id === 'guard').with.script; +const confirmScript = steps.find(step => step.id === 'target').with.script; +const verifyScript = steps.at(-1).with.script; +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + +function fixture(options = {}) { + const files = options.files ?? [file('README.rst')]; + const labels = new Set(options.labels ?? ['unrelated']); + const calls = []; + let gets = 0; + let listings = 0; + const initial = {number: 20, head: {sha: 'head'}, base: {sha: 'base'}, changed_files: files.length}; + const github = {rest: {pulls: { + get: async params => { + assert.deepEqual(params, {...repo, pull_number: 20}); + calls.push('get'); + gets++; + if (options.failGetAt === gets) throw new Error('PR lookup unavailable'); + return {data: {...initial, ...options.revisions?.[gets - 1]}}; + }, + listFiles: () => {} + }, issues: { + listLabelsOnIssue: () => {}, + removeLabel: async params => { + assert.deepEqual(params, {...repo, issue_number: 20, name: label}); + calls.push('remove'); + if (options.cleanupFailure) throw new Error('Removal unavailable'); + const actual = [...labels].find(name => name.toLowerCase() === label); + if (actual === undefined) throw {status: 404}; + labels.delete(actual); + } + }}, paginate: async (method, params) => { + assert.equal(params.per_page, 100); + if (method === github.rest.pulls.listFiles) { + assert.deepEqual(params, {...repo, pull_number: 20, per_page: 100}); + calls.push('files'); + if (options.fileFailure) throw new Error('File listing unavailable'); + return options.listings?.[listings++] ?? files; + } + assert.equal(method, github.rest.issues.listLabelsOnIssue); + assert.deepEqual(params, {...repo, issue_number: 20, per_page: 100}); + calls.push('labels'); + if (options.labelFailure) throw new Error('Labels unavailable'); + return options.labelRecords ?? [...labels].map(name => ({name})); + }}; + return {github, labels, calls}; +} + +async function adapter(f, hash, overrides = {}, load = () => guard) { + const env = {PR_NUMBER: '20', CONFIRMED_PR: '20', TARGET_OUTCOME: 'success', + CHECKOUT_OUTCOME: 'success', INSTALL_OUTCOME: 'success', + GUARD_OUTCOME: 'success', LABELER_OUTCOME: 'success', UNSAFE_RENAME: 'false', + SNAPSHOT_HASH: hash, ...overrides}; + const logs = []; + const run = new AsyncFunction('github', 'context', 'core', 'require', 'process', verifyScript); + let error; + try { + await run(f.github, {repo}, {info: text => logs.push(text), error: text => logs.push(text)}, + load, {env}); + } catch (caught) { error = caught; } + return {error, logs}; +} + +test('canonical policy matches mixed support files and keeps runtime or unknown paths visible', async () => { + const files = ['.github/workflows/coverage.yml', '.github/workflows/docs-pages.yml', + '.github/labeler.yml', '.github/release.yml', '.github/ISSUE_TEMPLATE/bug.md', + 'docs/a/b.md', 'docs/guide.rst', 'tests/unit/test_pool.py', 'README.rst', + 'CONTRIBUTING.rst', '.gitignore', '.gitattributes', 'scripts/coverage.sh', + 'scripts/check_numpy_imports.py'].map(name => file(name)); + assert.equal((await guard.snapshot(fixture({files}).github, repo, 20)).omit, true); + for (const name of ['cassandra/pool.py', 'pyproject.toml', 'uv.lock', 'setup.py', + 'Makefile', 'LICENSE', 'examples/basic.py', 'scripts/release.py', 'unknown.txt', + '.github/unknown.yml', '.github/workflows/lib-build.yml', + '.github/workflows/build-push.yml', '.github/workflows/publish-manually.yml', + '.github/workflows/github-release.yml']) { + const current = await guard.snapshot(fixture({files: [...files, file(name)]}).github, repo, 20); + assert.equal(current.omit, false, name); + } +}); + +test('renames use the actual canonical policy for both prior and current paths', async () => { + for (const [previous, omit] of [['docs/old.md', true], ['cassandra/pool.py', false], ['unknown', false]]) { + const current = await guard.snapshot(fixture({files: [file('docs/new.md', { + status: 'renamed', previous_filename: previous})]}).github, repo, 20); + assert.equal(current.omit, omit); + assert.equal(current.unsafeRename, !omit); + } +}); + +test('unsupported config shapes fail instead of approximating a second policy', () => { + for (const source of ['{}', 'omit-from-release-notes: []', + 'omit-from-release-notes: [{head-branch: [main]}]', + 'omit-from-release-notes: [{changed-files: [{any-glob-to-any-file: ["**"]}]}]', + 'omit-from-release-notes: [{changed-files: [{all-globs-to-all-files: []}]}]', + 'other-label: [{changed-files: [{all-globs-to-all-files: ["**"]}]}]']) { + assert.throws(() => guard.policy(source), /Unsupported labeler configuration/); + } +}); + +test('PR input rejects malformed, ambiguous and unsafe numbers', () => { + assert.equal(guard.parseNumber('20'), 20); + for (const value of ['', '0', '-1', '01', '1.0', '1e2', ' 20', '20\n', + '20,21', '${{ arbitrary }}', '9007199254740992', undefined, 20]) { + assert.throws(() => guard.parseNumber(value), /canonical positive safe PR number/); + } +}); + +test('pagination requests full metadata and accepts a complete listing beyond one page', async () => { + const f = fixture({files: Array.from({length: 105}, (_, i) => file(`docs/${i}.md`))}); + const snapshot = await guard.snapshot(f.github, repo, 20); + assert.equal(snapshot.count, 105); + assert.equal(snapshot.omit, true); + assert.match(snapshot.hash, /^[0-9a-f]{64}$/); + assert.deepEqual(f.calls, ['get', 'files', 'get']); +}); + +test('zero, incomplete, truncated, duplicate and invalid file evidence fail visibly', async () => { + const cases = [ + {files: []}, + {revisions: [{changed_files: 2}, {changed_files: 2}]}, + {files: [file('README.rst'), file('README.rst')]}, + {files: [file('README.rst', {status: 'renamed'})]}, + {files: [file('README.rst', {status: undefined})]}, + {files: [file('README.rst', {previous_filename: ''})]}, + {files: [file('README.rst')], listings: [[]]} + ]; + for (const options of cases) await assert.rejects(guard.snapshot(fixture(options).github, repo, 20)); +}); + +test('head, base and count changes during collection are rejected', async () => { + for (const changed of [{head: {sha: 'new'}}, {base: {sha: 'new'}}, {changed_files: 2}]) { + const f = fixture({revisions: [{}, changed]}); + await assert.rejects(guard.snapshot(f.github, repo, 20), /revisions changed/); + } +}); + +test('verification detects before/after revisions and filename, status or prior-path changes', async () => { + const original = await guard.snapshot(fixture().github, repo, 20); + for (const options of [ + {revisions: [{head: {sha: 'new'}}, {head: {sha: 'new'}}]}, + {revisions: [{base: {sha: 'new'}}, {base: {sha: 'new'}}]}, + {files: [file('docs/new.md')]}, + {files: [file('README.rst', {status: 'added'})]}, + {files: [file('README.rst', {status: 'renamed', previous_filename: 'docs/old.md'})]} + ]) { + await assert.rejects(guard.verify(fixture(options).github, repo, 20, original.hash), /snapshot changed/); + } + const f = fixture({labels: [label], revisions: [{}, {}, {head: {sha: 'new'}}]}); + await assert.rejects(guard.verify(f.github, repo, 20, original.hash), /revisions changed during label/); +}); + +test('lookup, listing and actual-label lookup failures propagate', async () => { + await assert.rejects(guard.snapshot(fixture({failGetAt: 1}).github, repo, 20), /lookup unavailable/); + await assert.rejects(guard.snapshot(fixture({failGetAt: 2}).github, repo, 20), /lookup unavailable/); + await assert.rejects(guard.snapshot(fixture({fileFailure: true}).github, repo, 20), /listing unavailable/); + const original = await guard.snapshot(fixture().github, repo, 20); + await assert.rejects(guard.verify(fixture({labelFailure: true}).github, repo, 20, original.hash), /Labels unavailable/); +}); + +test('actual-label verification catches stale state after internal action skip', async () => { + for (const [files, labels] of [[[file('README.rst')], []], [[file('cassandra/pool.py')], [label]]]) { + const f = fixture({files, labels: [...labels, 'unrelated']}); + const original = await guard.snapshot(f.github, repo, 20); + const {error} = await adapter(f, original.hash); + assert.match(error.message, /Actual omission label/); + assert.equal(f.labels.has(label), false); + assert.equal(f.labels.has('unrelated'), true); + assert.equal(f.calls.filter(call => call === 'remove').length, 1); + } +}); + +test('failed final snapshot, lookup or label retrieval attempts cleanup', async () => { + const original = await guard.snapshot(fixture().github, repo, 20); + for (const options of [{files: [file('docs/changed.md')]}, {failGetAt: 1}, + {fileFailure: true}, {labelFailure: true}]) { + const f = fixture({...options, labels: [label, 'unrelated']}); + const {error} = await adapter(f, original.hash); + assert.ok(error); + assert.deepEqual([...f.labels], ['unrelated']); + assert.equal(f.calls.filter(call => call === 'remove').length, 1); + } +}); + +test('successful verification checks actual labels and preserves unrelated labels', async () => { + for (const [files, labels] of [[[file('README.rst')], [label]], [[file('cassandra/pool.py')], []]]) { + const f = fixture({files, labels: [...labels, 'unrelated']}); + const original = await guard.snapshot(f.github, repo, 20); + const {error, logs} = await adapter(f, original.hash); + assert.ifError(error); + assert.match(logs[0], /Verified head=head base=base count=1 snapshot=.*actual label checked/); + assert.equal(f.labels.has('unrelated'), true); + assert.ok(!f.calls.includes('remove')); + } +}); + +test('actual omission identity preserves casing and rejects runtime exclusions', async () => { + const marker = 'Omit-from-release-notes'; + const distinct = ['unrelated', 'omit-from-release-notes-extra', 'prefix-omit-from-release-notes']; + const support = fixture({labels: [marker, ...distinct]}); + const supportSnapshot = await guard.snapshot(support.github, repo, 20); + const accepted = await adapter(support, supportSnapshot.hash); + assert.ifError(accepted.error); + assert.deepEqual([...support.labels], [marker, ...distinct]); + assert.ok(!support.calls.includes('remove')); + + const runtime = fixture({files: [file('cassandra/pool.py')], labels: [marker, ...distinct]}); + const runtimeSnapshot = await guard.snapshot(runtime.github, repo, 20); + const rejected = await adapter(runtime, runtimeSnapshot.hash); + assert.match(rejected.error.message, /Actual omission label/); + assert.deepEqual([...runtime.labels], distinct); + assert.equal(runtime.calls.filter(call => call === 'remove').length, 1); +}); + +test('malformed label records fail verification and attempt cleanup', async () => { + for (const labelRecords of [[null], [{}], [{name: null}], [{name: ''}]]) { + const f = fixture({labels: [label, 'unrelated'], labelRecords}); + const original = await guard.snapshot(f.github, repo, 20); + const result = await adapter(f, original.hash); + assert.match(result.error.message, /Invalid label metadata/); + assert.deepEqual([...f.labels], ['unrelated']); + } +}); + +test('intentional unsafe-rename skip removes omission label and verifies visible state', async () => { + const f = fixture({files: [file('docs/new.md', {status: 'renamed', previous_filename: 'cassandra/pool.py'})], + labels: [label, 'unrelated']}); + const original = await guard.snapshot(f.github, repo, 20); + const {error, logs} = await adapter(f, original.hash, {UNSAFE_RENAME: 'true', LABELER_OUTCOME: 'skipped'}); + assert.ifError(error); + assert.match(logs[0], /omit=false; actual label checked/); + assert.deepEqual([...f.labels], ['unrelated']); + assert.equal(f.calls.filter(call => call === 'remove').length, 1); +}); + +test('failed or skipped setup, guard and labeler clean up without requiring the helper', async () => { + for (const key of ['CHECKOUT_OUTCOME', 'INSTALL_OUTCOME', 'GUARD_OUTCOME', 'LABELER_OUTCOME']) { + for (const outcome of ['failure', 'skipped', 'cancelled', '']) { + const f = fixture({labels: [label, 'unrelated']}); + const {error} = await adapter(f, '', {[key]: outcome}, () => { throw new Error('Must not require helper'); }); + assert.match(error.message, /predecessor failed or unexpectedly skipped/); + assert.deepEqual([...f.labels], ['unrelated']); + assert.deepEqual(f.calls, ['remove']); + } + } + const f = fixture({labels: [label]}); + const {error} = await adapter(f, '', {}, () => { throw new Error('Helper missing'); }); + assert.match(error.message, /Helper missing/); + assert.deepEqual(f.calls, ['remove']); +}); + +test('cleanup API failures remain visible without losing the original failure or retrying', async () => { + const f = fixture({cleanupFailure: true}); + const result = await adapter(f, '', {INSTALL_OUTCOME: 'failure'}); + assert.match(result.error.message, /predecessor failed/); + assert.match(result.logs[0], /cleanup failed: Removal unavailable/); + assert.deepEqual(f.calls, ['remove']); + const unsafe = fixture({cleanupFailure: true}); + const result2 = await adapter(unsafe, '', {UNSAFE_RENAME: 'true', LABELER_OUTCOME: 'skipped'}); + assert.match(result2.error.message, /Removal unavailable/); + assert.deepEqual(unsafe.calls, ['remove']); +}); + +async function confirm(f, context = {repo, eventName: 'workflow_dispatch'}) { + const outputs = {}; + const run = new AsyncFunction('github', 'context', 'core', 'process', confirmScript); + let error; + try { + await run(f.github, context, {setOutput: (key, value) => {outputs[key] = value;}}, + {env: {PR_NUMBER: '20'}}); + } catch (caught) {error = caught;} + return {outputs, error}; +} + +test('target confirmation precedes checkout and accepts manual PR or trusted PR event identity', async () => { + const targetIndex = steps.findIndex(step => step.id === 'target'); + assert.ok(targetIndex > steps.findIndex(step => step.id === 'dispatch-ref')); + assert.ok(targetIndex < steps.findIndex(step => step.id === 'checkout')); + assert.ok(targetIndex < steps.findIndex(step => step.id === 'install')); + assert.equal(workflow.jobs.classify.env.PR_NUMBER, '${{ github.event.pull_request.number || inputs.pr_number }}'); + const manual = fixture({labels: [label, 'unrelated']}); + const first = await confirm(manual); + assert.ifError(first.error); + assert.deepEqual(first.outputs, {'confirmed-pr': '20'}); + assert.deepEqual(manual.calls, ['get']); + const snapshot = await guard.snapshot(manual.github, repo, 20); + const verified = await adapter(manual, snapshot.hash, {CONFIRMED_PR: first.outputs['confirmed-pr']}); + assert.ifError(verified.error); + assert.match(verified.logs[0], /omit=true; actual label checked/); + const event = fixture({failGetAt: 1}); + const second = await confirm(event, {repo, eventName: 'pull_request_target', + payload: {pull_request: {number: 20}}}); + assert.ifError(second.error); + assert.deepEqual(second.outputs, {'confirmed-pr': '20'}); + assert.deepEqual(event.calls, []); +}); + +test('ordinary issue, unavailable lookup and mismatched identity never enable label mutation', async () => { + for (const options of [{failGetAt: 1}, {revisions: [{number: 21}]}, {revisions: [{number: null}]}]) { + const f = fixture({...options, labels: [label, 'unrelated']}); + const target = await confirm(f); + assert.ok(target.error); + assert.deepEqual(target.outputs, {}); + const result = await adapter(f, '', {CONFIRMED_PR: '', TARGET_OUTCOME: 'failure'}, + () => {throw new Error('Must not load helper for unconfirmed target');}); + assert.match(result.error.message, /not confirmed as a pull request/); + assert.deepEqual([...f.labels], [label, 'unrelated']); + assert.ok(!f.calls.includes('remove')); + } + const issue = fixture({labels: [label, 'unrelated']}); + issue.github.rest.pulls.get = async () => {throw {status: 404};}; + const target = await confirm(issue); + assert.equal(target.error.status, 404); + const result = await adapter(issue, '', {CONFIRMED_PR: '', TARGET_OUTCOME: 'failure'}); + assert.match(result.error.message, /labels were not changed/); + assert.deepEqual([...issue.labels], [label, 'unrelated']); + assert.ok(!issue.calls.includes('remove')); +}); + +test('confirmed manual PR retains helper-independent cleanup after later failures', async () => { + for (const key of ['CHECKOUT_OUTCOME', 'INSTALL_OUTCOME', 'GUARD_OUTCOME', 'LABELER_OUTCOME']) { + const f = fixture({labels: [label, 'unrelated'], failGetAt: 2}); + const target = await confirm(f); + assert.ifError(target.error); + const result = await adapter(f, '', {CONFIRMED_PR: target.outputs['confirmed-pr'], [key]: 'failure'}, + () => {throw new Error('Must not need helper for predecessor failure');}); + assert.match(result.error.message, /predecessor failed/); + assert.deepEqual([...f.labels], ['unrelated']); + assert.deepEqual(f.calls, ['get', 'remove']); + } +}); + +test('trusted PR event retains cleanup without a later identity read', async () => { + const f = fixture({labels: [label, 'unrelated'], failGetAt: 1}); + const target = await confirm(f, {repo, eventName: 'pull_request_target', + payload: {pull_request: {number: 20}}}); + assert.ifError(target.error); + const result = await adapter(f, '', {CONFIRMED_PR: target.outputs['confirmed-pr'], GUARD_OUTCOME: 'failure'}, + () => {throw new Error('Must not load helper');}); + assert.match(result.error.message, /predecessor failed/); + assert.deepEqual(f.calls, ['remove']); + assert.deepEqual([...f.labels], ['unrelated']); +}); + +test('missing or different persisted target identity never enables cleanup', async () => { + for (const confirmed of ['', '21', 'true']) { + const f = fixture({labels: [label, 'unrelated']}); + const result = await adapter(f, '', {CONFIRMED_PR: confirmed, CHECKOUT_OUTCOME: 'failure'}); + assert.match(result.error.message, /not confirmed as a pull request/); + assert.deepEqual(f.calls, []); + assert.deepEqual([...f.labels], [label, 'unrelated']); + } + const finalizer = steps.at(-1); + assert.equal(finalizer.env.CONFIRMED_PR, '${{ steps.target.outputs.confirmed-pr }}'); + assert.equal(finalizer.env.TARGET_OUTCOME, '${{ steps.target.outcome }}'); +}); + +test('workflow adapters use environment input and emit bounded verified snapshot outputs', async () => { + const run = new AsyncFunction('github', 'context', 'core', 'require', 'process', collectScript); + const outputs = {}; + const f = fixture(); + await run(f.github, {repo}, {setOutput: (key, value) => { outputs[key] = value; }, info: () => {}}, + () => guard, {env: {PR_NUMBER: '20'}}); + assert.deepEqual(Object.keys(outputs), ['hash', 'unsafe-rename']); + assert.equal(outputs['unsafe-rename'], 'false'); + const invalid = await adapter(f, '', {PR_NUMBER: '01'}); + assert.match(invalid.error.message, /canonical positive safe PR number/); + assert.equal(steps.at(-1).if, 'always()'); + const checkout = steps.find(step => step.id === 'checkout'); + assert.equal(checkout.with.ref, '${{ github.sha }}'); + assert.equal(checkout.with['persist-credentials'], false); + assert.equal(steps.find(step => step.id === 'install').run, 'npm ci --ignore-scripts --prefix .github'); + const action = steps.find(step => step.id === 'labeler'); + assert.equal(action.if, "success() && steps.guard.outputs.unsafe-rename == 'false'"); + assert.equal(action.with['sync-labels'], true); + assert.equal(action.with['pr-number'], '${{ env.PR_NUMBER }}'); + for (const script of scripts) assert.ok(!script.includes('${{')); +}); + +test('manual backfills reject nondefault refs before loading helper code', () => { + const check = steps.find(step => step.id === 'dispatch-ref'); + assert.equal(check.if, "github.event_name == 'workflow_dispatch'"); + assert.equal(check.env.DEFAULT_BRANCH, '${{ github.event.repository.default_branch }}'); + assert.ok(steps.indexOf(check) < steps.findIndex(step => step.id === 'checkout')); + for (const [ref, branch, success] of [ + ['refs/heads/master', 'master', true], + ['refs/heads/feature', 'master', false], + ['refs/tags/v1.0.0', 'master', false], + ['refs/heads/master', '', false] + ]) { + const result = spawnSync('bash', ['-c', check.run], { + env: {...process.env, GITHUB_REF: ref, DEFAULT_BRANCH: branch}, encoding: 'utf8' + }); + assert.equal(result.status === 0, success, ref); + if (!success) assert.match(result.stderr, /default branch/); + } +}); diff --git a/.github/tests/release-publication.test.cjs b/.github/tests/release-publication.test.cjs new file mode 100644 index 0000000000..79af2ed699 --- /dev/null +++ b/.github/tests/release-publication.test.cjs @@ -0,0 +1,105 @@ +// Copyright ScyllaDB, Inc. SPDX-License-Identifier: Apache-2.0 +const assert = require('node:assert/strict'); +const {readFileSync} = require('node:fs'); +const {join} = require('node:path'); +const {test} = require('node:test'); +const {runInNewContext} = require('node:vm'); +const {spawnSync} = require('node:child_process'); +const yaml = require('js-yaml'); + +const load = name => yaml.load(readFileSync(join(__dirname, '../workflows', name), 'utf8')); +const automatic = load('build-push.yml'); +const manual = load('publish-manually.yml'); +const publisher = workflow => workflow.jobs.publish.steps.find(step => + step.uses?.startsWith('pypa/gh-action-pypi-publish@')); + +// Evaluate only the boolean expressions used by these caller workflows, not +// Actions scheduling or uploads. Ref types are normalized by GitHub; endsWith +// follows Actions' case-insensitive string comparison. +function expression(value, github, inputs = {}) { + if (typeof value === 'boolean') return value; + const source = value.replace(/^\$\{\{\s*|\s*\}\}$/g, ''); + return runInNewContext(source, { + github, inputs, + endsWith: (value, suffix) => String(value ?? '').toLowerCase().endsWith(suffix.toLowerCase()) + }); +} + +test('automatic release uploads reject existing files while branch uploads keep tolerance', () => { + for (const [ref_type, ref_name, upload, release] of [ + ['tag', '3.29.12-scylla', true, true], + ['branch', 'branch-feature-scylla', true, false], + ['branch', 'master', false, false] + ]) { + const github = {ref_type, ref_name, event: {ref: `refs/${ref_type}/${ref_name}`}}; + assert.equal(expression(automatic.jobs.publish.if, github), upload, ref_name); + assert.equal(expression(automatic.jobs['github-release'].if, github), release, ref_name); + assert.equal(expression(publisher(automatic).with['skip-existing'], github), !release, ref_name); + } +}); + +test('manual duplicate policy follows target selection and preserves branch and no-upload paths', () => { + for (const [ref_type, ref_name, target_tag, upload, release] of [ + ['branch', 'master', '3.29.12-scylla', true, true], + ['tag', '3.29.12-scylla', '', true, true], + ['branch', 'branch-feature-scylla', '', true, false], + ['branch', 'master', '', true, false], + ['tag', '3.29.12-scylla', 'other', true, false], + ['branch', 'master', '3.29.12-scylla', false, true] + ]) { + const github = {ref_type, ref_name}; + const inputs = {target_tag, upload}; + const scenario = JSON.stringify({github, inputs}); + assert.equal(expression(manual.jobs.publish.if, github, inputs), upload, scenario); + assert.equal(expression(manual.jobs['github-release'].if, github, inputs), release, scenario); + assert.equal(expression(publisher(manual).with['skip-existing'], github, inputs), !release, scenario); + } +}); + +test('new notes require successful publication and retain the pinned build source', () => { + for (const workflow of [automatic, manual]) { + const upload = workflow.jobs.publish; + const release = workflow.jobs['github-release']; + const action = publisher(workflow); + assert.equal(action.uses, + 'pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247'); + assert.equal(action['continue-on-error'] ?? false, false); + assert.equal(upload['continue-on-error'] ?? false, false); + assert.equal(upload.needs, 'build-and-publish'); + assert.deepEqual(release.needs, ['publish', 'build-and-publish']); + // Without a status function override, Actions applies success() to this + // condition: failed, cancelled or skipped prerequisites block the job. + assert.doesNotMatch(release.if, /\b(?:always|failure|cancelled|success)\s*\(/); + assert.equal(release.with.source_sha, '${{ needs.build-and-publish.outputs.source_sha }}'); + assert.equal(release.uses, './.github/workflows/github-release.yml'); + } +}); + +test('manual release uploads reject malformed selected tags before artifact builds', () => { + const validation = manual.jobs['validate-release-target']; + const step = validation.steps[0]; + assert.equal(manual.jobs['build-and-publish'].needs, 'validate-release-target'); + assert.deepEqual(validation.permissions, {}); + assert.equal(step.env.RELEASE_TAG, '${{ inputs.target_tag || github.ref_name }}'); + for (const [ref_type, ref_name, target_tag, upload, checked, accepted] of [ + ['branch', 'master', '3.29.12-scylla', true, true, true], + ['tag', '3.29.12-scylla', '', true, true, true], + ['branch', 'master', 'refs/tags/3.29.12-scylla', true, true, false], + ['branch', 'master', '3.29.0rc1-scylla', true, true, false], + ['branch', 'master', '3.29.0-SCYLLA', true, true, false], + ['branch', 'branch-feature-scylla', '', true, false, true], + ['branch', 'master', 'nonrelease', true, false, true], + ['branch', 'master', '3.29.0rc1-scylla', false, false, true] + ]) { + const scenario = JSON.stringify({ref_type, ref_name, target_tag, upload}); + assert.equal(expression(step.if, {ref_type, ref_name}, {target_tag, upload}), checked, scenario); + if (checked) { + const result = spawnSync('bash', ['-c', step.run], { + env: {...process.env, RELEASE_TAG: target_tag || ref_name}, + encoding: 'utf8', timeout: 5000 + }); + assert.equal(result.status === 0, accepted, scenario); + if (!accepted) assert.match(result.stderr, /Invalid release tag/); + } + } +}); diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 2a59525e46..bd0e78ed5d 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -5,10 +5,28 @@ on: branches: - master - 'branch-**' + tags: + - '[0-9]*.[0-9]*.[0-9]*-scylla' workflow_dispatch: +permissions: + contents: read + jobs: + validate-ref: + name: Validate release tag + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + steps: + - name: Require exact Scylla release tag + if: github.ref_type == 'tag' + env: + RELEASE_TAG: ${{ github.ref_name }} + run: python3 -c 'import os, re; assert re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+-scylla", os.environ["RELEASE_TAG"]), "Invalid release tag"' + build-and-publish: + needs: validate-ref name: "Build wheels" uses: ./.github/workflows/lib-build.yml @@ -32,4 +50,16 @@ jobs: - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 with: - skip-existing: true + # A release must follow a successful upload of this run's artifacts. + skip-existing: ${{ github.ref_type != 'tag' }} + + github-release: + name: Create GitHub release after PyPI publication + needs: [publish, build-and-publish] + if: github.ref_type == 'tag' + permissions: + contents: write + uses: ./.github/workflows/github-release.yml + with: + tag: ${{ github.ref_name }} + source_sha: ${{ needs.build-and-publish.outputs.source_sha }} diff --git a/.github/workflows/call_jira_sync.yml b/.github/workflows/call_jira_sync.yml index 7397f10cdc..c416566457 100644 --- a/.github/workflows/call_jira_sync.yml +++ b/.github/workflows/call_jira_sync.yml @@ -11,6 +11,9 @@ permissions: jobs: jira-sync: + if: >- + ${{ !((github.event.action == 'labeled' || github.event.action == 'unlabeled') + && github.event.label.name == 'omit-from-release-notes') }} uses: scylladb/github-automation/.github/workflows/main_pr_events_jira_sync.yml@47138e9130250ee1a35166cff7dd0e94c8897196 # main 2026-06-01 with: caller_action: ${{ github.event.action }} diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml new file mode 100644 index 0000000000..8581f9ea70 --- /dev/null +++ b/.github/workflows/github-release.yml @@ -0,0 +1,36 @@ +name: Create GitHub release + +on: + workflow_call: + inputs: + tag: + description: Successfully published Scylla driver tag + type: string + required: true + + source_sha: + description: Exact commit used for the published artifacts + type: string + required: true + +permissions: + contents: write + +concurrency: + group: github-release-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Verify tag and create release with filtered notes + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + SOURCE_SHA: ${{ inputs.source_sha }} + run: python3 .github/scripts/release_notes.py release "$RELEASE_TAG" "$SOURCE_SHA" diff --git a/.github/workflows/label-release-notes.yml b/.github/workflows/label-release-notes.yml new file mode 100644 index 0000000000..a9a597224d --- /dev/null +++ b/.github/workflows/label-release-notes.yml @@ -0,0 +1,138 @@ +name: Classify release notes + +on: + pull_request_target: + types: [opened, synchronize, reopened, edited, closed] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to classify, including merged PRs + required: true + type: string + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: release-notes-${{ github.event.pull_request.number || inputs.pr_number }} + cancel-in-progress: false + +jobs: + classify: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + steps: + - name: Require default-branch manual backfills + id: dispatch-ref + if: github.event_name == 'workflow_dispatch' + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [ -z "$DEFAULT_BRANCH" ] || [ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" ]; then + echo 'Run manual release-note classification from the default branch.' >&2 + exit 1 + fi + - name: Confirm pull request target + id: target + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const raw = process.env.PR_NUMBER; + if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(Number(raw))) { + throw new Error('Expected a canonical positive safe PR number'); + } + const number = Number(raw); + if (context.eventName !== 'pull_request_target' || + context.payload?.pull_request?.number !== number) { + const {data: pr} = await github.rest.pulls.get({ + ...context.repo, pull_number: number + }); + if (pr.number !== number) throw new Error('Target is not the requested pull request'); + } + core.setOutput('confirmed-pr', String(number)); + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + id: checkout + with: + ref: ${{ github.sha }} + persist-credentials: false + - run: npm ci --ignore-scripts --prefix .github + id: install + - name: Collect verified PR file snapshot + id: guard + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const guard = require('./.github/scripts/release-notes-guard.cjs'); + const number = guard.parseNumber(process.env.PR_NUMBER); + const snapshot = await guard.snapshot(github, context.repo, number); + core.setOutput('hash', snapshot.hash); + core.setOutput('unsafe-rename', String(snapshot.unsafeRename)); + core.info(`Collected head=${snapshot.head} base=${snapshot.base} count=${snapshot.count} snapshot=${snapshot.hash}`); + - uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 + id: labeler + if: success() && steps.guard.outputs.unsafe-rename == 'false' + with: + configuration-path: .github/labeler.yml + sync-labels: true + pr-number: ${{ env.PR_NUMBER }} + - name: Verify classification or attempt omission-label cleanup + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + CONFIRMED_PR: ${{ steps.target.outputs.confirmed-pr }} + TARGET_OUTCOME: ${{ steps.target.outcome }} + CHECKOUT_OUTCOME: ${{ steps.checkout.outcome }} + INSTALL_OUTCOME: ${{ steps.install.outcome }} + GUARD_OUTCOME: ${{ steps.guard.outcome }} + LABELER_OUTCOME: ${{ steps.labeler.outcome }} + SNAPSHOT_HASH: ${{ steps.guard.outputs.hash }} + UNSAFE_RENAME: ${{ steps.guard.outputs.unsafe-rename }} + with: + script: | + // Keep cleanup independent of checkout and installed dependencies. + const raw = process.env.PR_NUMBER; + if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(Number(raw))) { + throw new Error('Expected a canonical positive safe PR number'); + } + const number = Number(raw); + if (process.env.CONFIRMED_PR !== raw) { + throw new Error('Target was not confirmed as a pull request; labels were not changed'); + } + const removeLabel = async () => { + try { + await github.rest.issues.removeLabel({ + ...context.repo, issue_number: number, name: 'omit-from-release-notes' + }); + } catch (error) { + if (error.status !== 404) throw error; + } + }; + let removing = false; + try { + const unsafe = process.env.UNSAFE_RENAME === 'true'; + if ([process.env.TARGET_OUTCOME, process.env.CHECKOUT_OUTCOME, + process.env.INSTALL_OUTCOME, process.env.GUARD_OUTCOME].some(outcome => outcome !== 'success') || + !['true', 'false'].includes(process.env.UNSAFE_RENAME) || + process.env.LABELER_OUTCOME !== (unsafe ? 'skipped' : 'success')) { + throw new Error('Classification predecessor failed or unexpectedly skipped'); + } + if (unsafe) { + removing = true; + await removeLabel(); + removing = false; + } + const guard = require('./.github/scripts/release-notes-guard.cjs'); + const verified = await guard.verify(github, context.repo, number, process.env.SNAPSHOT_HASH); + core.info(`Verified head=${verified.head} base=${verified.base} count=${verified.count} snapshot=${verified.hash} omit=${verified.omit}; actual label checked`); + } catch (error) { + // A failed removal is already a visible cleanup attempt; do not retry it. + if (!removing) { + try { await removeLabel(); } + catch (cleanupError) { core.error(`Omission-label cleanup failed: ${cleanupError.message}`); } + } + throw error; + } diff --git a/.github/workflows/lib-build.yml b/.github/workflows/lib-build.yml index ca732780a4..eb03348132 100644 --- a/.github/workflows/lib-build.yml +++ b/.github/workflows/lib-build.yml @@ -33,7 +33,28 @@ on: required: false default: false + outputs: + source_sha: + description: Exact commit used for all published artifacts + value: ${{ jobs.resolve-source.outputs.sha }} + jobs: + resolve-source: + name: Pin build source + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + sha: ${{ steps.source.outputs.sha }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.target_tag || github.sha }} + persist-credentials: false + - name: Record immutable source commit + id: source + shell: bash + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + prepare-matrix: name: "Prepare matrix to run for ${{ inputs.python-version }} on `${{ inputs.target }}`" runs-on: ubuntu-24.04 @@ -78,7 +99,7 @@ jobs: name: Build wheels for ${{ matrix.target }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} timeout-minutes: 120 - needs: prepare-matrix + needs: [prepare-matrix, resolve-source] strategy: fail-fast: false matrix: @@ -87,7 +108,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - ref: ${{ inputs.target_tag || github.sha }} + ref: ${{ needs.resolve-source.outputs.sha }} - name: Set build concurrency to the runner's CPU count shell: bash @@ -192,13 +213,14 @@ jobs: path: ./wheelhouse/*.whl build-sdist: + needs: resolve-source name: Build source distribution runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - ref: ${{ inputs.target_tag || github.sha }} + ref: ${{ needs.resolve-source.outputs.sha }} - name: Install uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 diff --git a/.github/workflows/publish-manually.yml b/.github/workflows/publish-manually.yml index 4e32a10108..fbadc9bc6e 100644 --- a/.github/workflows/publish-manually.yml +++ b/.github/workflows/publish-manually.yml @@ -37,7 +37,20 @@ on: default: false jobs: + validate-release-target: + name: Validate selected release tag before publication + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + steps: + - name: Require exact Scylla release tag + if: inputs.upload && (inputs.target_tag != '' || github.ref_type == 'tag') && endsWith(inputs.target_tag || github.ref_name, '-scylla') + env: + RELEASE_TAG: ${{ inputs.target_tag || github.ref_name }} + run: python3 -c 'import os, re; assert re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+-scylla", os.environ["RELEASE_TAG"]), "Invalid release tag"' + build-and-publish: + needs: validate-release-target name: "Build wheels" uses: ./.github/workflows/lib-build.yml with: @@ -66,4 +79,16 @@ jobs: - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 with: - skip-existing: true + # Preserve branch uploads, but never reuse files for new release notes. + skip-existing: ${{ !((inputs.target_tag != '' || github.ref_type == 'tag') && endsWith(inputs.target_tag || github.ref_name, '-scylla')) }} + + github-release: + name: Create GitHub release after PyPI publication + needs: [publish, build-and-publish] + if: (inputs.target_tag != '' || github.ref_type == 'tag') && endsWith(inputs.target_tag || github.ref_name, '-scylla') + permissions: + contents: write + uses: ./.github/workflows/github-release.yml + with: + tag: ${{ inputs.target_tag || github.ref_name }} + source_sha: ${{ needs.build-and-publish.outputs.source_sha }} diff --git a/.github/workflows/release-automation-tests.yml b/.github/workflows/release-automation-tests.yml new file mode 100644 index 0000000000..a7480e02f4 --- /dev/null +++ b/.github/workflows/release-automation-tests.yml @@ -0,0 +1,45 @@ +name: Test release automation + +on: + pull_request: + paths: + - '.github/scripts/**' + - '.github/tests/**' + - '.github/package.json' + - '.github/package-lock.json' + - '.github/workflows/*release*' + - '.github/release.yml' + - '.github/labeler.yml' + - '.github/workflows/call_jira_sync.yml' + - '.github/workflows/build-push.yml' + - '.github/workflows/publish-manually.yml' + - '.github/workflows/lib-build.yml' + push: + branches: [master] + paths: + - '.github/scripts/**' + - '.github/tests/**' + - '.github/package.json' + - '.github/package-lock.json' + - '.github/workflows/*release*' + - '.github/release.yml' + - '.github/labeler.yml' + - '.github/workflows/call_jira_sync.yml' + - '.github/workflows/build-push.yml' + - '.github/workflows/publish-manually.yml' + - '.github/workflows/lib-build.yml' + +permissions: + contents: read + +jobs: + tests: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - run: python3 -m unittest discover -s .github/scripts -p 'test_*.py' -v + - run: npm ci --ignore-scripts --prefix .github + - run: node --test .github/tests/*.test.cjs diff --git a/.gitignore b/.gitignore index 35eccbe0d8..46949756f5 100644 --- a/.gitignore +++ b/.gitignore @@ -92,4 +92,6 @@ AGENTS.md claude.log claude_history.json claude_config.json -CLAUDE.md \ No newline at end of file +CLAUDE.md +# Release automation dependencies +/.github/node_modules/ diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 14847820f6..c5e651b272 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -168,3 +168,93 @@ To run the benchmarks, pick one of the files under the ``benchmarks/`` dir and r There are a few options. Use ``--help`` to see them all:: uv run benchmarks/future_batches.py --help + +GitHub releases and release notes +================================= + +Pushing a release tag of the form ``X.Y.Z-scylla`` builds and tests the wheels, +publishes them to PyPI, and then creates a GitHub release with generated notes. +Manual publication also creates the release when its selected ref is a release +tag. All artifacts use one pinned source commit, and release creation verifies +that the tag still resolves to that commit. Existing releases are preserved +on reruns, and matching unpublished releases must be published before retrying. +A failed build or PyPI publication prevents release creation; no new tags or +versions are generated. + +Uploads that can create a GitHub release fail if any distribution file already +exists on PyPI. This prevents a new build from skipping older files and creating +notes for a different source commit. Unrelated branch uploads retain duplicate +tolerance. Even a retry from the same source can stop after a partial or complete +prior upload. PyPI publication and GitHub release creation are not atomic. + +For recovery, inspect the original run's pinned source SHA, upload logs and +artifact inventory. Confirm that all intended files reached PyPI and the release +tag still identifies that original source. Only if the original ``publish`` job +succeeded and the GitHub-release job failed, retry that failed release job in +the original run, without rebuilding or rerunning the upload. If ``publish`` +failed or was cancelled, even if all files appear present, stop automated retries +and arrange separately verified recovery. Do the same for a partial upload, +unavailable evidence or a different tag. Do not move the tag, delete published +files or enable duplicate skipping to bypass the failure. This workflow does not +verify durable provenance across publication attempts. + +The ``Classify release notes`` workflow uses ``actions/labeler`` and the +filename rules in ``.github/labeler.yml`` to manage ``omit-from-release-notes``. +Documentation (``docs/``, ``README.rst``, ``CONTRIBUTING.rst``), tests +(``tests/``), the explicitly listed support files and workflows in ``.github/``, +``.gitignore``, ``.gitattributes``, and the coverage/import-check scripts qualify +when all changed files match this support-only union. The labeler configuration +is the source of the exact allowlist. Workflows that build or publish shipped +artifacts, production code, runtime dependencies, packaging/build manifests, +unknown paths, and mixed changes remain in the notes. Titles, authors and broad +``chore``/``dependencies`` labels do not determine omission. + +Labeling runs when a PR opens, changes, reopens, is edited or closes. +``sync-labels: true`` removes the omission label when filenames stop matching. +Trusted checks around labeling inspect rename origins and reject incomplete or +changing file listings. Failed or skipped classification attempts to remove +an unverified omission label without changing other labels. API outages can +prevent cleanup; before/after checks do not make classification atomic, and +cancellation can prevent the final cleanup step from running. The guard reads +the canonical labeler configuration using locked parser/matcher dependencies. +Unsupported configuration shapes fail visibly and require a guard update. +The workflow checks out only the trusted event revision, never the PR head. + +Before pushing a release tag or manually publishing, identify the merged PRs +included since the previous release. Run ``Classify release notes`` manually +with ``pr_number`` for each PR needing classification or backfilling, using a +current default branch that contains the guard. For example:: + + gh workflow run label-release-notes.yml --ref master -f pr_number=123 + +Manual runs selecting another branch or a tag are rejected before helper +checkout. This enforces the documented dispatch procedure; it does not secure +a modified workflow on an arbitrary selected branch. + +Wait for the final verification step, then compare its ``Verified`` log's +head/base SHAs, changed-file count and ``omit`` result with fresh PR metadata, +the complete file list including rename origins, and the actual labels:: + + gh api repos/scylladb/python-driver/pulls/123 --jq '{head: .head.sha, base: .base.sha, count: .changed_files}' + gh api --paginate repos/scylladb/python-driver/pulls/123/files --jq '.[] | {filename, status, previous_filename}' + gh api --paginate repos/scylladb/python-driver/issues/123/labels --jq '.[].name' + +Confirm the file count and unique names agree and that production, mixed, and +unverified changes have no omission label. Rerun classification after changed +revision evidence, failed or skipped runs, or label discrepancies, then repeat +verification. The release configuration excludes this label from generated +notes, but publication does not wait for classification automatically. Missing +this manual check can preserve omissions or unwanted support entries in the +published notes. Existing published notes are preserved on reruns; automated +release-time synchronization is outside this change. + +``omit-from-release-notes`` controls GitHub release-note presentation. The local +Jira caller skips add/remove events for this exact label and preserves other +events. Existing Jira copies are not cleaned, and other synchronization paths +may restore copied metadata. + +Run the focused automation checks with:: + + python3 -m unittest discover -s .github/scripts -p 'test_*.py' -v + npm ci --ignore-scripts --prefix .github + node --test .github/tests/*.test.cjs diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 8fc860ac4b..2c44cb2161 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -7,7 +7,10 @@ Releasing * For minor version releases (like ``3.26.9-scylla -> 3.27.0-scylla``) add new version to ``TAGS``, update ``LATEST_VERSION`` and add previous minor version to ``DEPRECATED_VERSIONS``. * Commit the version changes, e.g. ``git commit -m 'Release 3.26.9'`` * Tag the release. For example: ``git tag -a 3.26.9-scylla -m 'Release 3.26.9'`` -* Push the tag and new ``master`` SIMULTANEOUSLY: ``git push --atomic origin master v6.0.21-scylla`` +* Push the tag and new ``master`` SIMULTANEOUSLY: ``git push --atomic origin master 3.26.9-scylla`` * Now new version and its docs should be automatically published. Check `PyPI `_ and `docs `_ to make sure its there. -* If you didn't push branch and tag simultaneously (or doc publishing failed for other reason) then restart the relevant job from GitHub Actions UI. -* Publish a GitHub Release and a post on community forum. +* If documentation publishing failed, restart its relevant job from GitHub Actions UI. +* Release uploads fail when a distribution file already exists on PyPI, including retries after a partial or complete upload from the same source. A failed upload prevents new GitHub release notes. Unrelated branch uploads retain duplicate tolerance. +* Before recovering a failed release, inspect the original run's pinned source SHA, upload logs and artifact inventory. Confirm that every intended file reached PyPI and the tag still identifies that source. Only if the original ``publish`` job succeeded and the GitHub-release job failed, retry that failed release job in the original run; do not rebuild or rerun the upload. +* If ``publish`` failed or was cancelled, even if all files appear present, stop automated retries and arrange separately verified release recovery. Do the same for a partial upload, unavailable evidence or a changed tag. Do not move tags, delete published files or enable duplicate skipping to bypass the failure. Publication is not atomic across PyPI and GitHub, and the workflow does not verify durable provenance across attempts. See the release guidance in CONTRIBUTING.rst for the pre-release label check and recovery boundaries. +* Confirm the automatically created GitHub Release and publish a post on the community forum.