Skip to content
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

Feat/automate release #41

Merged
merged 9 commits into from
Oct 17, 2022
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

## v0.6.1 (draft)

- added `pangraph version` command that prints PanGraph's version on stderr.
- added `pangraph version` command that prints PanGraph's version on stderr, by @mmolari and @ivan-aksamentov, see [#40](https://github.com/neherlab/pangraph/pull/40).
- fix: wrong PanGraph's package version tag in `Project.toml`.
- added `tools/release.sh` script to automate the release process, see [#41](https://github.com/neherlab/pangraph/pull/41).

## v0.6.0

Expand Down
27 changes: 17 additions & 10 deletions docs/src/dev/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,29 @@

### Releasing a new version

Continuous integration (CI) will build a new version of the Docker container (see `Dockerfile`) on every pushed git tag.

Make sure you are on a correct branch and commit. Most of the time you want to release code from `master`:
To release a new version, run the provided bash script from the master branch of the repository:

```bash
git checkout master
bash tools/release.sh $RELEASE_VERSION
```

In order to create and push a git tag, run:
where `$RELEASE_VERSION` is a valid [semantic version](https://semver.org/), without a `v` prefix (i.e. `1.2.3` is correct, `v1.2.3` is not).

```
git tag $RELEASE_VERSION
git push origin --tags
```
The script will:
- check that the user is on the `master` branch.
- check that there are no uncommitted changes.
- check that the desired version is not already present in the repo.
- check that the version argument matches the expected "X.Y.Z".
- check that the version matches the version reported in `Project.toml`
- check that `CHANGELOG.md` has an entry for the release version, and use it to generate release notes.

where `$RELEASE_VERSION` is a valid [semantic version](https://semver.org/), without a `v` prefix (i.e. `1.2.3` is correct, `v1.2.3` is not).
If these conditions are met, then the script will create a release draft. This draft can be inspected on github (see [releases](https://github.com/neherlab/pangraph/releases)) and approved.

The script requires a working installation of python3 and `gh` (instructions for [installation on linux](https://github.com/cli/cli/blob/trunk/docs/install_linux.md)).

### CI for new releases

Continuous integration (CI) will build a new version of the Docker container (see `Dockerfile`) on every pushed git tag (or every commit on an open pull request).

The CI workflow will build the container image and will push it to Docker Hub. The image will be tagged with:

Expand Down
35 changes: 35 additions & 0 deletions tools/extract_release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env python3

# pylint: disable=redefined-outer-name,invalid-name,missing-module-docstring,missing-function-docstring,no-else-return

import argparse
import sys


def find_release_notes(input_changelog_md: str):
release_notes = ""
found_release_notes_block = False
with open(input_changelog_md) as f:
for line in f:
if not found_release_notes_block and line.startswith("## "):
found_release_notes_block = True
# release_notes += line
elif found_release_notes_block:
if line.startswith("## "):
return release_notes
else:
release_notes += line


if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Extracts last release notes section from a changelog markdown file"
)
parser.add_argument(
"input_changelog_md", type=str, help="Input changelog file in markdown format"
)
args = parser.parse_args()

release_notes = find_release_notes(args.input_changelog_md)

sys.stdout.write(release_notes)
81 changes: 81 additions & 0 deletions tools/release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/bin/bash

# Dependencies:
# - python3
# - gh (see this page for installation instructions on linux: https://github.com/cli/cli/blob/trunk/docs/install_linux.md )
#
# Usage example:
# bash release.sh 0.6.1

set -euo pipefail

# takes as argument the desired version
version=$1

# Directory where this script resides
THIS_DIR="$(
cd "$(dirname "${BASH_SOURCE[0]}")"
pwd
)"
CHANGELOG="$THIS_DIR/../CHANGELOG.md"
PROJECT="$THIS_DIR/../Project.toml"
REPO="neherlab/pangraph"

# check authentication
gh auth status >/dev/null

# check for uncommitted changes
if [ -n "$(git status --porcelain)" ]; then
echo "ERROR: there are uncommitted changes in the repo." >/dev/stderr
echo "Please commit before releasing a new version." >/dev/stderr
exit 1
fi

# check that the user is on master branch
curr_branch="$(cd ${THIS_DIR} && git branch --show-current)"
if [ "$curr_branch" != "master" ] ; then
echo "ERROR: repo is on branch ${curr_branch}." >/dev/stderr
echo "Releasing is only possible on master branch." >/dev/stderr
exit 1
fi

# check that release is not already present in the repo
if [ "release not found" != "$(gh release view "${version}" --repo "$REPO" 2>&1 || true)" ] ; then
echo "ERROR: desired release version ${version} is already present in the repo" >/dev/stderr
exit 1
fi

# check that the release version is well formatted, present in CHANGELOG.md and Project.toml file.
python3 "$THIS_DIR/release_checks.py" --release "$version" --changelog "$CHANGELOG" --project "$PROJECT"

# extract release notes from CHANGELOG.md and create a release draft
echo "Submitting new release:" >/dev/stderr
python3 "$THIS_DIR/extract_release_notes.py" "$CHANGELOG" |
gh release create \
"${version}" \
--repo "$REPO" \
--title "${version}" \
-d \
--notes-file -

# Looks like the release appears not immediately, and if an upload is following too quickly
# it fails because it cannot find the release. So let's wait for an arbitrary amount of
# time here until the release is live.
while [ "release not found" == "$(gh release view "${version}" --repo "$REPO" 2>&1 || true)" ]; do
echo "Waiting for release to go online"
sleep 2
done

# Check the release once again, in case of other errors
gh release view "${version}" --repo "$REPO" >/dev/null

echo "Draft release successfully submitted. Please review on GitHub and approve:"
echo " https://github.com/${REPO}/releases/tag/${version}"
echo ""
echo "Once approved, it will be visible externally, and the new git tag will be created, which should trigger CI build. You can track its progress at:"
echo " https://github.com//${REPO}/actions"
echo ""
echo "Once the CI build is done, please check that the new docker image tag '${version}' appears on DockerHub and that the 'latest' tag is updated and has the same digest:"
echo " https://hub.docker.com/r/${REPO}/tags"
echo ""
echo "If so, then the release is succesful and the new container image is available for end users."
100 changes: 100 additions & 0 deletions tools/release_checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env python3

# pylint: disable=redefined-outer-name,invalid-name,missing-module-docstring,missing-function-docstring,no-else-return

import argparse
import re


def parse_args():
"""Create argument parser and return parsed arguments"""
parser = argparse.ArgumentParser(
description="""
Script to perform preliminary checks on release versioning:
- check that release tag has the correct fom X.Y.Z
- check that changelog contains the corresponding entry.
- check that the Project.toml file contains the right version.
"""
)
parser.add_argument("--release", help="desired release version: X.X.X", type=str)
parser.add_argument("--changelog", help="changelog file", type=str)
parser.add_argument("--project", help="pangraph's Project.toml file", type=str)
return parser.parse_args()


def capture_first_changelog_entries(changelog_file):
"""Captures and returns the first two lines of the changelog that start with '## '"""

with open(changelog_file, "r") as f:
changelog = f.readlines()

# capture first two entries
versions = [l.strip() for l in changelog if l.startswith("## ")]
return versions[0], versions[1]


def check_newer_version(new_release, old_release):
"""Check the correct ordering of versions"""
x, y, z = map(int, new_release.split("."))
xo, yo, zo = map(int, old_release.split("."))

order = x > xo
order |= (x == xo) and (y > yo)
order |= (x == xo) and (y == yo) and (z > zo)

assert (
order
), f"new release version v{new_release} is not newer than previous release v{old_release}"


def capture_toml_version(project_file):
"""Capture line `version = "X.Y.Z"` in toml file and returns the "X.Y.Z" string"""

with open(project_file, "r") as f:
project = f.readlines()

versions = [l for l in project if l.startswith("version =")]
assert len(versions) == 1, "Missing or extra version line in Project.toml"
version = versions[0]

pattern = r'^version = "([\d]+\.[\d]+.[\d]+)"$'
m = re.match(pattern, version)
assert bool(
m
), 'version in Project.toml does have the expected pattern: `version = "X.Y.Z"`'
return m.group(1)


if __name__ == "__main__":

args = parse_args()
release = args.release

# 1) check that the version has the correct pattern
pattern = r"^[\d]+\.[\d]+\.[\d]+$"
match = re.match(pattern, release)
assert (
match
), f"specified release version {release} does not match expected pattern X.Y.Z"

# 2) check that changelog has the correct corresponding entry
# and that it is newer than the old one
next_version, prev_version = capture_first_changelog_entries(args.changelog)
# checks that the changelog has the release `## vX.Y.Z` entry
assert (
next_version == f"## v{release}"
), f"""
First entry in changelog: '{next_version}'
does not correspond to expected: '## v{release}'
"""
# checks that this entry is newer than the previous one.
check_newer_version(release, prev_version[len("## v") :])

# 3) check that the Project.toml file has the correct version
version = capture_toml_version(args.project)
assert (
version == release
), f"""
version in Project.toml does not correspond to the expected `version = "{release}"`.
Please modify it.
"""