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.
Files changed (120) hide show
  1. divejson-0.3.0/.github/scripts/release_bump.py +276 -0
  2. divejson-0.3.0/.github/workflows/release-bump.yml +281 -0
  3. divejson-0.3.0/CHANGELOG.md +180 -0
  4. {divejson-0.2.0 → divejson-0.3.0}/CONTRIBUTING.md +37 -1
  5. divejson-0.3.0/PKG-INFO +235 -0
  6. divejson-0.3.0/README.md +217 -0
  7. divejson-0.3.0/SPEC_REF +1 -0
  8. divejson-0.3.0/divejson/__init__.py +96 -0
  9. {divejson-0.2.0 → divejson-0.3.0}/divejson/cli.py +60 -19
  10. {divejson-0.2.0 → divejson-0.3.0}/divejson/conform.py +21 -34
  11. divejson-0.3.0/divejson/converter.py +569 -0
  12. divejson-0.3.0/divejson/fit.py +1319 -0
  13. divejson-0.3.0/divejson/registry.py +412 -0
  14. divejson-0.3.0/divejson/series.py +193 -0
  15. divejson-0.3.0/divejson/ssrf.py +698 -0
  16. divejson-0.3.0/divejson/suunto_json.py +1191 -0
  17. {divejson-0.2.0 → divejson-0.3.0}/divejson/uddf.py +256 -458
  18. divejson-0.3.0/divejson/xmlsource.py +201 -0
  19. divejson-0.3.0/docs/converting.md +292 -0
  20. divejson-0.3.0/docs/fit-mapping.md +504 -0
  21. divejson-0.3.0/docs/ssrf-mapping.md +335 -0
  22. divejson-0.3.0/docs/suunto-json-mapping.md +510 -0
  23. {divejson-0.2.0 → divejson-0.3.0}/docs/uddf-mapping.md +85 -199
  24. {divejson-0.2.0 → divejson-0.3.0}/fixtures/README.md +30 -8
  25. divejson-0.3.0/fixtures/fit/suunto-d5.divejson +850 -0
  26. divejson-0.3.0/fixtures/fit/suunto-d5.fit +0 -0
  27. divejson-0.3.0/fixtures/fit/suunto-ocean.divejson +9508 -0
  28. divejson-0.3.0/fixtures/fit/suunto-ocean.fit +0 -0
  29. divejson-0.3.0/fixtures/ssrf/refusals.divejson +94 -0
  30. divejson-0.3.0/fixtures/ssrf/refusals.ssrf +39 -0
  31. divejson-0.3.0/fixtures/ssrf/subsurface.divejson +131 -0
  32. divejson-0.3.0/fixtures/ssrf/subsurface.ssrf +42 -0
  33. divejson-0.3.0/fixtures/ssrf/trip-grouping.divejson +80 -0
  34. divejson-0.3.0/fixtures/ssrf/trip-grouping.ssrf +33 -0
  35. divejson-0.3.0/fixtures/suunto_json/header-only.divejson +27 -0
  36. divejson-0.3.0/fixtures/suunto_json/header-only.json +15 -0
  37. divejson-0.3.0/fixtures/suunto_json/not-a-dive.divejson +18 -0
  38. divejson-0.3.0/fixtures/suunto_json/not-a-dive.json +17 -0
  39. divejson-0.3.0/fixtures/suunto_json/purged-regulator.divejson +73 -0
  40. divejson-0.3.0/fixtures/suunto_json/purged-regulator.json +50 -0
  41. divejson-0.3.0/fixtures/suunto_json/suunto-d5.divejson +123 -0
  42. divejson-0.3.0/fixtures/suunto_json/suunto-d5.json +76 -0
  43. divejson-0.3.0/fixtures/suunto_json/suunto-ocean.divejson +114 -0
  44. divejson-0.3.0/fixtures/suunto_json/suunto-ocean.json +111 -0
  45. divejson-0.3.0/tests/fitbuild.py +312 -0
  46. divejson-0.3.0/tests/helpers.py +125 -0
  47. {divejson-0.2.0 → divejson-0.3.0}/tests/test_cli.py +48 -1
  48. {divejson-0.2.0 → divejson-0.3.0}/tests/test_conform.py +69 -17
  49. divejson-0.3.0/tests/test_converter.py +187 -0
  50. divejson-0.3.0/tests/test_fit_fixtures.py +203 -0
  51. divejson-0.3.0/tests/test_fit_parsing.py +838 -0
  52. divejson-0.3.0/tests/test_fit_units.py +293 -0
  53. divejson-0.3.0/tests/test_registry.py +427 -0
  54. divejson-0.3.0/tests/test_release_bump.py +339 -0
  55. divejson-0.3.0/tests/test_series.py +205 -0
  56. divejson-0.3.0/tests/test_ssrf_fixtures.py +205 -0
  57. divejson-0.3.0/tests/test_ssrf_parsing.py +380 -0
  58. divejson-0.3.0/tests/test_ssrf_units.py +198 -0
  59. divejson-0.3.0/tests/test_suunto_json_fixtures.py +326 -0
  60. divejson-0.3.0/tests/test_suunto_json_parsing.py +564 -0
  61. divejson-0.3.0/tests/test_suunto_json_units.py +235 -0
  62. {divejson-0.2.0 → divejson-0.3.0}/tests/test_uddf_fixtures.py +38 -5
  63. {divejson-0.2.0 → divejson-0.3.0}/tests/test_uddf_parsing.py +50 -32
  64. {divejson-0.2.0 → divejson-0.3.0}/tests/test_uddf_units.py +26 -13
  65. divejson-0.3.0/tests/test_xmlsource.py +123 -0
  66. divejson-0.2.0/CHANGELOG.md +0 -51
  67. divejson-0.2.0/PKG-INFO +0 -119
  68. divejson-0.2.0/README.md +0 -101
  69. divejson-0.2.0/SPEC_REF +0 -1
  70. divejson-0.2.0/divejson/__init__.py +0 -64
  71. divejson-0.2.0/tests/helpers.py +0 -57
  72. {divejson-0.2.0 → divejson-0.3.0}/.github/workflows/ci.yml +0 -0
  73. {divejson-0.2.0 → divejson-0.3.0}/.github/workflows/pr-title.yml +0 -0
  74. {divejson-0.2.0 → divejson-0.3.0}/.github/workflows/release.yml +0 -0
  75. {divejson-0.2.0 → divejson-0.3.0}/.gitignore +0 -0
  76. {divejson-0.2.0 → divejson-0.3.0}/LICENSE +0 -0
  77. {divejson-0.2.0 → divejson-0.3.0}/divejson/py.typed +0 -0
  78. {divejson-0.2.0 → divejson-0.3.0}/divejson/validate.py +0 -0
  79. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/agency-other-missing.divejson +0 -0
  80. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/avg-depth-exceeds-max.divejson +0 -0
  81. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/bad-version.divejson +0 -0
  82. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/bbox-missing-corner.divejson +0 -0
  83. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/bbox-south-exceeds-north.divejson +0 -0
  84. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/bbox-without-position.divejson +0 -0
  85. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/channel-length-mismatch.divejson +0 -0
  86. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/course-dates-reversed.divejson +0 -0
  87. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/dangling-reference.divejson +0 -0
  88. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/duplicate-json-member.divejson +0 -0
  89. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/duplicate-uuid.divejson +0 -0
  90. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/event-other-without-label.divejson +0 -0
  91. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/missing-format.divejson +0 -0
  92. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/naive-exported-at.divejson +0 -0
  93. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/non-increasing-samples.divejson +0 -0
  94. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/null-member.divejson +0 -0
  95. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/oxygen-helium-sum.divejson +0 -0
  96. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/position-incomplete.divejson +0 -0
  97. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/pressure-order.divejson +0 -0
  98. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/profile-duration-short.divejson +0 -0
  99. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/species-no-identity.divejson +0 -0
  100. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/trailing-newline-datetime.divejson +0 -0
  101. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/trip-dates-reversed.divejson +0 -0
  102. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/undefined-member.divejson +0 -0
  103. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/version-before-format.divejson +0 -0
  104. {divejson-0.2.0 → divejson-0.3.0}/fixtures/invalid/version-not-second.divejson +0 -0
  105. {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/divelogs.divejson +0 -0
  106. {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/divelogs.uddf +0 -0
  107. {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/legacy-writer.divejson +0 -0
  108. {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/legacy-writer.uddf +0 -0
  109. {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/mix-only-cylinder.divejson +0 -0
  110. {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/mix-only-cylinder.uddf +0 -0
  111. {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/opendiving.divejson +0 -0
  112. {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/opendiving.uddf +0 -0
  113. {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/subsurface.divejson +0 -0
  114. {divejson-0.2.0 → divejson-0.3.0}/fixtures/uddf/subsurface.uddf +0 -0
  115. {divejson-0.2.0 → divejson-0.3.0}/fixtures/valid/demo-logbook.divejson +0 -0
  116. {divejson-0.2.0 → divejson-0.3.0}/fixtures/valid/minimal.divejson +0 -0
  117. {divejson-0.2.0 → divejson-0.3.0}/fixtures/valid/technical-dive.divejson +0 -0
  118. {divejson-0.2.0 → divejson-0.3.0}/pyproject.toml +0 -0
  119. {divejson-0.2.0 → divejson-0.3.0}/schema/1.0/divejson.schema.json +0 -0
  120. {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"