Skip to content

Commit c11ca80

Browse files
committed
Harden release upload flow
1 parent c19a0eb commit c11ca80

6 files changed

Lines changed: 158 additions & 39 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
python -m pip install --upgrade pip
2828
# django-rspack is a sibling package and is not published yet.
2929
python -m pip install "django-rspack @ git+https://github.com/shakacode/django-rspack.git@7b09b8baf8d23c822978fcb472672a811927f5e5"
30-
python -m pip install "Django==${{ matrix.django-version }}" -e .[dev]
30+
python -m pip install "Django==${{ matrix.django-version }}" -e '.[dev]'
3131
3232
- name: Ruff
3333
run: ruff check .
@@ -57,7 +57,7 @@ jobs:
5757
python -m pip install --upgrade pip
5858
# django-rspack is a sibling package and is not published yet.
5959
python -m pip install "django-rspack @ git+https://github.com/shakacode/django-rspack.git@7b09b8baf8d23c822978fcb472672a811927f5e5"
60-
python -m pip install -e .[dev]
60+
python -m pip install -e '.[dev]'
6161
6262
- name: Install example dependencies
6363
working-directory: example

.github/workflows/release.yml

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
python -m pip install --upgrade pip
4343
# django-rspack is a sibling package and is not published yet.
4444
python -m pip install "django-rspack @ git+https://github.com/shakacode/django-rspack.git@7b09b8baf8d23c822978fcb472672a811927f5e5"
45-
python -m pip install "Django==${{ matrix.django-version }}" -e .[dev]
45+
python -m pip install "Django==${{ matrix.django-version }}" -e '.[dev]'
4646
4747
- name: Ruff
4848
run: ruff check .
@@ -74,7 +74,7 @@ jobs:
7474
python -m pip install --upgrade pip
7575
# django-rspack is a sibling package and is not published yet.
7676
python -m pip install "django-rspack @ git+https://github.com/shakacode/django-rspack.git@7b09b8baf8d23c822978fcb472672a811927f5e5"
77-
python -m pip install -e .[dev]
77+
python -m pip install -e '.[dev]'
7878
7979
- name: Install example dependencies
8080
working-directory: example
@@ -116,7 +116,7 @@ jobs:
116116
from pathlib import Path
117117
import re
118118
119-
text = Path("src/react_on_django/__about__.py").read_text()
119+
text = Path("src/react_on_django/__about__.py").read_text(encoding="utf-8")
120120
match = re.search(r'^__version__ = "([^"]+)"$', text, re.M)
121121
if not match:
122122
raise SystemExit("Could not find __version__ in src/react_on_django/__about__.py")
@@ -131,14 +131,7 @@ jobs:
131131
PACKAGE_VERSION: ${{ steps.version.outputs.version }}
132132
INPUT_VERSION: ${{ inputs.version }}
133133
run: |
134-
expected_tag="v${PACKAGE_VERSION}"
135-
136-
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF_NAME}" != "${expected_tag}" ]]; then
137-
echo "Tag ${GITHUB_REF_NAME} does not match package version ${PACKAGE_VERSION}."
138-
exit 1
139-
fi
140-
141-
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && -n "${INPUT_VERSION}" && "${INPUT_VERSION}" != "${PACKAGE_VERSION}" ]]; then
134+
if [[ -n "${INPUT_VERSION}" && "${INPUT_VERSION}" != "${PACKAGE_VERSION}" ]]; then
142135
echo "Requested version ${INPUT_VERSION} does not match package version ${PACKAGE_VERSION}."
143136
exit 1
144137
fi

Makefile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
.PHONY: release release-dry-run
22

33
PYTHON ?= python
4+
_truthy = $(filter 1 true yes y on TRUE YES Y ON,$(strip $(1)))
45

56
release:
6-
$(PYTHON) scripts/release.py $(if $(VERSION),--version $(VERSION),) $(if $(REPOSITORY),--repository $(REPOSITORY),) $(if $(YES),--yes,) $(if $(SKIP_CHECKS),--skip-checks,) $(if $(SKIP_PUSH),--skip-push,)
7+
$(PYTHON) scripts/release.py $(if $(VERSION),--version $(VERSION),) $(if $(REPOSITORY),--repository $(REPOSITORY),) $(if $(call _truthy,$(YES)),--yes,) $(if $(call _truthy,$(SKIP_CHECKS)),--skip-checks,) $(if $(call _truthy,$(SKIP_PUSH)),--skip-push,)
78

89
release-dry-run:
9-
$(PYTHON) scripts/release.py --dry-run $(if $(VERSION),--version $(VERSION),) $(if $(REPOSITORY),--repository $(REPOSITORY),) $(if $(YES),--yes,) $(if $(SKIP_CHECKS),--skip-checks,)
10+
$(PYTHON) scripts/release.py --dry-run $(if $(VERSION),--version $(VERSION),) $(if $(REPOSITORY),--repository $(REPOSITORY),) $(if $(call _truthy,$(YES)),--yes,) $(if $(call _truthy,$(SKIP_CHECKS)),--skip-checks,)

RELEASING.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ The maintainer flow is:
44

55
1. Update `CHANGELOG.md` for the version you want to ship.
66
2. Run `make release`.
7-
3. Confirm the version, branch, checks, upload target, and push step.
8-
4. If you chose `pypi` or `testpypi`, `twine` uploads locally.
9-
5. Otherwise, push the tag and use the GitHub workflow manually if you still
10-
want CI-based publishing.
7+
3. Confirm the version, branch, checks, push step, and upload target.
8+
4. The command pushes the branch plus tag before any local `twine` upload.
9+
5. If you did not choose a local upload, use the GitHub workflow manually after
10+
the tag is pushed if you still want CI-based publishing.
1111

1212
`make release` is the Python equivalent of `rake release`. It validates the
1313
repo state, bumps `src/react_on_django/__about__.py` if needed, runs the
14-
release checks, creates the release commit, tags it, optionally uploads with
15-
`twine`, and then pushes the branch plus tag to GitHub.
14+
release checks, creates the release commit, tags it, pushes the branch plus tag
15+
to GitHub, and optionally uploads with `twine`.
1616

1717
## Before you run it
1818

@@ -89,6 +89,13 @@ Dry run without commit, tag, or push:
8989
make release-dry-run VERSION=0.1.0a1
9090
```
9191

92+
Boolean Makefile flags are enabled by truthy values like `1`, `true`, `yes`,
93+
`y`, or `on`. For example:
94+
95+
```bash
96+
make release-dry-run VERSION=0.1.0a1 YES=1 SKIP_CHECKS=true
97+
```
98+
9299
The release command runs:
93100

94101
- `ruff check .`
@@ -142,8 +149,12 @@ python -m pip install react-on-django==0.1.0a1
142149
4. Runs the release checks and local package build.
143150
5. Commits `CHANGELOG.md` and `src/react_on_django/__about__.py` if needed.
144151
6. Creates `vX.Y.Z` or `vX.Y.ZaN`.
145-
7. Optionally uploads `dist/*` with `twine upload --repository pypi|testpypi`.
146-
8. Pushes the current branch plus tags to `origin`.
152+
7. Pushes the current branch plus tags to `origin`.
153+
8. Optionally uploads `dist/*` with `twine upload --repository pypi|testpypi`.
154+
155+
The command refuses `SKIP_PUSH=1 REPOSITORY=pypi` or
156+
`SKIP_PUSH=1 REPOSITORY=testpypi` because a package upload should not exist
157+
without the matching commit and tag on GitHub.
147158

148159
## Local upload setup
149160

scripts/release.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,15 @@ def version_sort_key(version: ParsedVersion) -> tuple[int, int, int, int, int]:
9595

9696

9797
def read_current_version() -> ParsedVersion:
98-
text = VERSION_FILE.read_text()
98+
text = VERSION_FILE.read_text(encoding="utf-8")
9999
match = re.search(r'^__version__ = "([^"]+)"$', text, re.M)
100100
if not match:
101101
raise ReleaseError(f"Could not find __version__ in {VERSION_FILE}")
102102
return parse_version(match.group(1))
103103

104104

105105
def update_version_file(target_version: ParsedVersion) -> None:
106-
text = VERSION_FILE.read_text()
106+
text = VERSION_FILE.read_text(encoding="utf-8")
107107
updated = re.sub(
108108
r'^__version__ = "([^"]+)"$',
109109
f'__version__ = "{target_version}"',
@@ -113,36 +113,34 @@ def update_version_file(target_version: ParsedVersion) -> None:
113113
)
114114
if updated == text:
115115
raise ReleaseError(f"Could not update version in {VERSION_FILE}")
116-
VERSION_FILE.write_text(updated)
116+
VERSION_FILE.write_text(updated, encoding="utf-8")
117117

118118

119119
@contextmanager
120120
def prepared_version(
121121
target_version: ParsedVersion, *, restore_after_success: bool
122122
) -> Iterator[None]:
123-
original = VERSION_FILE.read_text()
123+
original = VERSION_FILE.read_text(encoding="utf-8")
124124
current_version = read_current_version()
125125
should_update = current_version != target_version
126126

127127
if should_update:
128128
update_version_file(target_version)
129129

130+
success = False
130131
try:
131132
yield
132-
except Exception:
133-
if should_update:
134-
VERSION_FILE.write_text(original)
135-
raise
136-
else:
137-
if should_update and restore_after_success:
138-
VERSION_FILE.write_text(original)
133+
success = True
134+
finally:
135+
if should_update and (restore_after_success or not success):
136+
VERSION_FILE.write_text(original, encoding="utf-8")
139137

140138

141139
def latest_changelog_version() -> ParsedVersion | None:
142140
if not CHANGELOG_FILE.exists():
143141
return None
144142

145-
for line in CHANGELOG_FILE.read_text().splitlines():
143+
for line in CHANGELOG_FILE.read_text(encoding="utf-8").splitlines():
146144
match = CHANGELOG_HEADER_RE.match(line.strip())
147145
if not match:
148146
continue
@@ -159,7 +157,7 @@ def extract_changelog_section(version: ParsedVersion) -> str | None:
159157
if not CHANGELOG_FILE.exists():
160158
return None
161159

162-
lines = CHANGELOG_FILE.read_text().splitlines()
160+
lines = CHANGELOG_FILE.read_text(encoding="utf-8").splitlines()
163161
collecting = False
164162
section_lines: list[str] = []
165163

@@ -389,6 +387,11 @@ def resolve_upload_plan(
389387
) -> UploadPlan:
390388
selected = parse_repository_name(repository)
391389
if dry_run:
390+
if selected is not None:
391+
print(
392+
f"Warning: ignoring --repository {selected} because --dry-run does not upload.",
393+
flush=True,
394+
)
392395
return UploadPlan(repository=None, source="dry-run")
393396
if selected is not None:
394397
return UploadPlan(repository=selected, source="explicit option")
@@ -397,6 +400,14 @@ def resolve_upload_plan(
397400
return prompt_upload_repository()
398401

399402

403+
def validate_upload_push_plan(*, skip_push: bool, upload_plan: UploadPlan) -> None:
404+
if skip_push and upload_plan.repository is not None:
405+
raise ReleaseError(
406+
"Cannot upload distributions with --skip-push. Push the release branch and "
407+
"tag before uploading to pypi or testpypi."
408+
)
409+
410+
400411
def upload_distributions(repository: str) -> None:
401412
artifacts = dist_artifacts()
402413
run(sys.executable, "-m", "twine", "upload", "--repository", repository, *artifacts)
@@ -488,6 +499,7 @@ def main(argv: list[str] | None = None) -> int:
488499
dry_run=args.dry_run,
489500
yes=args.yes,
490501
)
502+
validate_upload_push_plan(skip_push=args.skip_push, upload_plan=upload_plan)
491503
print_release_summary(
492504
context,
493505
dry_run=args.dry_run,
@@ -516,10 +528,10 @@ def main(argv: list[str] | None = None) -> int:
516528

517529
stage_and_commit_release_files(context.target_version)
518530
create_tag(context.target_version)
519-
if upload_plan.repository is not None:
520-
upload_distributions(upload_plan.repository)
521531
if not args.skip_push:
522532
push_release(context.branch)
533+
if upload_plan.repository is not None:
534+
upload_distributions(upload_plan.repository)
523535
finally:
524536
clean_build_artifacts()
525537

tests/test_release.py

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import importlib.util
22
import sys
3+
from contextlib import contextmanager
34
from pathlib import Path
45

56
import pytest
@@ -118,7 +119,7 @@ def test_release_check_commands_sets_example_python_for_e2e():
118119
release = load_release_module()
119120

120121
commands = list(release.release_check_commands(skip_checks=False))
121-
example_test = commands[-1]
122+
example_test = next(command for command in commands if command[0] == ["./bin/test-ci"])
122123

123124
assert example_test[0] == ["./bin/test-ci"]
124125
assert example_test[1].name == "example"
@@ -151,6 +152,21 @@ def test_prepared_version_restores_after_pre_commit_failure(tmp_path, monkeypatc
151152
assert version_file.read_text() == '__version__ = "0.1.0"\n'
152153

153154

155+
def test_prepared_version_restores_after_keyboard_interrupt(tmp_path, monkeypatch):
156+
release = load_release_module()
157+
version_file = tmp_path / "__about__.py"
158+
version_file.write_text('__version__ = "0.1.0"\n')
159+
monkeypatch.setattr(release, "VERSION_FILE", version_file)
160+
161+
with pytest.raises(KeyboardInterrupt):
162+
with release.prepared_version(
163+
release.parse_version("0.1.0a1"), restore_after_success=False
164+
):
165+
raise KeyboardInterrupt()
166+
167+
assert version_file.read_text() == '__version__ = "0.1.0"\n'
168+
169+
154170
def test_prepared_version_keeps_target_version_after_success(tmp_path, monkeypatch):
155171
release = load_release_module()
156172
version_file = tmp_path / "__about__.py"
@@ -200,3 +216,89 @@ def test_resolve_upload_plan_defaults_to_skip_when_yes_is_set():
200216

201217
assert plan.repository is None
202218
assert plan.source == "non-interactive default"
219+
220+
221+
def test_resolve_upload_plan_warns_when_dry_run_ignores_repository(capsys):
222+
release = load_release_module()
223+
224+
plan = release.resolve_upload_plan("testpypi", dry_run=True, yes=True)
225+
226+
assert plan.repository is None
227+
assert plan.source == "dry-run"
228+
assert (
229+
"ignoring --repository testpypi because --dry-run does not upload"
230+
in capsys.readouterr().out
231+
)
232+
233+
234+
def test_validate_upload_push_plan_rejects_upload_without_push():
235+
release = load_release_module()
236+
plan = release.UploadPlan(repository="pypi", source="explicit option")
237+
238+
with pytest.raises(
239+
release.ReleaseError,
240+
match="Cannot upload distributions with --skip-push",
241+
):
242+
release.validate_upload_push_plan(skip_push=True, upload_plan=plan)
243+
244+
245+
def test_main_pushes_release_refs_before_upload(monkeypatch):
246+
release = load_release_module()
247+
target = release.parse_version("0.1.0a1")
248+
current = release.parse_version("0.1.0")
249+
calls = []
250+
251+
@contextmanager
252+
def fake_prepared_version(*args, **kwargs):
253+
yield
254+
255+
monkeypatch.setattr(release, "read_current_version", lambda: current)
256+
monkeypatch.setattr(
257+
release,
258+
"resolve_target_version",
259+
lambda requested, current_version: target,
260+
)
261+
monkeypatch.setattr(
262+
release,
263+
"validate_repo_state",
264+
lambda version: release.ReleaseContext(
265+
branch="release-branch",
266+
current_version=current,
267+
target_version=version,
268+
changelog_dirty=False,
269+
version_dirty=False,
270+
),
271+
)
272+
monkeypatch.setattr(
273+
release,
274+
"resolve_upload_plan",
275+
lambda repository, dry_run, yes: release.UploadPlan(
276+
repository="testpypi", source="explicit option"
277+
),
278+
)
279+
monkeypatch.setattr(release, "print_release_summary", lambda *args, **kwargs: None)
280+
monkeypatch.setattr(release, "prepared_version", fake_prepared_version)
281+
monkeypatch.setattr(
282+
release,
283+
"run_release_checks",
284+
lambda skip_checks: calls.append("checks"),
285+
)
286+
monkeypatch.setattr(release, "build_distributions", lambda: calls.append("build"))
287+
monkeypatch.setattr(
288+
release,
289+
"stage_and_commit_release_files",
290+
lambda target_version: calls.append("commit"),
291+
)
292+
monkeypatch.setattr(release, "create_tag", lambda target_version: calls.append("tag"))
293+
monkeypatch.setattr(release, "push_release", lambda branch: calls.append("push"))
294+
monkeypatch.setattr(
295+
release,
296+
"upload_distributions",
297+
lambda repository: calls.append("upload"),
298+
)
299+
monkeypatch.setattr(release, "clean_build_artifacts", lambda: calls.append("clean"))
300+
301+
exit_code = release.main(["--version", "0.1.0a1", "--repository", "testpypi", "--yes"])
302+
303+
assert exit_code == 0
304+
assert calls == ["checks", "build", "commit", "tag", "push", "upload", "clean"]

0 commit comments

Comments
 (0)