divejson 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- divejson-0.3.0/.github/scripts/release_bump.py +276 -0
- divejson-0.3.0/.github/workflows/release-bump.yml +281 -0
- divejson-0.3.0/CHANGELOG.md +180 -0
- {divejson-0.2.0 → divejson-0.3.0}/CONTRIBUTING.md +37 -1
- divejson-0.3.0/PKG-INFO +235 -0
- divejson-0.3.0/README.md +217 -0
- divejson-0.3.0/SPEC_REF +1 -0
- divejson-0.3.0/divejson/__init__.py +96 -0
- {divejson-0.2.0 → divejson-0.3.0}/divejson/cli.py +60 -19
- {divejson-0.2.0 → divejson-0.3.0}/divejson/conform.py +21 -34
- divejson-0.3.0/divejson/converter.py +569 -0
- divejson-0.3.0/divejson/fit.py +1319 -0
- divejson-0.3.0/divejson/registry.py +412 -0
- divejson-0.3.0/divejson/series.py +193 -0
- divejson-0.3.0/divejson/ssrf.py +698 -0
- divejson-0.3.0/divejson/suunto_json.py +1191 -0
- {divejson-0.2.0 → divejson-0.3.0}/divejson/uddf.py +256 -458
- divejson-0.3.0/divejson/xmlsource.py +201 -0
- divejson-0.3.0/docs/converting.md +292 -0
- divejson-0.3.0/docs/fit-mapping.md +504 -0
- divejson-0.3.0/docs/ssrf-mapping.md +335 -0
- divejson-0.3.0/docs/suunto-json-mapping.md +510 -0
- {divejson-0.2.0 → divejson-0.3.0}/docs/uddf-mapping.md +85 -199
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/README.md +30 -8
- divejson-0.3.0/fixtures/fit/suunto-d5.divejson +850 -0
- divejson-0.3.0/fixtures/fit/suunto-d5.fit +0 -0
- divejson-0.3.0/fixtures/fit/suunto-ocean.divejson +9508 -0
- divejson-0.3.0/fixtures/fit/suunto-ocean.fit +0 -0
- divejson-0.3.0/fixtures/ssrf/refusals.divejson +94 -0
- divejson-0.3.0/fixtures/ssrf/refusals.ssrf +39 -0
- divejson-0.3.0/fixtures/ssrf/subsurface.divejson +131 -0
- divejson-0.3.0/fixtures/ssrf/subsurface.ssrf +42 -0
- divejson-0.3.0/fixtures/ssrf/trip-grouping.divejson +80 -0
- divejson-0.3.0/fixtures/ssrf/trip-grouping.ssrf +33 -0
- divejson-0.3.0/fixtures/suunto_json/header-only.divejson +27 -0
- divejson-0.3.0/fixtures/suunto_json/header-only.json +15 -0
- divejson-0.3.0/fixtures/suunto_json/not-a-dive.divejson +18 -0
- divejson-0.3.0/fixtures/suunto_json/not-a-dive.json +17 -0
- divejson-0.3.0/fixtures/suunto_json/purged-regulator.divejson +73 -0
- divejson-0.3.0/fixtures/suunto_json/purged-regulator.json +50 -0
- divejson-0.3.0/fixtures/suunto_json/suunto-d5.divejson +123 -0
- divejson-0.3.0/fixtures/suunto_json/suunto-d5.json +76 -0
- divejson-0.3.0/fixtures/suunto_json/suunto-ocean.divejson +114 -0
- divejson-0.3.0/fixtures/suunto_json/suunto-ocean.json +111 -0
- divejson-0.3.0/tests/fitbuild.py +312 -0
- divejson-0.3.0/tests/helpers.py +125 -0
- {divejson-0.2.0 → divejson-0.3.0}/tests/test_cli.py +48 -1
- {divejson-0.2.0 → divejson-0.3.0}/tests/test_conform.py +69 -17
- divejson-0.3.0/tests/test_converter.py +187 -0
- divejson-0.3.0/tests/test_fit_fixtures.py +203 -0
- divejson-0.3.0/tests/test_fit_parsing.py +838 -0
- divejson-0.3.0/tests/test_fit_units.py +293 -0
- divejson-0.3.0/tests/test_registry.py +427 -0
- divejson-0.3.0/tests/test_release_bump.py +339 -0
- divejson-0.3.0/tests/test_series.py +205 -0
- divejson-0.3.0/tests/test_ssrf_fixtures.py +205 -0
- divejson-0.3.0/tests/test_ssrf_parsing.py +380 -0
- divejson-0.3.0/tests/test_ssrf_units.py +198 -0
- divejson-0.3.0/tests/test_suunto_json_fixtures.py +326 -0
- divejson-0.3.0/tests/test_suunto_json_parsing.py +564 -0
- divejson-0.3.0/tests/test_suunto_json_units.py +235 -0
- {divejson-0.2.0 → divejson-0.3.0}/tests/test_uddf_fixtures.py +38 -5
- {divejson-0.2.0 → divejson-0.3.0}/tests/test_uddf_parsing.py +50 -32
- {divejson-0.2.0 → divejson-0.3.0}/tests/test_uddf_units.py +26 -13
- divejson-0.3.0/tests/test_xmlsource.py +123 -0
- divejson-0.2.0/CHANGELOG.md +0 -51
- divejson-0.2.0/PKG-INFO +0 -119
- divejson-0.2.0/README.md +0 -101
- divejson-0.2.0/SPEC_REF +0 -1
- divejson-0.2.0/divejson/__init__.py +0 -64
- divejson-0.2.0/tests/helpers.py +0 -57
- {divejson-0.2.0 → divejson-0.3.0}/.github/workflows/ci.yml +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/.github/workflows/pr-title.yml +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/.github/workflows/release.yml +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/.gitignore +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/LICENSE +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/divejson/py.typed +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/divejson/validate.py +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/agency-other-missing.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/avg-depth-exceeds-max.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/bad-version.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/bbox-missing-corner.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/bbox-south-exceeds-north.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/bbox-without-position.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/channel-length-mismatch.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/course-dates-reversed.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/dangling-reference.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/duplicate-json-member.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/duplicate-uuid.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/event-other-without-label.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/missing-format.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/naive-exported-at.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/non-increasing-samples.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/null-member.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/oxygen-helium-sum.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/position-incomplete.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/pressure-order.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/profile-duration-short.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/species-no-identity.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/trailing-newline-datetime.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/trip-dates-reversed.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/undefined-member.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/version-before-format.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/version-not-second.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/divelogs.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/divelogs.uddf +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/legacy-writer.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/legacy-writer.uddf +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/mix-only-cylinder.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/mix-only-cylinder.uddf +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/opendiving.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/opendiving.uddf +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/subsurface.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/subsurface.uddf +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/valid/demo-logbook.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/valid/minimal.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/fixtures/valid/technical-dive.divejson +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/pyproject.toml +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/schema/1.0/divejson.schema.json +0 -0
- {divejson-0.2.0 → divejson-0.3.0}/tests/test_package.py +0 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Work out what version a release is, and write it into the two files that carry it.
|
|
3
|
+
|
|
4
|
+
`.github/workflows/release-bump.yml` runs this over a checkout of `main`; the two
|
|
5
|
+
rewrites then land through a pull request and the tag goes on the commit that lands. It
|
|
6
|
+
is a script beside the workflow rather than part of the package, because nothing a `pip
|
|
7
|
+
install divejson` gets has any business knowing how this repository cuts a release.
|
|
8
|
+
|
|
9
|
+
The version is computed from the conventional-commit subjects since the last `v*` tag,
|
|
10
|
+
which — the repository being squash-only with `PR_TITLE` as the subject — are the titles
|
|
11
|
+
of the pull requests in the window. **Below 1.0 a breaking change moves the minor**: 0.x
|
|
12
|
+
has no major to spend, and the promise 0.x makes is precisely that a minor may break.
|
|
13
|
+
From 1.0.0 the rule is ordinary semver.
|
|
14
|
+
|
|
15
|
+
Everything it will not do is a refusal rather than a guess, because every one of them is
|
|
16
|
+
a release that is wrong in a way nobody would notice until PyPI had it:
|
|
17
|
+
|
|
18
|
+
- an empty `## Unreleased` section publishes a version whose notes say nothing;
|
|
19
|
+
- a `__version__` that disagrees with the newest tag means a bump landed and its tag
|
|
20
|
+
never did, where the answer is to tag what is already there rather than to bump past
|
|
21
|
+
it, silently burning a version number;
|
|
22
|
+
- a version that is not ahead of the current one, or one whose tag already exists, is a
|
|
23
|
+
release PyPI would refuse after `main` had already moved.
|
|
24
|
+
|
|
25
|
+
Run it by hand to see what a release would be — it only touches the working tree, and
|
|
26
|
+
`git diff` is the whole report:
|
|
27
|
+
|
|
28
|
+
python3 .github/scripts/release_bump.py
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
from collections.abc import Sequence
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
# The line hatchling reads the built version out of (`[tool.hatch.version]`), matched
|
|
42
|
+
# whole so that a `__version__` mentioned in a docstring or a comment cannot be the one
|
|
43
|
+
# that moves. `[^"\n]` and not `[^"]`: a class that admits a newline would match a
|
|
44
|
+
# `__version__ = "0.3.0` whose closing quote is on the next line, which is the shape a
|
|
45
|
+
# version string carrying a newline writes and the shape the read-back below has to catch.
|
|
46
|
+
VERSION_LINE = re.compile(r'^__version__ = "(?P<version>[^"\n]*)"$', re.MULTILINE)
|
|
47
|
+
|
|
48
|
+
# Three plain integers and nothing else. A pre-release or build-metadata suffix would
|
|
49
|
+
# make `bump` ambiguous and `release.yml`'s `dist/divejson-$version.tar.gz` check depend
|
|
50
|
+
# on how the build normalises it, so it is refused rather than half-supported.
|
|
51
|
+
#
|
|
52
|
+
# Used with `fullmatch` rather than anchored with `$`, because Python's `$` also matches
|
|
53
|
+
# *before* a trailing newline: `"0.3.0\n"` would pass an anchored `match`, be written into
|
|
54
|
+
# the version line as two physical lines, and leave a package that does not import.
|
|
55
|
+
SEMVER = re.compile(r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)")
|
|
56
|
+
|
|
57
|
+
# The same shape `.github/workflows/pr-title.yml` enforces on every title that becomes a
|
|
58
|
+
# subject here, with the type left open: that workflow owns the list of types, and a
|
|
59
|
+
# second copy of it in this file is a second place for it to go stale. What this needs
|
|
60
|
+
# from a subject is only whether it carries `!`, and whether its type is `feat` or `fix`.
|
|
61
|
+
SUBJECT = re.compile(r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]*)\))?(?P<breaking>!)?: ")
|
|
62
|
+
|
|
63
|
+
UNRELEASED = "## Unreleased"
|
|
64
|
+
|
|
65
|
+
INIT_PATH = Path("divejson") / "__init__.py"
|
|
66
|
+
CHANGELOG_PATH = Path("CHANGELOG.md")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ReleaseError(Exception):
|
|
70
|
+
"""Something about the tree or the request means there is no release to cut."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_version(text: str) -> tuple[int, int, int]:
|
|
74
|
+
match = SEMVER.fullmatch(text)
|
|
75
|
+
if match is None:
|
|
76
|
+
raise ReleaseError(f"{text!r} is not a major.minor.patch version")
|
|
77
|
+
return int(match["major"]), int(match["minor"]), int(match["patch"])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_version(init_text: str) -> str:
|
|
81
|
+
"""The version the package currently declares."""
|
|
82
|
+
found = VERSION_LINE.findall(init_text)
|
|
83
|
+
if len(found) != 1:
|
|
84
|
+
raise ReleaseError(f"{INIT_PATH} declares __version__ {len(found)} times, not once")
|
|
85
|
+
return found[0]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def bump_kind(subjects: Sequence[str]) -> str:
|
|
89
|
+
"""What the window as a whole asks for: `breaking`, `feature`, `fix` or `none`.
|
|
90
|
+
|
|
91
|
+
Subjects that are not conventional subjects contribute nothing. They exist — the
|
|
92
|
+
handful of commits that predate `pr-title.yml` — and one of them is not evidence
|
|
93
|
+
either way.
|
|
94
|
+
"""
|
|
95
|
+
types: set[str] = set()
|
|
96
|
+
for subject in subjects:
|
|
97
|
+
match = SUBJECT.match(subject)
|
|
98
|
+
if match is None:
|
|
99
|
+
continue
|
|
100
|
+
if match["breaking"]:
|
|
101
|
+
return "breaking"
|
|
102
|
+
types.add(match["type"])
|
|
103
|
+
if "feat" in types:
|
|
104
|
+
return "feature"
|
|
105
|
+
if "fix" in types:
|
|
106
|
+
return "fix"
|
|
107
|
+
return "none"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def next_version(current: str, subjects: Sequence[str]) -> str:
|
|
111
|
+
major, minor, patch = parse_version(current)
|
|
112
|
+
kind = bump_kind(subjects)
|
|
113
|
+
if major == 0:
|
|
114
|
+
# Below 1.0 a break has no major to move, and a minor is what 0.x already warns
|
|
115
|
+
# about — so breaking and feature land on the same answer, and only a window of
|
|
116
|
+
# fixes (or of changes that claim nothing at all) is a patch.
|
|
117
|
+
if kind in ("breaking", "feature"):
|
|
118
|
+
return f"0.{minor + 1}.0"
|
|
119
|
+
return f"0.{minor}.{patch + 1}"
|
|
120
|
+
if kind == "breaking":
|
|
121
|
+
return f"{major + 1}.0.0"
|
|
122
|
+
if kind == "feature":
|
|
123
|
+
return f"{major}.{minor + 1}.0"
|
|
124
|
+
return f"{major}.{minor}.{patch + 1}"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def rewrite_version(init_text: str, version: str) -> str:
|
|
128
|
+
read_version(init_text) # exactly one line to move, or nothing is rewritten
|
|
129
|
+
return VERSION_LINE.sub(f'__version__ = "{version}"', init_text, count=1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def rewrite_changelog(changelog_text: str, version: str) -> str:
|
|
133
|
+
"""`## Unreleased` becomes `## <version>`, with a fresh empty `## Unreleased` above."""
|
|
134
|
+
lines = changelog_text.split("\n")
|
|
135
|
+
headings = [i for i, line in enumerate(lines) if line.rstrip() == UNRELEASED]
|
|
136
|
+
if len(headings) != 1:
|
|
137
|
+
raise ReleaseError(
|
|
138
|
+
f"{CHANGELOG_PATH} has {len(headings)} `{UNRELEASED}` headings, not one"
|
|
139
|
+
)
|
|
140
|
+
start = headings[0]
|
|
141
|
+
end = next(
|
|
142
|
+
(i for i in range(start + 1, len(lines)) if lines[i].startswith("## ")),
|
|
143
|
+
len(lines),
|
|
144
|
+
)
|
|
145
|
+
if not "\n".join(lines[start + 1 : end]).strip():
|
|
146
|
+
raise ReleaseError(
|
|
147
|
+
f"the `{UNRELEASED}` section of {CHANGELOG_PATH} is empty — a release with no "
|
|
148
|
+
"notes is not one anybody can read, so write the entries first"
|
|
149
|
+
)
|
|
150
|
+
lines[start : start + 1] = [UNRELEASED, "", f"## {version}"]
|
|
151
|
+
return "\n".join(lines)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def git(root: Path, *args: str) -> str:
|
|
155
|
+
result = subprocess.run(
|
|
156
|
+
["git", *args], cwd=root, capture_output=True, text=True, check=True
|
|
157
|
+
)
|
|
158
|
+
return result.stdout
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def last_tag(root: Path) -> str | None:
|
|
162
|
+
"""The newest `v*` tag reachable from HEAD, or None in a repository with no release.
|
|
163
|
+
|
|
164
|
+
Reachability rather than a sort over every tag: a tag on some other branch is not
|
|
165
|
+
what this history was released as, and would silently truncate the window.
|
|
166
|
+
"""
|
|
167
|
+
result = subprocess.run(
|
|
168
|
+
["git", "describe", "--tags", "--abbrev=0", "--match", "v*"],
|
|
169
|
+
cwd=root,
|
|
170
|
+
capture_output=True,
|
|
171
|
+
text=True,
|
|
172
|
+
)
|
|
173
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def subjects_since(root: Path, tag: str | None) -> list[str]:
|
|
177
|
+
span = f"{tag}..HEAD" if tag else "HEAD"
|
|
178
|
+
return [line for line in git(root, "log", "--format=%s", span).splitlines() if line]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def plan(root: Path, init_text: str, requested: str | None) -> tuple[str, str, str | None, list[str]]:
|
|
182
|
+
"""The whole decision, before a byte of the tree is touched."""
|
|
183
|
+
current = read_version(init_text)
|
|
184
|
+
parse_version(current)
|
|
185
|
+
|
|
186
|
+
tag = last_tag(root)
|
|
187
|
+
if tag is not None and tag != f"v{current}":
|
|
188
|
+
raise ReleaseError(
|
|
189
|
+
f"the tree declares {current} and the newest tag reachable is {tag}. A bump "
|
|
190
|
+
f"has landed whose tag never did — push v{current} at the commit that moved "
|
|
191
|
+
"it rather than bumping past a version nobody released"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
subjects = subjects_since(root, tag)
|
|
195
|
+
if not subjects:
|
|
196
|
+
raise ReleaseError(f"nothing has landed since {tag or 'the start of the history'}")
|
|
197
|
+
|
|
198
|
+
if requested:
|
|
199
|
+
version = requested
|
|
200
|
+
if parse_version(version) <= parse_version(current):
|
|
201
|
+
raise ReleaseError(f"{version} is not ahead of {current}")
|
|
202
|
+
else:
|
|
203
|
+
version = next_version(current, subjects)
|
|
204
|
+
|
|
205
|
+
if f"v{version}" in git(root, "tag", "--list").split():
|
|
206
|
+
raise ReleaseError(f"v{version} already exists — PyPI would refuse it as a duplicate")
|
|
207
|
+
|
|
208
|
+
return current, version, tag, subjects
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def run(root: Path, requested: str | None) -> None:
|
|
212
|
+
"""Decide, rewrite, report. Every refusal leaves this by raising `ReleaseError`."""
|
|
213
|
+
init_text = (root / INIT_PATH).read_text(encoding="utf-8")
|
|
214
|
+
changelog_text = (root / CHANGELOG_PATH).read_text(encoding="utf-8")
|
|
215
|
+
|
|
216
|
+
current, version, tag, subjects = plan(root, init_text, requested)
|
|
217
|
+
# Both rewrites are computed before either is written, so a refusal from the second
|
|
218
|
+
# one does not leave the first one on disk.
|
|
219
|
+
new_init = rewrite_version(init_text, version)
|
|
220
|
+
new_changelog = rewrite_changelog(changelog_text, version)
|
|
221
|
+
|
|
222
|
+
(root / INIT_PATH).write_text(new_init, encoding="utf-8")
|
|
223
|
+
(root / CHANGELOG_PATH).write_text(new_changelog, encoding="utf-8")
|
|
224
|
+
|
|
225
|
+
# Read back rather than trust the substitution: this string is what `release.yml` will
|
|
226
|
+
# look for as `dist/divejson-$version.tar.gz`, and the tag is pushed before anything
|
|
227
|
+
# builds. `read_version` raises here if the line it wrote is no longer one line.
|
|
228
|
+
written = read_version((root / INIT_PATH).read_text(encoding="utf-8"))
|
|
229
|
+
if written != version:
|
|
230
|
+
raise ReleaseError(f"wrote {written!r}, meant {version!r}")
|
|
231
|
+
|
|
232
|
+
print(f"released so far: {current} (tag {tag or 'none'})")
|
|
233
|
+
print(f"releasing: {version} ({bump_kind(subjects)})")
|
|
234
|
+
print(f"window: {len(subjects)} commits")
|
|
235
|
+
for subject in subjects:
|
|
236
|
+
print(f" {subject}")
|
|
237
|
+
|
|
238
|
+
# Only once everything above held: the workflow names the branch, the pull request and
|
|
239
|
+
# the tag from this, so a version here is a commitment to push it.
|
|
240
|
+
output = os.environ.get("GITHUB_OUTPUT")
|
|
241
|
+
if output:
|
|
242
|
+
with open(output, "a", encoding="utf-8") as handle:
|
|
243
|
+
handle.write(f"version={version}\n")
|
|
244
|
+
handle.write(f"previous={current}\n")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
248
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
249
|
+
parser.add_argument(
|
|
250
|
+
"--version",
|
|
251
|
+
default="",
|
|
252
|
+
help="the version to release. Empty — which is what the workflow passes when its "
|
|
253
|
+
"own input was left blank — computes it from the commit subjects.",
|
|
254
|
+
)
|
|
255
|
+
parser.add_argument(
|
|
256
|
+
"--root",
|
|
257
|
+
type=Path,
|
|
258
|
+
default=Path.cwd(),
|
|
259
|
+
help="the checkout to work in (default: the working directory)",
|
|
260
|
+
)
|
|
261
|
+
args = parser.parse_args(argv)
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
# Stripped once, here: whitespace around a version typed into the dispatch form is
|
|
265
|
+
# a typo rather than an intention, and every check below is on the value that is
|
|
266
|
+
# actually written.
|
|
267
|
+
run(args.root.resolve(), args.version.strip() or None)
|
|
268
|
+
except ReleaseError as error:
|
|
269
|
+
prefix = "::error::" if os.environ.get("GITHUB_ACTIONS") else "error: "
|
|
270
|
+
print(f"{prefix}{error}", file=sys.stderr)
|
|
271
|
+
return 1
|
|
272
|
+
return 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
if __name__ == "__main__":
|
|
276
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
name: Release bump
|
|
2
|
+
|
|
3
|
+
# Cuts a release: works out the version, writes it into the two files that carry it, lands
|
|
4
|
+
# that on `main` through a pull request, and tags the commit that lands — which is the
|
|
5
|
+
# event `release.yml` publishes from. `.github/scripts/release_bump.py` is the half with
|
|
6
|
+
# the decisions in it, and it has tests; this file is the half that has to touch GitHub,
|
|
7
|
+
# and every awkward thing below is one of these four.
|
|
8
|
+
#
|
|
9
|
+
# **A GitHub App token, never `GITHUB_TOKEN`.** GitHub starts no workflow run from an
|
|
10
|
+
# event created with the default token — `workflow_dispatch`, `repository_dispatch` and
|
|
11
|
+
# three `pull_request` activity types are the documented exceptions, and a tag push is not
|
|
12
|
+
# among them. A tag pushed with `GITHUB_TOKEN` would publish nothing and say nothing was
|
|
13
|
+
# wrong: no error, no run, no release, just a tag sitting there.
|
|
14
|
+
#
|
|
15
|
+
# **The bump commit is made through the API.** `main`'s ruleset requires signatures, and a
|
|
16
|
+
# runner's `git commit` is signed by nobody. GraphQL's `createCommitOnBranch` is used
|
|
17
|
+
# rather than `git` because a commit it creates is signed by GitHub itself — documented
|
|
18
|
+
# behaviour of that mutation, and the only way a runner gets a commit onto this branch.
|
|
19
|
+
#
|
|
20
|
+
# **Through a pull request, and this app is not a bypass actor.** The ruleset's
|
|
21
|
+
# `bypass_actors` is empty on purpose, and GitHub's bypass is ruleset-wide rather than
|
|
22
|
+
# per-rule: an "always allow" entry for this app would also let it push unsigned commits,
|
|
23
|
+
# force-push `main` and delete it — so a leaked private key could rewrite the history of a
|
|
24
|
+
# package that publishes to PyPI by trusted publishing, and destroy the evidence of its own
|
|
25
|
+
# use. The pull request costs nothing instead, because `main` requires zero approving
|
|
26
|
+
# reviews: this workflow opens one and squash-merges it in the same run, with no rule
|
|
27
|
+
# relaxed and no human in the loop. Squash is also the only merge method `main` allows.
|
|
28
|
+
#
|
|
29
|
+
# **The tag goes on the squash commit.** Squash-merging writes a *new* commit on `main`;
|
|
30
|
+
# the branch's own commit never lands there, and a tag on it would name a release nothing
|
|
31
|
+
# on `main` can be traced to.
|
|
32
|
+
#
|
|
33
|
+
# ## What this needs from outside the repository
|
|
34
|
+
#
|
|
35
|
+
# A GitHub App owned by the `divejson` organisation and installed on this repository
|
|
36
|
+
# alone, with two repository permissions and nothing else: **Contents: read and write**
|
|
37
|
+
# (the branch, the commit, the tag) and **Pull requests: read and write** (opening it,
|
|
38
|
+
# merging it). No organisation permissions, no account permissions, no webhook. Then, on
|
|
39
|
+
# this repository:
|
|
40
|
+
#
|
|
41
|
+
# RELEASE_BOT_CLIENT_ID a repository **variable** — the app's Client ID
|
|
42
|
+
# RELEASE_BOT_PRIVATE_KEY a repository **secret** — the app's private key, whole .pem
|
|
43
|
+
#
|
|
44
|
+
# The Client ID is a variable rather than a secret on purpose. It is not a secret, and
|
|
45
|
+
# secrets are masked in every log line, which would turn each failure here into `***`.
|
|
46
|
+
|
|
47
|
+
on:
|
|
48
|
+
workflow_dispatch:
|
|
49
|
+
inputs:
|
|
50
|
+
version:
|
|
51
|
+
description: "Version to release, e.g. 0.3.0. Computed from the commit subjects when blank."
|
|
52
|
+
required: false
|
|
53
|
+
type: string
|
|
54
|
+
dry_run:
|
|
55
|
+
description: "Report the version and the diff, and push nothing."
|
|
56
|
+
required: false
|
|
57
|
+
default: false
|
|
58
|
+
type: boolean
|
|
59
|
+
|
|
60
|
+
# Two dispatches would otherwise both read the same `main`, compute the same version, and
|
|
61
|
+
# race to create the same branch. Queued rather than cancelled: a run cancelled between
|
|
62
|
+
# the merge and the tag leaves a bump on `main` that nothing published.
|
|
63
|
+
concurrency:
|
|
64
|
+
group: release-bump
|
|
65
|
+
cancel-in-progress: false
|
|
66
|
+
|
|
67
|
+
jobs:
|
|
68
|
+
bump:
|
|
69
|
+
name: Bump the version, land it, tag it
|
|
70
|
+
runs-on: ubuntu-latest
|
|
71
|
+
|
|
72
|
+
# Every write goes through the app's token, so the default one needs nothing at all.
|
|
73
|
+
permissions: {}
|
|
74
|
+
|
|
75
|
+
steps:
|
|
76
|
+
- name: A release is cut with the workflow that is on `main`
|
|
77
|
+
if: ${{ !inputs.dry_run && github.ref != 'refs/heads/main' }}
|
|
78
|
+
env:
|
|
79
|
+
REF: ${{ github.ref }}
|
|
80
|
+
run: |
|
|
81
|
+
echo "::error::dispatched from $REF, and the tree released is always main's — so this would be a branch's machinery against a tree it has not merged into. Dispatch from main, or use dry_run to try a branch's copy."
|
|
82
|
+
exit 1
|
|
83
|
+
|
|
84
|
+
- name: The release app's credentials are configured
|
|
85
|
+
env:
|
|
86
|
+
CLIENT_ID: ${{ vars.RELEASE_BOT_CLIENT_ID }}
|
|
87
|
+
PRIVATE_KEY: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
|
|
88
|
+
run: |
|
|
89
|
+
missing=""
|
|
90
|
+
[ -n "$CLIENT_ID" ] || missing="$missing RELEASE_BOT_CLIENT_ID (repository variable)"
|
|
91
|
+
[ -n "$PRIVATE_KEY" ] || missing="$missing RELEASE_BOT_PRIVATE_KEY (repository secret)"
|
|
92
|
+
if [ -n "$missing" ]; then
|
|
93
|
+
echo "::error::not configured:$missing — the header of this workflow says what the app is and what it needs"
|
|
94
|
+
exit 1
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
- name: Mint a token for the release app
|
|
98
|
+
id: app
|
|
99
|
+
uses: actions/create-github-app-token@v3
|
|
100
|
+
with:
|
|
101
|
+
client-id: ${{ vars.RELEASE_BOT_CLIENT_ID }}
|
|
102
|
+
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
|
|
103
|
+
|
|
104
|
+
- name: Check `main` out, with the history a version is computed from
|
|
105
|
+
uses: actions/checkout@v4
|
|
106
|
+
with:
|
|
107
|
+
# Always `main`, whatever branch the dispatch names: the tree a release is cut
|
|
108
|
+
# from is not a dispatcher's choice, and a dry run from a branch is then that
|
|
109
|
+
# branch's machinery measured against the tree it would really release.
|
|
110
|
+
ref: main
|
|
111
|
+
# The whole history and every tag. The window is the subjects since the last
|
|
112
|
+
# `v*` tag, and a shallow clone has neither end of it.
|
|
113
|
+
fetch-depth: 0
|
|
114
|
+
token: ${{ steps.app.outputs.token }}
|
|
115
|
+
# Nothing here pushes with git — the commit and the tag both go through the API
|
|
116
|
+
# — so the token has no reason to be left in `.git/config`.
|
|
117
|
+
persist-credentials: false
|
|
118
|
+
|
|
119
|
+
- uses: actions/setup-python@v5
|
|
120
|
+
with:
|
|
121
|
+
python-version: "3.12"
|
|
122
|
+
|
|
123
|
+
- name: Work out the version, and write it into the two files that carry it
|
|
124
|
+
id: bump
|
|
125
|
+
env:
|
|
126
|
+
# Through the environment rather than interpolated into the script: a
|
|
127
|
+
# `${{ inputs.version }}` written inside `run:` is substituted before bash parses
|
|
128
|
+
# the line, so a dispatcher could put `$(...)` in the box and have it run.
|
|
129
|
+
VERSION: ${{ inputs.version }}
|
|
130
|
+
run: |
|
|
131
|
+
set -eo pipefail
|
|
132
|
+
{
|
|
133
|
+
echo '```'
|
|
134
|
+
python3 .github/scripts/release_bump.py --version "$VERSION"
|
|
135
|
+
echo '```'
|
|
136
|
+
} | tee -a "$GITHUB_STEP_SUMMARY"
|
|
137
|
+
|
|
138
|
+
- name: What the release changes
|
|
139
|
+
run: |
|
|
140
|
+
set -eo pipefail
|
|
141
|
+
{
|
|
142
|
+
echo '```diff'
|
|
143
|
+
git diff
|
|
144
|
+
echo '```'
|
|
145
|
+
} | tee -a "$GITHUB_STEP_SUMMARY"
|
|
146
|
+
|
|
147
|
+
- name: Stop here — this was a dry run
|
|
148
|
+
if: ${{ inputs.dry_run }}
|
|
149
|
+
run: |
|
|
150
|
+
echo "::notice::dry run — nothing was committed, merged or tagged"
|
|
151
|
+
|
|
152
|
+
- name: Open the pull request that carries the bump
|
|
153
|
+
if: ${{ !inputs.dry_run }}
|
|
154
|
+
id: pr
|
|
155
|
+
env:
|
|
156
|
+
GH_TOKEN: ${{ steps.app.outputs.token }}
|
|
157
|
+
VERSION: ${{ steps.bump.outputs.version }}
|
|
158
|
+
run: |
|
|
159
|
+
set -eo pipefail
|
|
160
|
+
base="$(git rev-parse HEAD)"
|
|
161
|
+
branch="release/v$VERSION"
|
|
162
|
+
title="chore(release): v$VERSION"
|
|
163
|
+
|
|
164
|
+
if gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$branch" >/dev/null 2>&1; then
|
|
165
|
+
echo "::error::$branch already exists, so an earlier run stopped part-way. Merge or delete its pull request, then dispatch again."
|
|
166
|
+
exit 1
|
|
167
|
+
fi
|
|
168
|
+
|
|
169
|
+
# The branch first, at `main`'s head: `createCommitOnBranch` appends to a branch,
|
|
170
|
+
# it does not create one.
|
|
171
|
+
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
|
|
172
|
+
-f "ref=refs/heads/$branch" -f "sha=$base" >/dev/null
|
|
173
|
+
|
|
174
|
+
# `expectedHeadOid` is the optimistic lock — on the branch just created, so it
|
|
175
|
+
# cannot fail here, but it is the mutation's own guard against appending to a
|
|
176
|
+
# branch that moved and it is not optional.
|
|
177
|
+
query='mutation($input: CreateCommitOnBranchInput!) {
|
|
178
|
+
createCommitOnBranch(input: $input) { commit { oid } }
|
|
179
|
+
}'
|
|
180
|
+
commit="$(jq -n \
|
|
181
|
+
--arg query "$query" \
|
|
182
|
+
--arg repo "$GITHUB_REPOSITORY" \
|
|
183
|
+
--arg branch "$branch" \
|
|
184
|
+
--arg oid "$base" \
|
|
185
|
+
--arg headline "$title" \
|
|
186
|
+
--arg init "$(base64 -w0 divejson/__init__.py)" \
|
|
187
|
+
--arg changelog "$(base64 -w0 CHANGELOG.md)" \
|
|
188
|
+
'{query: $query, variables: {input: {
|
|
189
|
+
branch: {repositoryNameWithOwner: $repo, branchName: $branch},
|
|
190
|
+
expectedHeadOid: $oid,
|
|
191
|
+
message: {headline: $headline},
|
|
192
|
+
fileChanges: {additions: [
|
|
193
|
+
{path: "divejson/__init__.py", contents: $init},
|
|
194
|
+
{path: "CHANGELOG.md", contents: $changelog}
|
|
195
|
+
]}
|
|
196
|
+
}}}' \
|
|
197
|
+
| gh api graphql --input - --jq '.data.createCommitOnBranch.commit.oid')"
|
|
198
|
+
echo "the bump is $commit on $branch"
|
|
199
|
+
|
|
200
|
+
# `printf` rather than a heredoc: a heredoc body has to start at column 1,
|
|
201
|
+
# which is outside this block scalar.
|
|
202
|
+
body="$(printf '%s\n' \
|
|
203
|
+
"\`__version__\` and the changelog's \`## Unreleased\` heading both move to $VERSION." \
|
|
204
|
+
"" \
|
|
205
|
+
"Opened by \`.github/workflows/release-bump.yml\`, which squash-merges this and tags the commit that lands — and the tag is what \`release.yml\` builds and publishes from.")"
|
|
206
|
+
url="$(gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" \
|
|
207
|
+
--title "$title" --body "$body")"
|
|
208
|
+
echo "base=$base" >> "$GITHUB_OUTPUT"
|
|
209
|
+
echo "number=${url##*/}" >> "$GITHUB_OUTPUT"
|
|
210
|
+
echo "$url"
|
|
211
|
+
|
|
212
|
+
- name: Squash-merge it, and tag the commit that lands
|
|
213
|
+
if: ${{ !inputs.dry_run }}
|
|
214
|
+
env:
|
|
215
|
+
GH_TOKEN: ${{ steps.app.outputs.token }}
|
|
216
|
+
VERSION: ${{ steps.bump.outputs.version }}
|
|
217
|
+
NUMBER: ${{ steps.pr.outputs.number }}
|
|
218
|
+
BASE: ${{ steps.pr.outputs.base }}
|
|
219
|
+
run: |
|
|
220
|
+
set -eo pipefail
|
|
221
|
+
|
|
222
|
+
# Mergeability is computed asynchronously, so a merge attempted the instant the
|
|
223
|
+
# pull request opens is refused for being UNKNOWN rather than for anything real.
|
|
224
|
+
state=UNKNOWN
|
|
225
|
+
for _ in $(seq 30); do
|
|
226
|
+
state="$(gh pr view "$NUMBER" --repo "$GITHUB_REPOSITORY" --json mergeStateStatus --jq .mergeStateStatus)"
|
|
227
|
+
[ "$state" = UNKNOWN ] || break
|
|
228
|
+
sleep 4
|
|
229
|
+
done
|
|
230
|
+
case "$state" in
|
|
231
|
+
# UNSTABLE is the ordinary answer here: the pull request's own CI is still
|
|
232
|
+
# running, and nothing on `main` is a required check. What that CI would say,
|
|
233
|
+
# `release.yml` asks again and harder once the tag exists — it rebuilds the
|
|
234
|
+
# sdist, builds the wheel from it, and runs the whole corpus through the wheel
|
|
235
|
+
# before a byte reaches PyPI — so waiting would buy a slower release and no
|
|
236
|
+
# more certainty, on a commit that moves a version string and a heading.
|
|
237
|
+
CLEAN|UNSTABLE|HAS_HOOKS) ;;
|
|
238
|
+
*)
|
|
239
|
+
echo "::error::the pull request is $state and cannot be merged. BLOCKED means a rule on main wants something this app cannot give — read the ruleset before relaxing anything here."
|
|
240
|
+
exit 1
|
|
241
|
+
;;
|
|
242
|
+
esac
|
|
243
|
+
|
|
244
|
+
# The version was computed from `main` at $BASE. If `main` has moved since, the
|
|
245
|
+
# squash commit would carry work this run never read, under a version that does
|
|
246
|
+
# not describe it.
|
|
247
|
+
main_head="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq .object.sha)"
|
|
248
|
+
if [ "$main_head" != "$BASE" ]; then
|
|
249
|
+
echo "::error::main moved from $BASE to $main_head while this ran, so the release would carry changes the version was never computed from. Nothing was merged; the pull request is open."
|
|
250
|
+
exit 1
|
|
251
|
+
fi
|
|
252
|
+
|
|
253
|
+
# No --subject: the repository's squash subject source is PR_TITLE, so the title
|
|
254
|
+
# checked by `pr-title.yml` is the subject that lands on `main`.
|
|
255
|
+
gh pr merge "$NUMBER" --repo "$GITHUB_REPOSITORY" --squash
|
|
256
|
+
|
|
257
|
+
# The squash commit, not the branch's. GitHub names it a moment after the merge.
|
|
258
|
+
merged=""
|
|
259
|
+
for _ in $(seq 30); do
|
|
260
|
+
merged="$(gh pr view "$NUMBER" --repo "$GITHUB_REPOSITORY" --json mergeCommit --jq '.mergeCommit.oid // empty')"
|
|
261
|
+
[ -z "$merged" ] || break
|
|
262
|
+
sleep 2
|
|
263
|
+
done
|
|
264
|
+
if [ -z "$merged" ]; then
|
|
265
|
+
echo "::error::merged, but GitHub has not named the squash commit. Tag v$VERSION on main's head by hand."
|
|
266
|
+
exit 1
|
|
267
|
+
fi
|
|
268
|
+
|
|
269
|
+
# Annotated, like every tag this repository already carries, which takes two
|
|
270
|
+
# calls: the object, then the ref that points at it. Creating the ref is the
|
|
271
|
+
# push, and it is the app's token that makes it — which is the whole reason the
|
|
272
|
+
# app exists, since `release.yml` would never run off `GITHUB_TOKEN`'s.
|
|
273
|
+
object="$(gh api "repos/$GITHUB_REPOSITORY/git/tags" \
|
|
274
|
+
-f "tag=v$VERSION" -f "message=divejson $VERSION" \
|
|
275
|
+
-f "object=$merged" -f "type=commit" --jq .sha)"
|
|
276
|
+
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
|
|
277
|
+
-f "ref=refs/tags/v$VERSION" -f "sha=$object" >/dev/null
|
|
278
|
+
|
|
279
|
+
echo "::notice::v$VERSION tagged at $merged"
|
|
280
|
+
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
281
|
+
echo "Merged as \`$merged\` and tagged \`v$VERSION\`; \`release.yml\` publishes from here." >> "$GITHUB_STEP_SUMMARY"
|