Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 60 additions & 37 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,71 +55,81 @@ jobs:
NEW: ${{ inputs.version }}
run: |
python - <<'PY'
import os, re
import os, re, sys
from pathlib import Path

old = os.environ["OLD"]
new = os.environ["NEW"]
old_re = re.escape(old)
# Generic semver pattern. Used for packaging files where the
# version string may have drifted out of sync with app/constants.py
# (root cause of the v1.13.9 Snap Store freeze: snapcraft.yaml stayed
# at 1.13.9 from v1.13.10 onward because OLD no longer matched).
ANY = r"\d+\.\d+\.\d+"

# (path, list of (regex, replacement)) pairs.
# Each replacement uses {new} as the new version.
# (path, list of (regex, replacement), bool: required) tuples.
# required=True targets fail the job if no substitution happens.
targets = [
("app/constants.py", [
(rf'APP_VERSION\s*=\s*"{old_re}"', f'APP_VERSION = "{new}"'),
]),
], True),
("installer.py", [
(rf'APP_VERSION\s*=\s*"{old_re}"', f'APP_VERSION = "{new}"'),
]),
(rf'APP_VERSION\s*=\s*"{ANY}"', f'APP_VERSION = "{new}"'),
], True),
("docs/index.html", [
(rf'"softwareVersion":\s*"{old_re}"', f'"softwareVersion": "{new}"'),
(rf'"softwareVersion":\s*"{ANY}"', f'"softwareVersion": "{new}"'),
(rf'v{old_re}', f'v{new}'),
]),
], True),
("docs/changelog.html", [
(rf'v{old_re}', f'v{new}'),
]),
], False),
("snap/snapcraft.yaml", [
(rf"version:\s*'{old_re}'", f"version: '{new}'"),
]),
(rf"version:\s*'{ANY}'", f"version: '{new}'"),
], True),
("rpm/pdfapps.spec", [
(rf'^Version:\s+{old_re}', f'Version: {new}'),
]),
(rf'^Version:\s+{ANY}', f'Version: {new}'),
], True),
("aur/pdfapps/PKGBUILD", [
(rf'pkgver={old_re}', f'pkgver={new}'),
]),
(rf'pkgver={ANY}', f'pkgver={new}'),
], True),
("aur/pdfapps/.SRCINFO", [
(rf'pkgver = {old_re}', f'pkgver = {new}'),
(rf'{old_re}', new),
]),
(rf'pkgver = {ANY}', f'pkgver = {new}'),
(rf'pdfapps-{ANY}', f'pdfapps-{new}'),
(rf'v{ANY}', f'v{new}'),
], True),
("aur/pdfapps-bin/PKGBUILD", [
(rf'pkgver={old_re}', f'pkgver={new}'),
]),
(rf'pkgver={ANY}', f'pkgver={new}'),
], True),
("aur/pdfapps-bin/.SRCINFO", [
(rf'pkgver = {old_re}', f'pkgver = {new}'),
(rf'provides = pdfapps={old_re}', f'provides = pdfapps={new}'),
(rf'v{old_re}', f'v{new}'),
(rf'pdfapps-{old_re}', f'pdfapps-{new}'),
]),
(rf'pkgver = {ANY}', f'pkgver = {new}'),
(rf'provides = pdfapps={ANY}', f'provides = pdfapps={new}'),
(rf'v{ANY}', f'v{new}'),
(rf'pdfapps-{ANY}', f'pdfapps-{new}'),
], True),
("winget/nelsonduarte.PDFApps.installer.yaml", [
(rf'PackageVersion:\s*{old_re}', f'PackageVersion: {new}'),
(rf'v{old_re}', f'v{new}'),
]),
(rf'PackageVersion:\s*{ANY}', f'PackageVersion: {new}'),
(rf'v{ANY}', f'v{new}'),
], True),
("winget/nelsonduarte.PDFApps.locale.en-US.yaml", [
(rf'PackageVersion:\s*{old_re}', f'PackageVersion: {new}'),
(rf'v{old_re}', f'v{new}'),
]),
(rf'PackageVersion:\s*{ANY}', f'PackageVersion: {new}'),
(rf'v{ANY}', f'v{new}'),
], True),
("winget/nelsonduarte.PDFApps.yaml", [
(rf'PackageVersion:\s*{old_re}', f'PackageVersion: {new}'),
]),
(rf'PackageVersion:\s*{ANY}', f'PackageVersion: {new}'),
], True),
("flatpak/io.github.nelsonduarte.PDFApps.yml", [
(rf'tag:\s*v{old_re}', f'tag: v{new}'),
]),
(rf'tag:\s*v{ANY}', f'tag: v{new}'),
], True),
]

for path_str, subs in targets:
missing_required = []
stale_required = []
for path_str, subs, required in targets:
p = Path(path_str)
if not p.exists():
print(f"skip (missing): {path_str}")
if required:
missing_required.append(path_str)
continue
text = p.read_text(encoding="utf-8")
original = text
Expand All @@ -129,7 +139,20 @@ jobs:
p.write_text(text, encoding="utf-8")
print(f"updated: {path_str}")
else:
print(f"unchanged: {path_str}")
# Already at the new version? Then unchanged is fine.
if new in original:
print(f"already at {new}: {path_str}")
else:
print(f"unchanged (no match!): {path_str}")
if required:
stale_required.append(path_str)

if missing_required or stale_required:
if missing_required:
print(f"::error::Required packaging files missing: {missing_required}")
if stale_required:
print(f"::error::Required packaging files did not match any version pattern and are not at {new}: {stale_required}")
sys.exit(1)
PY

- name: Show diff
Expand Down
2 changes: 1 addition & 1 deletion snap/snapcraft.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: pdfapps
title: PDFApps
base: core22
version: '1.13.9'
version: '1.13.15'
summary: Fast, offline, subscription-free PDF editor
description: |
PDFApps is an all-in-one PDF editor with 13 built-in tools: split, merge,
Expand Down
172 changes: 172 additions & 0 deletions tests/test_release_bump_script.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""Regression test for the release.yml inline bump script.

Confirms the snap/snapcraft.yaml freeze bug (Snap Store stuck at v1.13.9
since the v1.13.10 release) cannot recur: even when app/constants.py has
drifted out of sync with the packaging files, the bump script must still
update every required target or fail loudly.
"""
from __future__ import annotations

import re
import subprocess
import sys
import textwrap
from pathlib import Path

import pytest
import yaml


REPO_ROOT = Path(__file__).resolve().parents[1]
RELEASE_YML = REPO_ROOT / ".github" / "workflows" / "release.yml"


def _extract_bump_script() -> str:
"""Pull the inline Python heredoc out of the release workflow."""
text = RELEASE_YML.read_text(encoding="utf-8")
match = re.search(
r"python - <<'PY'\n(.*?)\n PY",
text,
flags=re.DOTALL,
)
assert match, "could not locate inline bump script in release.yml"
body = match.group(1)
# Strip the leading 10-space YAML indentation.
return textwrap.dedent(body)


@pytest.fixture
def fake_repo(tmp_path: Path) -> Path:
"""Mirror the directory layout the bump script writes to."""
(tmp_path / "app").mkdir()
(tmp_path / "app" / "constants.py").write_text(
'APP_VERSION = "1.13.14"\n', encoding="utf-8"
)
(tmp_path / "installer.py").write_text(
'APP_VERSION = "1.13.14"\n', encoding="utf-8"
)
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "index.html").write_text(
'"softwareVersion": "1.13.14"\nv1.13.14\n', encoding="utf-8"
)
(tmp_path / "docs" / "changelog.html").write_text("v1.13.14\n", encoding="utf-8")
(tmp_path / "snap").mkdir()
# Frozen one minor behind app/constants.py — this is exactly the
# bug we're guarding against.
(tmp_path / "snap" / "snapcraft.yaml").write_text(
"name: pdfapps\nversion: '1.13.9'\n", encoding="utf-8"
)
(tmp_path / "rpm").mkdir()
(tmp_path / "rpm" / "pdfapps.spec").write_text(
"Name: pdfapps\nVersion: 1.13.9\n", encoding="utf-8"
)
for sub in ("pdfapps", "pdfapps-bin"):
(tmp_path / "aur" / sub).mkdir(parents=True)
(tmp_path / "aur" / sub / "PKGBUILD").write_text(
"pkgver=1.13.9\n", encoding="utf-8"
)
(tmp_path / "aur" / "pdfapps" / ".SRCINFO").write_text(
"pkgname = pdfapps\n\tpkgver = 1.13.9\n\tsource = pdfapps-1.13.9.tar.gz::"
"https://github.com/x/x/archive/v1.13.9.tar.gz\n",
encoding="utf-8",
)
(tmp_path / "aur" / "pdfapps-bin" / ".SRCINFO").write_text(
"pkgname = pdfapps-bin\n\tpkgver = 1.13.10\n\tprovides = pdfapps=1.13.10\n"
"\tsource = pdfapps-1.13.10.tar.gz::https://x/v1.13.10/y.tar.gz\n",
encoding="utf-8",
)
(tmp_path / "winget").mkdir()
(tmp_path / "winget" / "nelsonduarte.PDFApps.installer.yaml").write_text(
"PackageVersion: 1.13.9\nInstallerUrl: https://x/v1.13.9/y.exe\n",
encoding="utf-8",
)
(tmp_path / "winget" / "nelsonduarte.PDFApps.locale.en-US.yaml").write_text(
"PackageVersion: 1.13.9\nReleaseNotesUrl: https://x/v1.13.9\n",
encoding="utf-8",
)
(tmp_path / "winget" / "nelsonduarte.PDFApps.yaml").write_text(
"PackageVersion: 1.13.9\n", encoding="utf-8"
)
(tmp_path / "flatpak").mkdir()
(tmp_path / "flatpak" / "io.github.nelsonduarte.PDFApps.yml").write_text(
" sources:\n - type: git\n tag: v1.13.9\n",
encoding="utf-8",
)
return tmp_path


def _run_script(repo: Path, *, old: str, new: str) -> subprocess.CompletedProcess[str]:
script = _extract_bump_script()
return subprocess.run(
[sys.executable, "-c", script],
cwd=repo,
env={"OLD": old, "PATH": "", "NEW": new, "SYSTEMROOT": __import__("os").environ.get("SYSTEMROOT", "")},
capture_output=True,
text=True,
)


def test_bump_script_updates_snap_even_when_constants_drifted(fake_repo: Path) -> None:
"""The exact failure mode that froze the Snap Store at 1.13.9."""
result = _run_script(fake_repo, old="1.13.14", new="1.13.15")
assert result.returncode == 0, f"bump failed:\nstdout={result.stdout}\nstderr={result.stderr}"

snap = yaml.safe_load((fake_repo / "snap" / "snapcraft.yaml").read_text())
assert snap["version"] == "1.13.15", (
"snapcraft.yaml must move to the new version even though "
"the OLD value from constants.py never appeared in it"
)


def test_bump_script_updates_all_required_packaging_files(fake_repo: Path) -> None:
result = _run_script(fake_repo, old="1.13.14", new="1.13.15")
assert result.returncode == 0, result.stderr

# Spot-check each ecosystem.
assert 'APP_VERSION = "1.13.15"' in (fake_repo / "app" / "constants.py").read_text()
assert "Version: 1.13.15" in (fake_repo / "rpm" / "pdfapps.spec").read_text()
assert "pkgver=1.13.15" in (fake_repo / "aur" / "pdfapps" / "PKGBUILD").read_text()
assert "pkgver=1.13.15" in (fake_repo / "aur" / "pdfapps-bin" / "PKGBUILD").read_text()
assert "PackageVersion: 1.13.15" in (
fake_repo / "winget" / "nelsonduarte.PDFApps.installer.yaml"
).read_text()
assert "tag: v1.13.15" in (
fake_repo / "flatpak" / "io.github.nelsonduarte.PDFApps.yml"
).read_text()
# No stale 1.13.9 / 1.13.10 / 1.13.14 references in required files.
for path in [
fake_repo / "snap" / "snapcraft.yaml",
fake_repo / "rpm" / "pdfapps.spec",
fake_repo / "aur" / "pdfapps" / "PKGBUILD",
fake_repo / "aur" / "pdfapps-bin" / "PKGBUILD",
fake_repo / "winget" / "nelsonduarte.PDFApps.installer.yaml",
fake_repo / "flatpak" / "io.github.nelsonduarte.PDFApps.yml",
]:
body = path.read_text()
assert "1.13.9" not in body, f"{path.name} still references 1.13.9"
assert "1.13.10" not in body, f"{path.name} still references 1.13.10"
assert "1.13.14" not in body, f"{path.name} still references 1.13.14"


def test_bump_script_is_idempotent(fake_repo: Path) -> None:
"""Running the bump twice with the same NEW must not fail."""
first = _run_script(fake_repo, old="1.13.14", new="1.13.15")
assert first.returncode == 0, first.stderr
second = _run_script(fake_repo, old="1.13.15", new="1.13.15")
assert second.returncode == 0, (
f"second bump should be a no-op, got: {second.stdout}\n{second.stderr}"
)


def test_current_snapcraft_yaml_is_at_release_version() -> None:
"""Belt-and-braces guard against re-introducing the freeze."""
constants = (REPO_ROOT / "app" / "constants.py").read_text(encoding="utf-8")
match = re.search(r'APP_VERSION\s*=\s*"([^"]+)"', constants)
assert match, "APP_VERSION not found in app/constants.py"
app_version = match.group(1)

snap = yaml.safe_load((REPO_ROOT / "snap" / "snapcraft.yaml").read_text())
assert snap["version"] == app_version, (
f"snap/snapcraft.yaml version ({snap['version']!r}) is out of sync "
f"with app/constants.py ({app_version!r}) — Snap Store will freeze"
)