Repository navigation
ci: create GitHub releases with filtered release notes #1149
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dkropachev
wants to merge
13
commits into
master
Choose a base branch
from
ci/automatic-github-release-notes
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
10de02c
ci: publish GitHub releases after PyPI uploads
dkropachev 4f16139
ci: classify support-only PRs for release note omission
dkropachev 4b0af04
ci: skip release-note label events in the Jira caller
dkropachev 3bc2c90
ci: create GitHub releases only for tag publications
dkropachev cd9864f
ci: require default-branch release-note backfills
dkropachev d07d9cb
ci: require a tag selection for manual GitHub releases
dkropachev 61cefa8
ci: verify omission labels by logical GitHub identity
dkropachev 966c305
ci: reject duplicate files before creating release notes
dkropachev 5b14662
docs: limit release job retries to successful uploads
dkropachev bdd0361
docs: align the release tag push example
dkropachev 9babf5c
ci: confirm pull requests before omission-label cleanup
dkropachev fc021c6
ci: validate manual release tags before building
dkropachev e8c77f6
docs: fix the release notes heading underline
dkropachev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}' |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| { | ||
| "name": "python-driver-release-automation", | ||
| "private": true, | ||
| "dependencies": { | ||
| "js-yaml": "5.5.0", | ||
| "minimatch": "10.2.5" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| changelog: | ||
| exclude: | ||
| labels: | ||
| - omit-from-release-notes |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| // Copyright ScyllaDB, Inc. SPDX-License-Identifier: Apache-2.0 | ||
| const {createHash} = require('node:crypto'); | ||
| const {readFileSync} = require('node:fs'); | ||
| const {join} = require('node:path'); | ||
| const yaml = require('js-yaml'); | ||
| const {Minimatch} = require('minimatch'); | ||
|
|
||
| const label = 'omit-from-release-notes'; | ||
|
|
||
| function parseNumber(value) { | ||
| if (typeof value !== 'string' || !/^[1-9][0-9]*$/.test(value) || | ||
| !Number.isSafeInteger(Number(value))) { | ||
| throw new Error('Expected a canonical positive safe PR number'); | ||
| } | ||
| return Number(value); | ||
| } | ||
|
|
||
| function policy(source = readFileSync(join(__dirname, '../labeler.yml'), 'utf8')) { | ||
| const config = yaml.load(source); | ||
| const onlyKey = (object, key) => object && !Array.isArray(object) && | ||
| typeof object === 'object' && Object.keys(object).length === 1 && | ||
| Object.hasOwn(object, key); | ||
| const rules = config?.[label]; | ||
| const files = rules?.[0]?.['changed-files']; | ||
| const globs = files?.[0]?.['all-globs-to-all-files']; | ||
| if (!onlyKey(config, label) || !Array.isArray(rules) || rules.length !== 1 || | ||
| !onlyKey(rules[0], 'changed-files') || !Array.isArray(files) || files.length !== 1 || | ||
| !onlyKey(files[0], 'all-globs-to-all-files') || !Array.isArray(globs) || | ||
| globs.length === 0 || !globs.every(glob => typeof glob === 'string' && glob.length > 0)) { | ||
| throw new Error('Unsupported labeler configuration; update the guard before changing its shape'); | ||
| } | ||
| const matchers = globs.map(glob => new Minimatch(glob, {dot: true})); | ||
| return filename => matchers.every(matcher => matcher.match(filename)); | ||
| } | ||
|
|
||
| function revision(pr) { | ||
| if (!pr.head?.sha || !pr.base?.sha || !Number.isSafeInteger(pr.changed_files) || | ||
| pr.changed_files <= 0) throw new Error('Invalid PR revision or changed-file count'); | ||
| return {head: pr.head.sha, base: pr.base.sha, count: pr.changed_files}; | ||
| } | ||
|
|
||
| async function readRevision(github, params) { | ||
| return revision((await github.rest.pulls.get(params)).data); | ||
| } | ||
|
|
||
| async function snapshot(github, repo, number) { | ||
| const params = {...repo, pull_number: number}; | ||
| const before = await readRevision(github, params); | ||
| const files = await github.paginate(github.rest.pulls.listFiles, {...params, per_page: 100}); | ||
| const seen = new Set(); | ||
| const records = files.map(file => { | ||
| if (typeof file.filename !== 'string' || !file.filename || seen.has(file.filename) || | ||
| !['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged'].includes(file.status) || | ||
| (file.previous_filename !== undefined && | ||
| (typeof file.previous_filename !== 'string' || !file.previous_filename)) || | ||
| (file.status === 'renamed' && !file.previous_filename)) { | ||
| throw new Error('Incomplete or duplicate changed-file metadata'); | ||
| } | ||
| seen.add(file.filename); | ||
| return {filename: file.filename, status: file.status, previous_filename: file.previous_filename ?? null}; | ||
| }).sort((a, b) => a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0); | ||
| const after = await readRevision(github, params); | ||
| if (JSON.stringify(before) !== JSON.stringify(after)) throw new Error('PR revisions changed during file collection'); | ||
| if (records.length !== before.count) throw new Error('Changed-file listing does not match expected count'); | ||
| const matches = policy(); | ||
| const unsafeRename = records.some(file => file.previous_filename && !matches(file.previous_filename)); | ||
| const omit = !unsafeRename && records.every(file => matches(file.filename)); | ||
| const hash = createHash('sha256').update(JSON.stringify({...before, files: records})).digest('hex'); | ||
| return {...before, hash, omit, unsafeRename}; | ||
| } | ||
|
|
||
| async function verify(github, repo, number, expectedHash) { | ||
| const current = await snapshot(github, repo, number); | ||
| if (current.hash !== expectedHash) throw new Error('PR file snapshot changed since classification'); | ||
| const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, | ||
| {...repo, issue_number: number, per_page: 100}); | ||
| if (!labels.every(item => typeof item?.name === 'string' && item.name.length > 0)) { | ||
| throw new Error('Invalid label metadata'); | ||
| } | ||
| if (labels.some(item => item.name.toLowerCase() === label) !== current.omit) { | ||
| throw new Error('Actual omission label does not match verified file policy'); | ||
| } | ||
| const after = await readRevision(github, {...repo, pull_number: number}); | ||
| if (JSON.stringify(after) !== JSON.stringify({head: current.head, base: current.base, count: current.count})) { | ||
| throw new Error('PR revisions changed during label verification'); | ||
| } | ||
| return current; | ||
| } | ||
|
|
||
| module.exports = {parseNumber, policy, snapshot, verify}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.