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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/labeler.yml
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}'
88 changes: 88 additions & 0 deletions .github/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .github/package.json
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"
}
}
4 changes: 4 additions & 0 deletions .github/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
changelog:
exclude:
labels:
- omit-from-release-notes
90 changes: 90 additions & 0 deletions .github/scripts/release-notes-guard.cjs
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};
80 changes: 80 additions & 0 deletions .github/scripts/release_notes.py
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')
Comment thread
dkropachev marked this conversation as resolved.
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)
Loading
Loading