From 10de02ca6d364b0192188e91f3a27230615c72e5 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Sun, 25 Oct 2026 11:21:35 -0400 Subject: [PATCH 01/13] ci: publish GitHub releases after PyPI uploads Create GitHub releases after successful PyPI publication of Scylla version tags, including manual publication. Pin wheel and source builds to one commit and verify the release tag still resolves to that published source. Preserve existing published notes on reruns and reject matching drafts or unpublished releases, including drafts found through paginated discovery. Limit build-token access and cover release API behavior with offline tests. --- .github/scripts/release_notes.py | 80 +++++++++++ .github/scripts/test_release_notes.py | 128 ++++++++++++++++++ .github/workflows/build-push.yml | 28 ++++ .github/workflows/github-release.yml | 36 +++++ .github/workflows/lib-build.yml | 28 +++- .github/workflows/publish-manually.yml | 11 ++ .../workflows/release-automation-tests.yml | 31 +++++ CONTRIBUTING.rst | 16 +++ 8 files changed, 355 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/release_notes.py create mode 100644 .github/scripts/test_release_notes.py create mode 100644 .github/workflows/github-release.yml create mode 100644 .github/workflows/release-automation-tests.yml 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/workflows/build-push.yml b/.github/workflows/build-push.yml index 2a59525e46..a11e1338ba 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 @@ -33,3 +51,13 @@ jobs: - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 with: skip-existing: true + + github-release: + name: Create GitHub release after PyPI publication + needs: [publish, build-and-publish] + 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/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/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..283abc268d 100644 --- a/.github/workflows/publish-manually.yml +++ b/.github/workflows/publish-manually.yml @@ -67,3 +67,14 @@ jobs: - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 with: skip-existing: true + + github-release: + name: Create GitHub release after PyPI publication + needs: [publish, build-and-publish] + if: 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..5c6763bf2d --- /dev/null +++ b/.github/workflows/release-automation-tests.yml @@ -0,0 +1,31 @@ +name: Test release automation + +on: + pull_request: + paths: + - '.github/scripts/**' + - '.github/workflows/*release*' + - '.github/workflows/build-push.yml' + - '.github/workflows/publish-manually.yml' + - '.github/workflows/lib-build.yml' + push: + branches: [master] + paths: + - '.github/scripts/**' + - '.github/workflows/*release*' + - '.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 diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 14847820f6..5c2f48c7da 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -168,3 +168,19 @@ 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. + +Run the focused automation checks with:: + + python3 -m unittest discover -s .github/scripts -p 'test_*.py' -v From 4f161397ed5af0646c63949edffe5e24e76e433f Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Sun, 25 Oct 2026 12:37:06 -0400 Subject: [PATCH 02/13] ci: classify support-only PRs for release note omission Use pinned actions/labeler with one canonical support-only filename union and synchronized labels on PR updates, merge events and manual backfills. Keep runtime, build/package inputs, unknown paths and mixed changes visible. Check rename origins and complete, stable file evidence around labeling. Verify the actual label and attempt omission-label cleanup after failed or skipped classification without changing unrelated labels. Cover policy, revision, listing, label and cleanup behavior with offline regression tests. Document the required manual pre-release verification. Publication remains asynchronous and preserves existing notes; API outages or cancellation can prevent cleanup, and classification is not atomic. --- .github/labeler.yml | 4 + .github/package-lock.json | 88 ++++++ .github/package.json | 8 + .github/release.yml | 4 + .github/scripts/release-notes-guard.cjs | 87 ++++++ .github/tests/release-notes-guard.test.cjs | 263 ++++++++++++++++++ .github/workflows/label-release-notes.yml | 105 +++++++ .../workflows/release-automation-tests.yml | 12 + .gitignore | 4 +- CONTRIBUTING.rst | 48 ++++ 10 files changed, 622 insertions(+), 1 deletion(-) create mode 100644 .github/labeler.yml create mode 100644 .github/package-lock.json create mode 100644 .github/package.json create mode 100644 .github/release.yml create mode 100644 .github/scripts/release-notes-guard.cjs create mode 100644 .github/tests/release-notes-guard.test.cjs create mode 100644 .github/workflows/label-release-notes.yml 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..f45125cf43 --- /dev/null +++ b/.github/scripts/release-notes-guard.cjs @@ -0,0 +1,87 @@ +// 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.some(item => item.name === 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/tests/release-notes-guard.test.cjs b/.github/tests/release-notes-guard.test.cjs new file mode 100644 index 0000000000..0146c088e8 --- /dev/null +++ b/.github/tests/release-notes-guard.test.cjs @@ -0,0 +1,263 @@ +// 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 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 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 = {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'); + if (!labels.delete(label)) throw {status: 404}; + } + }}, 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 [...labels].map(name => ({name})); + }}; + return {github, labels, calls}; +} + +async function adapter(f, hash, overrides = {}, load = () => guard) { + const env = {PR_NUMBER: '20', 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', scripts[1]); + 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('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']); +}); + +test('workflow adapters use environment input and emit bounded verified snapshot outputs', async () => { + const run = new AsyncFunction('github', 'context', 'core', 'require', 'process', scripts[0]); + 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()'); + assert.equal(steps[0].with.ref, '${{ github.sha }}'); + assert.equal(steps[0].with['persist-credentials'], false); + assert.equal(steps[1].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('${{')); +}); diff --git a/.github/workflows/label-release-notes.yml b/.github/workflows/label-release-notes.yml new file mode 100644 index 0000000000..7dd86f1d90 --- /dev/null +++ b/.github/workflows/label-release-notes.yml @@ -0,0 +1,105 @@ +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: + - 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: + 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); + 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.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/release-automation-tests.yml b/.github/workflows/release-automation-tests.yml index 5c6763bf2d..398d7ca3b6 100644 --- a/.github/workflows/release-automation-tests.yml +++ b/.github/workflows/release-automation-tests.yml @@ -4,7 +4,12 @@ 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/build-push.yml' - '.github/workflows/publish-manually.yml' - '.github/workflows/lib-build.yml' @@ -12,7 +17,12 @@ on: 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/build-push.yml' - '.github/workflows/publish-manually.yml' - '.github/workflows/lib-build.yml' @@ -29,3 +39,5 @@ jobs: 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 5c2f48c7da..4a691bc03a 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -181,6 +181,54 @@ 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. +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 + +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. + 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 From 4b0af0496c135e02d6c38908e3ca10af13b3f645 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Sun, 25 Oct 2026 17:15:58 -0400 Subject: [PATCH 03/13] ci: skip release-note label events in the Jira caller The omission label controls GitHub release-note presentation. Exclude add and remove events for this exact label from the repository-local Jira caller while preserving every other event. Document that shared Jira automation and existing copies remain outside this change and other synchronization paths may restore copied metadata. --- .github/workflows/call_jira_sync.yml | 3 +++ .github/workflows/release-automation-tests.yml | 2 ++ CONTRIBUTING.rst | 5 +++++ 3 files changed, 10 insertions(+) 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/release-automation-tests.yml b/.github/workflows/release-automation-tests.yml index 398d7ca3b6..a7480e02f4 100644 --- a/.github/workflows/release-automation-tests.yml +++ b/.github/workflows/release-automation-tests.yml @@ -10,6 +10,7 @@ on: - '.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' @@ -23,6 +24,7 @@ on: - '.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' diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 4a691bc03a..5a12d684fe 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -227,6 +227,11 @@ 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 From 3bc2c90fa01767baeb63fd6a687c8eacc5435ff1 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Sun, 25 Oct 2026 19:12:04 -0400 Subject: [PATCH 04/13] ci: create GitHub releases only for tag publications Existing PyPI publication also accepts branch names ending in scylla. Keep those branch uploads from invoking a release helper that requires a version tag, while retaining successful publication as a prerequisite. --- .github/workflows/build-push.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index a11e1338ba..665fc9ea2b 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -55,6 +55,7 @@ jobs: 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 From cd9864fb0ba7e137540285f679f99a7073fb12b6 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Sun, 25 Oct 2026 19:29:34 -0400 Subject: [PATCH 05/13] ci: require default-branch release-note backfills Reject manual classification from another branch or a tag before loading helper code, matching the documented dispatch procedure. Keep PR target events and dependency-independent cleanup unchanged. Exercise the actual shell precondition for supported and rejected refs. --- .github/tests/release-notes-guard.test.cjs | 27 +++++++++++++++++++--- .github/workflows/label-release-notes.yml | 10 ++++++++ CONTRIBUTING.rst | 4 ++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/.github/tests/release-notes-guard.test.cjs b/.github/tests/release-notes-guard.test.cjs index 0146c088e8..af81257bc6 100644 --- a/.github/tests/release-notes-guard.test.cjs +++ b/.github/tests/release-notes-guard.test.cjs @@ -3,6 +3,7 @@ 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'); @@ -252,12 +253,32 @@ test('workflow adapters use environment input and emit bounded verified snapshot 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()'); - assert.equal(steps[0].with.ref, '${{ github.sha }}'); - assert.equal(steps[0].with['persist-credentials'], false); - assert.equal(steps[1].run, 'npm ci --ignore-scripts --prefix .github'); + 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/workflows/label-release-notes.yml b/.github/workflows/label-release-notes.yml index 7dd86f1d90..be00bb6f4b 100644 --- a/.github/workflows/label-release-notes.yml +++ b/.github/workflows/label-release-notes.yml @@ -26,6 +26,16 @@ jobs: 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 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 id: checkout with: diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 5a12d684fe..5ded16ac4f 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -210,6 +210,10 @@ 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:: From d07d9cb03815bc3999258b47f232add06ebba65b Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Sun, 25 Oct 2026 19:52:35 -0400 Subject: [PATCH 06/13] ci: require a tag selection for manual GitHub releases A dispatch branch ending in -scylla can upload successfully with no explicit target tag. Skip the new release step for that branch fallback while retaining explicit target tags and successful-upload dependencies. --- .github/workflows/publish-manually.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-manually.yml b/.github/workflows/publish-manually.yml index 283abc268d..2bdf04936c 100644 --- a/.github/workflows/publish-manually.yml +++ b/.github/workflows/publish-manually.yml @@ -71,7 +71,7 @@ jobs: github-release: name: Create GitHub release after PyPI publication needs: [publish, build-and-publish] - if: endsWith(inputs.target_tag || github.ref_name, '-scylla') + 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 From 61cefa82b257641b24cf76afec998cf37f933917 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Sun, 25 Oct 2026 19:52:45 -0400 Subject: [PATCH 07/13] ci: verify omission labels by logical GitHub identity GitHub preserves label-name casing while treating lookup identity without case sensitivity. Compare the complete marker name accordingly so runtime changes cannot pass verification with a differently capitalized exclusion. Reject malformed metadata and cover support acceptance, runtime cleanup, and preservation of distinct labels using the existing workflow adapter. --- .github/scripts/release-notes-guard.cjs | 5 +++- .github/tests/release-notes-guard.test.cjs | 34 ++++++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.github/scripts/release-notes-guard.cjs b/.github/scripts/release-notes-guard.cjs index f45125cf43..2f5c209a9a 100644 --- a/.github/scripts/release-notes-guard.cjs +++ b/.github/scripts/release-notes-guard.cjs @@ -74,7 +74,10 @@ async function verify(github, repo, number, expectedHash) { 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.some(item => item.name === label) !== current.omit) { + 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}); diff --git a/.github/tests/release-notes-guard.test.cjs b/.github/tests/release-notes-guard.test.cjs index af81257bc6..8134d0ac6a 100644 --- a/.github/tests/release-notes-guard.test.cjs +++ b/.github/tests/release-notes-guard.test.cjs @@ -38,7 +38,9 @@ function fixture(options = {}) { assert.deepEqual(params, {...repo, issue_number: 20, name: label}); calls.push('remove'); if (options.cleanupFailure) throw new Error('Removal unavailable'); - if (!labels.delete(label)) throw {status: 404}; + 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); @@ -52,7 +54,7 @@ function fixture(options = {}) { assert.deepEqual(params, {...repo, issue_number: 20, per_page: 100}); calls.push('labels'); if (options.labelFailure) throw new Error('Labels unavailable'); - return [...labels].map(name => ({name})); + return options.labelRecords ?? [...labels].map(name => ({name})); }}; return {github, labels, calls}; } @@ -203,6 +205,34 @@ test('successful verification checks actual labels and preserves unrelated label } }); +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']}); From 966c305d6d711ce46a0702449e6a817c3e7d2195 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Sun, 25 Oct 2026 21:32:14 -0400 Subject: [PATCH 08/13] ci: reject duplicate files before creating release notes A fresh publication can rebuild a moved version tag while skip-existing retains PyPI files from an earlier source. The new release would then associate notes with code absent from those distributions. Fail on duplicates for uploads that can create a GitHub release, retaining tolerance for unrelated branch uploads. Document the accepted restriction on partial and complete upload retries and recovery from the original run. Cover both callers' release selection and upload dependency in the existing automation tests. --- .github/tests/release-publication.test.cjs | 75 ++++++++++++++++++++++ .github/workflows/build-push.yml | 3 +- .github/workflows/publish-manually.yml | 3 +- CONTRIBUTING.rst | 16 +++++ MAINTENANCE.md | 7 +- 5 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 .github/tests/release-publication.test.cjs diff --git a/.github/tests/release-publication.test.cjs b/.github/tests/release-publication.test.cjs new file mode 100644 index 0000000000..12130eddfc --- /dev/null +++ b/.github/tests/release-publication.test.cjs @@ -0,0 +1,75 @@ +// 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 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'); + } +}); diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 665fc9ea2b..bd0e78ed5d 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -50,7 +50,8 @@ 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 diff --git a/.github/workflows/publish-manually.yml b/.github/workflows/publish-manually.yml index 2bdf04936c..1494e5cc80 100644 --- a/.github/workflows/publish-manually.yml +++ b/.github/workflows/publish-manually.yml @@ -66,7 +66,8 @@ 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 diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 5ded16ac4f..88209fff45 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -181,6 +181,22 @@ 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. After a confirmed complete upload, +retry only the failed GitHub-release job in that original run, without rebuilding +or rerunning the upload. If the upload was partial, evidence is unavailable or +the tag differs, stop automated retries and arrange a separately verified release +recovery. 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 diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 8fc860ac4b..035c482e92 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -9,5 +9,8 @@ Releasing * 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`` * 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. For a confirmed complete upload, retry only the failed GitHub-release job in that original run; do not rebuild or rerun the upload. +* For a partial upload, unavailable evidence or a changed tag, stop automated retries and arrange separately verified release recovery. 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. From 5b14662600567d2879e967afed710e4852ed203f Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Sun, 25 Oct 2026 22:17:28 -0400 Subject: [PATCH 09/13] docs: limit release job retries to successful uploads A complete PyPI file inventory does not mean the publication job succeeded. A failed upload can leave the dependent GitHub release job skipped, so there is no failed release job to retry in that run. Require successful publication and a failed release job for job-only retries. Direct failed or cancelled uploads to separately verified recovery even when all files appear present. --- CONTRIBUTING.rst | 15 ++++++++------- MAINTENANCE.md | 4 ++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 88209fff45..8f4ac71f95 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -189,13 +189,14 @@ 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. After a confirmed complete upload, -retry only the failed GitHub-release job in that original run, without rebuilding -or rerunning the upload. If the upload was partial, evidence is unavailable or -the tag differs, stop automated retries and arrange a separately verified release -recovery. 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. +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``. diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 035c482e92..d3cb572906 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -11,6 +11,6 @@ Releasing * Now new version and its docs should be automatically published. Check `PyPI `_ and `docs `_ to make sure its there. * 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. For a confirmed complete upload, retry only the failed GitHub-release job in that original run; do not rebuild or rerun the upload. -* For a partial upload, unavailable evidence or a changed tag, stop automated retries and arrange separately verified release recovery. 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. +* 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. From bdd03615b31f891b74988136249bdf6a35ce1e0a Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Sun, 25 Oct 2026 22:44:08 -0400 Subject: [PATCH 10/13] docs: align the release tag push example The maintenance guide creates 3.26.9-scylla but then pushes a different, v-prefixed tag. Push the tag from the preceding example so the documented atomic operation matches the supported release format. --- MAINTENANCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MAINTENANCE.md b/MAINTENANCE.md index d3cb572906..2c44cb2161 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -7,7 +7,7 @@ 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 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. From 9babf5c12662a9818691435de7c05be7a2e6247f Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Mon, 26 Oct 2026 00:45:16 -0400 Subject: [PATCH 11/13] ci: confirm pull requests before omission-label cleanup A manual backfill can name an ordinary issue. When the PR lookup fails, the always-run finalizer previously removed its omission label anyway. Confirm the exact PR before checkout and retain that number for the finalizer. Reject unconfirmed targets before any cleanup write while keeping confirmed-PR cleanup independent of later setup or read failures. Exercise the actual workflow scripts and confirmation handoff, including ordinary issues, mismatched identity and unavailable dependencies. --- .github/tests/release-notes-guard.test.cjs | 106 ++++++++++++++++++++- .github/workflows/label-release-notes.yml | 27 +++++- 2 files changed, 127 insertions(+), 6 deletions(-) diff --git a/.github/tests/release-notes-guard.test.cjs b/.github/tests/release-notes-guard.test.cjs index 8134d0ac6a..5979567141 100644 --- a/.github/tests/release-notes-guard.test.cjs +++ b/.github/tests/release-notes-guard.test.cjs @@ -14,6 +14,9 @@ const workflowText = readFileSync(join(__dirname, '../workflows/label-release-no 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 = {}) { @@ -22,7 +25,7 @@ function fixture(options = {}) { const calls = []; let gets = 0; let listings = 0; - const initial = {head: {sha: 'head'}, base: {sha: 'base'}, changed_files: files.length}; + 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}); @@ -60,11 +63,12 @@ function fixture(options = {}) { } async function adapter(f, hash, overrides = {}, load = () => guard) { - const env = {PR_NUMBER: '20', CHECKOUT_OUTCOME: 'success', INSTALL_OUTCOME: 'success', + 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', scripts[1]); + 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)}, @@ -272,8 +276,102 @@ test('cleanup API failures remain visible without losing the original failure or 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', scripts[0]); + 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: () => {}}, diff --git a/.github/workflows/label-release-notes.yml b/.github/workflows/label-release-notes.yml index be00bb6f4b..a9a597224d 100644 --- a/.github/workflows/label-release-notes.yml +++ b/.github/workflows/label-release-notes.yml @@ -36,6 +36,24 @@ jobs: 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: @@ -65,6 +83,8 @@ jobs: 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 }} @@ -79,6 +99,9 @@ jobs: 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({ @@ -91,8 +114,8 @@ jobs: let removing = false; try { const unsafe = process.env.UNSAFE_RENAME === 'true'; - if ([process.env.CHECKOUT_OUTCOME, process.env.INSTALL_OUTCOME, - process.env.GUARD_OUTCOME].some(outcome => outcome !== 'success') || + 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'); From fc021c6906fd50380d6f9271bbb6f65ee437d8ee Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Mon, 26 Oct 2026 14:15:59 -0400 Subject: [PATCH 12/13] ci: validate manual release tags before building Suffix matching can select a malformed manual ref for publication, then fail only after its files reach PyPI because the release helper requires an exact version tag. Validate selected release uploads before artifact builds using the same tag form as the automatic caller. Preserve unrelated branch uploads, explicit non-release targets and no-upload builds. Exercise the actual validation script, target handoff and prerequisite in the existing tests. --- .github/tests/release-publication.test.cjs | 30 ++++++++++++++++++++++ .github/workflows/publish-manually.yml | 13 ++++++++++ 2 files changed, 43 insertions(+) diff --git a/.github/tests/release-publication.test.cjs b/.github/tests/release-publication.test.cjs index 12130eddfc..79af2ed699 100644 --- a/.github/tests/release-publication.test.cjs +++ b/.github/tests/release-publication.test.cjs @@ -4,6 +4,7 @@ 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')); @@ -73,3 +74,32 @@ test('new notes require successful publication and retain the pinned build sourc 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/publish-manually.yml b/.github/workflows/publish-manually.yml index 1494e5cc80..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: From e8c77f6cdc6c121bd3e59e18148e377d9c6e6d4f Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Mon, 26 Oct 2026 14:15:59 -0400 Subject: [PATCH 13/13] docs: fix the release notes heading underline Match the underline to the 33-character section title so ReST does not report a short title underline. --- CONTRIBUTING.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 8f4ac71f95..c5e651b272 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -170,7 +170,7 @@ 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.