Skip to content

Commit fdc843b

Browse files
synchronize-mip: record step refuses a repository move
The lockfile owns profile to repository. Record this release now fails if SOURCE_REPOSITORY differs from the locked repository, naming both and telling the maintainer to edit pydevices-lock.json on the PyDevices branch; otherwise it updates ref only. The script check remains defence in depth. Co-authored-by: Brad Barnett <bdbarnett@users.noreply.github.com>
1 parent 8b504fd commit fdc843b

2 files changed

Lines changed: 92 additions & 5 deletions

File tree

.github/workflows/reusable-synchronize-mip-package.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,15 @@ jobs:
9090
profile = os.environ["PUBLICATION_PROFILE"]
9191
if profile not in lock:
9292
raise SystemExit(f"{profile!r} is not in {path}; add it before publishing")
93-
lock[profile] = {
94-
"repository": os.environ["SOURCE_REPOSITORY"],
95-
"ref": os.environ["SOURCE_REF"],
96-
}
93+
entry = lock[profile]
94+
expected = entry["repository"]
95+
got = os.environ["SOURCE_REPOSITORY"]
96+
if expected != got:
97+
raise SystemExit(
98+
f"profile {profile!r} is locked to {expected}, not {got}; "
99+
f"edit pydevices-lock.json on the PyDevices branch to move the profile"
100+
)
101+
entry["ref"] = os.environ["SOURCE_REF"]
97102
path.write_text(json.dumps(lock, indent=2) + "\n")
98103
print(f"{profile} -> {lock[profile]['ref']}")
99104
PY

tests/test_synchronize_mip_package.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,18 @@
44

55
import importlib
66
import json
7+
import os
78
import subprocess
89
import sys
910
import tempfile
11+
import textwrap
1012
import unittest
1113
from pathlib import Path
1214

1315
REPO = Path(__file__).resolve().parents[1]
1416
SCRIPTS = REPO / "scripts"
1517
SYNC_SCRIPT = SCRIPTS / "synchronize_mip_package.py"
18+
SYNC_WORKFLOW = REPO / ".github/workflows/reusable-synchronize-mip-package.yml"
1619

1720

1821
def _load_module(name: str):
@@ -118,7 +121,10 @@ def test_audioeffects_dispatch_matches_lockfile(self) -> None:
118121
self.assertEqual(result.returncode, 0, result.stderr)
119122
self.assertTrue((mip / "micropython" / "audioeffects" / "manifest.py").is_file())
120123

121-
def test_stale_audioif_caller_is_rejected_when_lockfile_names_audiocomponents(self) -> None:
124+
def test_script_in_isolation_rejects_source_repository_name_that_disagrees_with_lockfile(self) -> None:
125+
# Defence in depth only. The publication workflow never presents this
126+
# mismatch: Record this release refuses a repository move before the
127+
# sync loop runs synchronize_mip_package.py.
122128
with tempfile.TemporaryDirectory() as tmp:
123129
root = Path(tmp)
124130
source = root / "source"
@@ -190,6 +196,82 @@ def test_lockfile_repository_reads_named_entry(self) -> None:
190196
)
191197

192198

199+
def record_release_python() -> str:
200+
text = SYNC_WORKFLOW.read_text(encoding="utf-8")
201+
start = text.index("- name: Record this release in the lockfile")
202+
block = text[start:]
203+
begin = block.index("python3 - <<'PY'\n") + len("python3 - <<'PY'\n")
204+
end = block.index("\n PY\n", begin)
205+
return textwrap.dedent(block[begin:end])
206+
207+
208+
def run_record_step(
209+
lockfile: Path,
210+
*,
211+
profile: str,
212+
repository: str,
213+
ref: str,
214+
) -> subprocess.CompletedProcess[str]:
215+
with tempfile.TemporaryDirectory() as tmp:
216+
script = Path(tmp) / "record_release.py"
217+
script.write_text(record_release_python(), encoding="utf-8")
218+
env = os.environ.copy()
219+
env.update(
220+
{
221+
"LOCKFILE": str(lockfile),
222+
"PUBLICATION_PROFILE": profile,
223+
"SOURCE_REPOSITORY": repository,
224+
"SOURCE_REF": ref,
225+
}
226+
)
227+
return subprocess.run(
228+
[sys.executable, str(script)],
229+
capture_output=True,
230+
text=True,
231+
env=env,
232+
check=False,
233+
)
234+
235+
236+
class RecordLockfileReleaseTests(unittest.TestCase):
237+
def test_matching_repository_updates_ref_only(self) -> None:
238+
with tempfile.TemporaryDirectory() as tmp:
239+
mip = Path(tmp)
240+
write_lockfile(mip, {"audioinstruments": "PyDevices/audiocomponents"})
241+
lockfile = mip / "pydevices-lock.json"
242+
before = json.loads(lockfile.read_text(encoding="utf-8"))
243+
result = run_record_step(
244+
lockfile,
245+
profile="audioinstruments",
246+
repository="PyDevices/audiocomponents",
247+
ref="v0.3.0",
248+
)
249+
self.assertEqual(result.returncode, 0, result.stderr)
250+
after = json.loads(lockfile.read_text(encoding="utf-8"))
251+
self.assertEqual(after["audioinstruments"]["repository"], "PyDevices/audiocomponents")
252+
self.assertEqual(after["audioinstruments"]["ref"], "v0.3.0")
253+
self.assertEqual(before["audioinstruments"]["repository"], after["audioinstruments"]["repository"])
254+
255+
def test_mismatched_repository_fails_without_writing(self) -> None:
256+
with tempfile.TemporaryDirectory() as tmp:
257+
mip = Path(tmp)
258+
write_lockfile(mip, {"audioinstruments": "PyDevices/audiocomponents"})
259+
lockfile = mip / "pydevices-lock.json"
260+
before = lockfile.read_text(encoding="utf-8")
261+
result = run_record_step(
262+
lockfile,
263+
profile="audioinstruments",
264+
repository="PyDevices/audioif",
265+
ref="v0.3.0",
266+
)
267+
self.assertNotEqual(result.returncode, 0)
268+
self.assertIn("PyDevices/audiocomponents", result.stderr)
269+
self.assertIn("PyDevices/audioif", result.stderr)
270+
self.assertIn("pydevices-lock.json", result.stderr)
271+
self.assertIn("PyDevices branch", result.stderr)
272+
self.assertEqual(lockfile.read_text(encoding="utf-8"), before)
273+
274+
193275
class SharedDescriptionTests(unittest.TestCase):
194276
def test_pydevices_manifest_uses_shared_description(self) -> None:
195277
text = sync.render_pydevices_manifest("pydevices", "1.2.3", ())

0 commit comments

Comments
 (0)