Skip to content

Commit 83edf3e

Browse files
committed
chore(release): add release script
Modify GitHub workflow to create a draft GitHub release, like ggshield. Update tests so that `scripts/release run-tests` pass: - Use only $GITGUARDIAN_API_KEY, not $TEST_LIVE_SERVER or $TEST_LIVE_SERVER_TOKEN - quota: do not check the exact value of `limit`: it can vary from account to account. Checking that it's more than 0 and that `content.count + content.remaining == content.limit` is enough. chore(release): use the same
1 parent 2be4f83 commit 83edf3e

File tree

4 files changed

+278
-14
lines changed

4 files changed

+278
-14
lines changed

.github/workflows/test-lint.yml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,20 +41,26 @@ jobs:
4141
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
4242
steps:
4343
- uses: actions/checkout@v3
44+
4445
- name: Set up Python ${{ matrix.python-version }}
4546
uses: actions/setup-python@v1
4647
with:
4748
python-version: ${{ matrix.python-version }}
49+
4850
- name: Install dependencies
4951
run: |
5052
python -m pip install --upgrade pip
5153
python -m pip install --upgrade pipenv==2022.10.4
5254
pipenv install --system --dev --skip-lock
55+
5356
- name: Test with pytest
5457
run: |
5558
pipenv run coverage run --source pygitguardian -m pytest --disable-pytest-warnings
5659
pipenv run coverage report --fail-under=80
5760
pipenv run coverage xml
61+
env:
62+
GITGUARDIAN_API_KEY: ${{ secrets.GITGUARDIAN_API_KEY }}
63+
5864
- uses: codecov/codecov-action@v1
5965
with:
6066
file: ./coverage.xml
@@ -70,21 +76,31 @@ jobs:
7076
- uses: actions/checkout@v3
7177
with:
7278
fetch-depth: 0
79+
7380
- name: Set up Python
7481
uses: actions/setup-python@v2
7582
with:
7683
python-version: '3.x'
84+
7785
- name: Install dependencies
7886
run: python -m pip install --upgrade pip setuptools wheel
7987
- name: Build distribution
8088
run: >-
8189
python setup.py sdist bdist_wheel
90+
8291
- name: Publish distribution 📦 to PyPI
8392
uses: pypa/gh-action-pypi-publish@master
8493
with:
8594
user: __token__
8695
password: ${{ secrets.pypi_password }}
87-
- name: Release Notary Action
88-
uses: docker://aevea/release-notary:0.9.1
96+
97+
- name: Create Release
98+
id: create_release
99+
uses: actions/create-release@master
89100
env:
90101
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
102+
with:
103+
tag_name: ${{ steps.tags.outputs.tag }}
104+
release_name: ${{ steps.tags.outputs.tag }}
105+
draft: true
106+
prerelease: false

scripts/release

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
#!/usr/bin/env python3
2+
"""
3+
A multi-command tool to automate steps of the release process
4+
"""
5+
import re
6+
import shutil
7+
import subprocess
8+
import sys
9+
from pathlib import Path
10+
from typing import Any, List, Union
11+
12+
import click
13+
14+
15+
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
16+
17+
18+
ROOT_DIR = Path(__file__).parent.parent
19+
CHANGELOG_PATH = ROOT_DIR / "CHANGELOG.md"
20+
INIT_PATH = ROOT_DIR / "pygitguardian" / "__init__.py"
21+
CASSETTES_DIR = ROOT_DIR / "tests" / "cassettes"
22+
23+
# The branch this script must be run from, except in dev mode.
24+
RELEASE_BRANCH = "master"
25+
26+
27+
def get_version() -> str:
28+
from pygitguardian import __version__
29+
30+
return __version__
31+
32+
33+
def get_tag(version: str) -> str:
34+
return f"v{version}"
35+
36+
37+
def check_run(
38+
cmd: List[Union[str, Path]], **kwargs: Any
39+
) -> subprocess.CompletedProcess:
40+
return subprocess.run(cmd, check=True, text=True, **kwargs)
41+
42+
43+
def log_progress(message: str) -> None:
44+
click.secho(message, fg="magenta", err=True)
45+
46+
47+
def log_error(message: str) -> None:
48+
prefix = click.style("ERROR:", fg="red")
49+
click.secho(f"{prefix} {message}", err=True)
50+
51+
52+
def fail(message: str) -> None:
53+
log_error(message)
54+
sys.exit(1)
55+
56+
57+
def check_working_tree_is_on_release_branch() -> bool:
58+
proc = check_run(["git", "branch"], capture_output=True)
59+
lines = proc.stdout.splitlines()
60+
if f"* {RELEASE_BRANCH}" not in lines:
61+
log_error(f"This script must be run on the '{RELEASE_BRANCH}' branch")
62+
return False
63+
return True
64+
65+
66+
def check_working_tree_is_clean() -> bool:
67+
proc = check_run(["git", "status", "--porcelain"], capture_output=True)
68+
lines = proc.stdout.splitlines()
69+
if lines:
70+
log_error("Working tree contains changes")
71+
return False
72+
return True
73+
74+
75+
@click.group(context_settings=CONTEXT_SETTINGS)
76+
@click.option(
77+
"--dev-mode",
78+
is_flag=True,
79+
help=f"Do not abort if the working tree contains changes or if we are not on the '{RELEASE_BRANCH}' branch",
80+
)
81+
def main(dev_mode: bool) -> int:
82+
"""Helper script to release py-gitguardian. Commands should be run in this order:
83+
84+
\b
85+
1. run-tests
86+
2. prepare
87+
3. tag
88+
4. publish-gh-release
89+
"""
90+
checks = (check_working_tree_is_on_release_branch, check_working_tree_is_clean)
91+
if not all(x() for x in checks):
92+
if dev_mode:
93+
log_progress("Ignoring errors because --dev-mode is set")
94+
else:
95+
fail("Use --dev-mode to ignore")
96+
97+
return 0
98+
99+
100+
@main.command()
101+
def run_tests() -> None:
102+
"""Run all tests.
103+
104+
Unit-tests are run without cassettes. This ensures the recorded cassettes
105+
still match production reality.
106+
"""
107+
108+
# If CASSETTES_DIR does not exist, tests fail, so recreate it
109+
log_progress("Removing cassettes")
110+
shutil.rmtree(CASSETTES_DIR)
111+
CASSETTES_DIR.mkdir()
112+
113+
log_progress("Running tests")
114+
check_run(["pytest", "tests"], cwd=ROOT_DIR)
115+
116+
log_progress("Restoring cassettes")
117+
check_run(["git", "restore", CASSETTES_DIR], cwd=ROOT_DIR)
118+
119+
120+
def replace_once_in_file(path: Path, src: str, dst: str, flags: int = 0) -> None:
121+
"""Look for `src` in `path`, replace it with `dst`. Abort if no match or more than
122+
one were found."""
123+
content = path.read_text()
124+
content, count = re.subn(src, dst, content, flags=flags)
125+
if count != 1:
126+
fail(
127+
f"Did not make any change to {path}: expected 1 match for '{src}', got {count}."
128+
)
129+
path.write_text(content)
130+
131+
132+
def check_version(version: str) -> None:
133+
# Check version is valid
134+
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
135+
fail(f"'{version}' is not a valid version number")
136+
137+
# Check version does not already exist
138+
tag = get_tag(version)
139+
proc = check_run(["git", "tag"], capture_output=True)
140+
tags = proc.stdout.splitlines()
141+
if tag in tags:
142+
fail(f"The {tag} tag already exists.")
143+
144+
145+
def update_version(version: str) -> None:
146+
replace_once_in_file(
147+
INIT_PATH,
148+
"^__version__ = .*$",
149+
f'__version__ = "{version}"',
150+
flags=re.MULTILINE,
151+
)
152+
153+
154+
def update_changelog() -> None:
155+
check_run(["scriv", "collect", "--edit"])
156+
# prettier and scriv disagree on some minor formatting issue.
157+
# Run prettier through pre-commit to fix the CHANGELOG.md.
158+
# Do not use `check_run()` here because if prettier reformats the file
159+
# (which it will), then the command exit code will be 1.
160+
subprocess.run(["pre-commit", "run", "prettier", "--files", CHANGELOG_PATH])
161+
162+
163+
def commit_changes(version: str) -> None:
164+
check_run(["git", "add", CHANGELOG_PATH, "changelog.d", INIT_PATH])
165+
message = f"chore(release): {version}"
166+
check_run(["git", "commit", "--message", message])
167+
168+
169+
@main.command()
170+
@click.argument("version")
171+
def prepare(version: str) -> None:
172+
"""Prepare the code for the release:
173+
174+
\b
175+
- Bump the version in __init__.py
176+
- Update the changelog
177+
- Commit changes
178+
"""
179+
check_version(version)
180+
update_version(version)
181+
update_changelog()
182+
commit_changes(version)
183+
log_progress(f"Done, review changes and then run `{sys.argv[0]} tag`")
184+
185+
186+
@main.command()
187+
def tag() -> None:
188+
"""Create the tag for the version, push the main branch and the tag."""
189+
version = get_version()
190+
tag = get_tag(version)
191+
message = f"Releasing {version}"
192+
check_run(["git", "tag", "--annotate", tag, "--message", message])
193+
check_run(["git", "push", "origin", "main:main", f"{tag}:{tag}"])
194+
195+
196+
def get_release_notes(version: str) -> str:
197+
"""Reads CHANGELOG.md, returns the changes for version `version`, formatted for
198+
`gh release`."""
199+
200+
# Extract changes from CHANGELOG.md
201+
changes = CHANGELOG_PATH.read_text()
202+
start_match = re.search(
203+
f"^## {re.escape(version)} - .*", changes, flags=re.MULTILINE
204+
)
205+
assert start_match
206+
start_pos = start_match.end() + 1
207+
208+
end_match = re.search("^(<a |## )", changes[start_pos:], flags=re.MULTILINE)
209+
assert end_match
210+
211+
notes = changes[start_pos : end_match.start() + start_pos]
212+
213+
# Remove one level of indent
214+
notes = re.sub("^#", "", notes, flags=re.MULTILINE)
215+
return notes.strip()
216+
217+
218+
@main.command()
219+
@click.option(
220+
"--dry-run",
221+
is_flag=True,
222+
help="Do not publish, just print the content of the release notes",
223+
)
224+
def publish_gh_release(dry_run: bool = False) -> None:
225+
"""Set the release notes of the GitHub release, then remove its "draft" status.
226+
227+
GitHub CLI (https://cli.github.com/) must be installed."""
228+
229+
version = get_version()
230+
tag = get_tag(version)
231+
232+
notes = get_release_notes(version)
233+
if dry_run:
234+
print(notes)
235+
return
236+
237+
check_run(
238+
[
239+
"gh",
240+
"release",
241+
"edit",
242+
tag,
243+
"--title",
244+
version,
245+
"--notes",
246+
notes,
247+
]
248+
)
249+
check_run(["gh", "release", "edit", tag, "--draft=false"])
250+
251+
252+
if __name__ == "__main__":
253+
sys.exit(main())

tests/conftest.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
from pygitguardian import GGClient
88

99

10-
base_uri = os.environ.get("TEST_LIVE_SERVER_URL", "https://api.gitguardian.com")
11-
1210
my_vcr = vcr.VCR(
1311
cassette_library_dir=join(dirname(realpath(__file__)), "cassettes"),
1412
path_transformer=vcr.VCR.ensure_suffix(".yaml"),
@@ -20,11 +18,8 @@
2018
filter_headers=["Authorization"],
2119
)
2220

23-
if os.environ.get("TEST_LIVE_SERVER", "false").lower() == "true":
24-
my_vcr.record_mode = "all"
25-
2621

2722
@pytest.fixture
2823
def client():
29-
api_key = os.environ.get("TEST_LIVE_SERVER_TOKEN", "sample_api_key")
30-
return GGClient(base_uri=base_uri, api_key=api_key)
24+
api_key = os.environ["GITGUARDIAN_API_KEY"]
25+
return GGClient(api_key=api_key)

tests/test_client.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
)
1919
from pygitguardian.models import Detail, MultiScanResult, QuotaResponse, ScanResult
2020

21-
from .conftest import base_uri, my_vcr
21+
from .conftest import my_vcr
2222

2323

2424
FILENAME = ".env"
@@ -257,7 +257,7 @@ def test_client__url_from_endpoint(base_uries, version, endpoints_and_urls):
257257
for endpoint, expected_url in endpoints_and_urls:
258258
assert (
259259
client._url_from_endpoint(endpoint, version) == expected_url
260-
), f"Could not get the expected URL for base_uri=`{base_uri}`"
260+
), f"Could not get the expected URL for base_uri=`{curr_base_uri}`"
261261

262262

263263
@my_vcr.use_cassette
@@ -371,7 +371,7 @@ def test_multi_content_exceptions(
371371
@my_vcr.use_cassette
372372
def test_multi_content_not_ok():
373373
req = [{"document": "valid"}]
374-
client = GGClient(base_uri=base_uri, api_key="invalid")
374+
client = GGClient(api_key="invalid")
375375

376376
obj = client.multi_content_scan(req)
377377

@@ -383,7 +383,7 @@ def test_multi_content_not_ok():
383383
@my_vcr.use_cassette
384384
def test_content_not_ok():
385385
req = {"document": "valid", "filename": "valid"}
386-
client = GGClient(base_uri=base_uri, api_key="invalid")
386+
client = GGClient(api_key="invalid")
387387

388388
obj = client.content_scan(**req)
389389

@@ -603,7 +603,7 @@ def test_quota_overview(client: GGClient):
603603
if isinstance(quota_response, QuotaResponse):
604604
content = quota_response.content
605605
assert content.count + content.remaining == content.limit
606-
assert content.limit == 10000
606+
assert content.limit > 0
607607
assert 2021 <= content.since.year <= date.today().year
608608
else:
609609
pytest.fail("returned should be a QuotaResponse")

0 commit comments

Comments
 (0)