From 3014135cf0fa583872977016685efe57d1429c22 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Fri, 5 Jun 2026 20:20:56 +0100 Subject: [PATCH 1/2] fix(snap): bump snapcraft.yaml version to 1.13.15 The Snap Store was frozen at v1.13.9 since the v1.13.10 release because snap/snapcraft.yaml had a hardcoded version that the bump script did not touch. Revisions 25-29 were created on the Snap Store with the correct binary but the version string visible to users remained 1.13.9. This commit restores parity for v1.13.15. The next commit prevents this from recurring by making the bump script resilient to drift between app/constants.py and the other packaging files. Co-Authored-By: Claude Opus 4.7 --- snap/snapcraft.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index b5d3799..5c4f6b3 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -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, From 2b99b86d672f6c7c899cb4f77c4678c4980a46f3 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Fri, 5 Jun 2026 20:22:55 +0100 Subject: [PATCH 2/2] chore(release): make bump script resilient to constants.py drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the v1.13.9 Snap Store freeze: the bump script in release.yml derives OLD from app/constants.py and uses re.escape(OLD) to match every other packaging file. Between v1.13.9 and v1.13.13, app/constants.py was bumped manually without running release.yml, so the other packaging files (snap, rpm, winget, aur-bin, flatpak) drifted out of sync. From v1.13.13 onward the OLD value from constants.py no longer matched the strings in those files, and re.sub silently did nothing — the workflow reported "unchanged" and proceeded to tag the release. Fix: 1. Packaging-file regexes now match any semver (\d+\.\d+\.\d+) instead of the escaped OLD value, so drift no longer hides the substitution. 2. Each target is marked required/optional. If a required file produces no match AND is not already at NEW, the workflow fails loudly instead of pretending success. 3. Idempotency: re-running with NEW == OLD is a no-op, not a failure. 4. Regression tests in tests/test_release_bump_script.py extract the inline script from release.yml and exercise the exact failure mode (constants.py at 1.13.14, snap stuck at 1.13.9) plus an assertion that snap/snapcraft.yaml in the repo always matches APP_VERSION. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release.yml | 97 ++++++++++------- tests/test_release_bump_script.py | 172 ++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 37 deletions(-) create mode 100644 tests/test_release_bump_script.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92b1e06..c15cc51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 diff --git a/tests/test_release_bump_script.py b/tests/test_release_bump_script.py new file mode 100644 index 0000000..0e4ae61 --- /dev/null +++ b/tests/test_release_bump_script.py @@ -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" + )