From b17c94c83d0a1fd85e608da66323ddc29e555acf Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 00:59:46 -0300 Subject: [PATCH 1/9] feat: declare managed workflow consumers --- consumers.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 consumers.json diff --git a/consumers.json b/consumers.json new file mode 100644 index 0000000..0f52e55 --- /dev/null +++ b/consumers.json @@ -0,0 +1,10 @@ +{ + "consumers": [ + { + "repository": "LibreCodeCoop/extract", + "workflows": [ + "reuse.yml" + ] + } + ] +} From fea3a000545a8f06b05b164ac07629d6be9a41db Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 01:00:02 -0300 Subject: [PATCH 2/9] feat: add safe consumer workflow synchronizer --- scripts/sync_consumer.py | 334 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 scripts/sync_consumer.py diff --git a/scripts/sync_consumer.py b/scripts/sync_consumer.py new file mode 100644 index 0000000..648be0d --- /dev/null +++ b/scripts/sync_consumer.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import argparse +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path + +LOCK_HEADER = ( + "# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors\n" + "# SPDX-License-Identifier: MIT\n" +) + + +@dataclass(frozen=True) +class Consumer: + repository: str + workflows: tuple[str, ...] + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def load_consumers(path: Path) -> list[Consumer]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("consumer manifest must be a JSON object") + + raw_consumers = payload.get("consumers") + if not isinstance(raw_consumers, list): + raise ValueError("consumer manifest must contain a consumers array") + + consumers: list[Consumer] = [] + seen: set[str] = set() + + for index, raw in enumerate(raw_consumers): + if not isinstance(raw, dict): + raise ValueError(f"consumers[{index}] must be an object") + + repository = raw.get("repository") + workflows = raw.get("workflows") + + if not isinstance(repository, str) or "/" not in repository: + raise ValueError(f"consumers[{index}].repository must be owner/name") + if repository in seen: + raise ValueError(f"duplicate consumer repository: {repository}") + seen.add(repository) + + if ( + not isinstance(workflows, list) + or not workflows + or not all(isinstance(item, str) and item for item in workflows) + ): + raise ValueError( + f"consumers[{index}].workflows must be a non-empty array of names" + ) + + if len(set(workflows)) != len(workflows): + raise ValueError(f"duplicate workflow in consumer {repository}") + + for workflow in workflows: + workflow_path = Path(workflow) + if ( + workflow_path.name != workflow + or workflow_path.suffix not in {".yml", ".yaml"} + ): + raise ValueError( + f"invalid workflow name for {repository}: {workflow}" + ) + + consumers.append( + Consumer(repository=repository, workflows=tuple(sorted(workflows))) + ) + + return consumers + + +def matrix(consumers: list[Consumer]) -> dict[str, list[dict[str, object]]]: + return { + "include": [ + { + "repository": consumer.repository, + "workflows": list(consumer.workflows), + } + for consumer in consumers + ] + } + + +def parse_lock(path: Path) -> dict[str, str]: + if not path.is_file(): + return {} + + entries: dict[str, str] = {} + for line_number, raw_line in enumerate( + path.read_text(encoding="utf-8").splitlines(), + start=1, + ): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + + parts = line.split() + if len(parts) != 2: + raise ValueError(f"invalid lock entry at line {line_number}") + + digest, workflow = parts + if ( + len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + ): + raise ValueError(f"invalid SHA-256 at line {line_number}") + + if workflow in entries: + raise ValueError(f"duplicate lock entry: {workflow}") + entries[workflow] = digest + + return entries + + +def write_lock(path: Path, entries: dict[str, str]) -> None: + lines = [LOCK_HEADER.rstrip("\n"), ""] + lines.extend(f"{entries[name]} {name}" for name in sorted(entries)) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def sync_consumer( + source_dir: Path, + target_dir: Path, + workflows: tuple[str, ...], + lock_path: Path, +) -> dict[str, object]: + lock_entries = parse_lock(lock_path) + results: list[dict[str, str]] = [] + failed = False + + for workflow in workflows: + source = source_dir / workflow + target = target_dir / ".github/workflows" / workflow + + if not source.is_file(): + results.append( + { + "workflow": workflow, + "status": "failed", + "message": "source template does not exist", + } + ) + failed = True + continue + + source_hash = sha256(source) + locked_hash = lock_entries.get(workflow) + + if locked_hash is None: + if target.is_file() and sha256(target) != source_hash: + results.append( + { + "workflow": workflow, + "status": "failed", + "message": ( + "existing workflow is not managed yet and differs " + "from the current template" + ), + } + ) + failed = True + continue + + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(source.read_bytes()) + lock_entries[workflow] = source_hash + results.append( + { + "workflow": workflow, + "status": "adopted", + "message": "workflow adopted into managed synchronization", + } + ) + continue + + if not target.is_file(): + results.append( + { + "workflow": workflow, + "status": "failed", + "message": "managed workflow was deleted locally", + } + ) + failed = True + continue + + current_hash = sha256(target) + if current_hash != locked_hash: + results.append( + { + "workflow": workflow, + "status": "failed", + "message": "local workflow diverged from its managed lock", + } + ) + failed = True + continue + + if source_hash == locked_hash: + results.append( + { + "workflow": workflow, + "status": "unchanged", + "message": "workflow is already current", + } + ) + continue + + target.write_bytes(source.read_bytes()) + lock_entries[workflow] = source_hash + results.append( + { + "workflow": workflow, + "status": "updated", + "message": "workflow updated to the current template", + } + ) + + write_lock(lock_path, lock_entries) + + return { + "ok": not failed, + "results": results, + } + + +def render_pull_request_body(repository: str, report: dict[str, object]) -> str: + lines = [ + "Automated synchronization from LibreCodeCoop/github-workflows.", + "", + f"Consumer: {repository}", + "", + "## Workflow status", + "", + ] + + icons = { + "adopted": "OK", + "updated": "UPDATED", + "unchanged": "UNCHANGED", + "failed": "FAILED", + } + + for item in report["results"]: + status = item["status"] + lines.append( + f"- {icons[status]} {item['workflow']} - {status}: {item['message']}" + ) + + lines.extend( + [ + "", + "Managed workflow files are updated only when their current SHA-256 " + "matches the previously recorded lock. Local divergence is never " + "overwritten silently.", + "", + ] + ) + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + + matrix_parser = subparsers.add_parser("matrix") + matrix_parser.add_argument("manifest", type=Path) + + sync_parser = subparsers.add_parser("sync") + sync_parser.add_argument("manifest", type=Path) + sync_parser.add_argument("repository") + sync_parser.add_argument("source_dir", type=Path) + sync_parser.add_argument("target_dir", type=Path) + sync_parser.add_argument("--report", type=Path) + sync_parser.add_argument("--body", type=Path) + + args = parser.parse_args() + + try: + consumers = load_consumers(args.manifest) + + if args.command == "matrix": + print(json.dumps(matrix(consumers), separators=(",", ":"))) + return 0 + + consumer = next( + (item for item in consumers if item.repository == args.repository), + None, + ) + if consumer is None: + raise ValueError(f"consumer is not declared: {args.repository}") + + lock_path = args.target_dir / ".github/librecode-workflows.lock" + report = sync_consumer( + args.source_dir, + args.target_dir, + consumer.workflows, + lock_path, + ) + + if args.report: + args.report.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + if args.body: + args.body.write_text( + render_pull_request_body(args.repository, report), + encoding="utf-8", + ) + + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 if report["ok"] else 1 + + except (OSError, ValueError) as error: + parser.error(str(error)) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From fba048df756e6c7b9cb14a1be7cab34b6f1109e9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 01:00:16 -0300 Subject: [PATCH 3/9] test: cover consumer workflow synchronization --- tests/test_sync_consumer.py | 211 ++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 tests/test_sync_consumer.py diff --git a/tests/test_sync_consumer.py b/tests/test_sync_consumer.py new file mode 100644 index 0000000..15b0c02 --- /dev/null +++ b/tests/test_sync_consumer.py @@ -0,0 +1,211 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from scripts.sync_consumer import ( + load_consumers, + matrix, + parse_lock, + render_pull_request_body, + sha256, + sync_consumer, +) + + +class SyncConsumerTest(unittest.TestCase): + def create_source(self, root: Path, content: str = "name: REUSE\n") -> Path: + source = root / "source" + source.mkdir(parents=True) + (source / "reuse.yml").write_text(content, encoding="utf-8") + return source + + def target_workflow(self, root: Path) -> Path: + return root / "target/.github/workflows/reuse.yml" + + def lock_path(self, root: Path) -> Path: + return root / "target/.github/librecode-workflows.lock" + + def test_load_consumers_and_matrix(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = Path(directory) / "consumers.json" + manifest.write_text( + json.dumps( + { + "consumers": [ + { + "repository": "LibreCodeCoop/extract", + "workflows": ["reuse.yml"], + } + ] + } + ), + encoding="utf-8", + ) + + consumers = load_consumers(manifest) + + self.assertEqual(consumers[0].repository, "LibreCodeCoop/extract") + self.assertEqual(consumers[0].workflows, ("reuse.yml",)) + self.assertEqual( + matrix(consumers), + { + "include": [ + { + "repository": "LibreCodeCoop/extract", + "workflows": ["reuse.yml"], + } + ] + }, + ) + + def test_adopts_matching_existing_workflow(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = self.create_source(root) + target = self.target_workflow(root) + target.parent.mkdir(parents=True) + target.write_bytes((source / "reuse.yml").read_bytes()) + + report = sync_consumer( + source, + root / "target", + ("reuse.yml",), + self.lock_path(root), + ) + + self.assertTrue(report["ok"]) + self.assertEqual(report["results"][0]["status"], "adopted") + lock = parse_lock(self.lock_path(root)) + self.assertEqual(lock["reuse.yml"], sha256(source / "reuse.yml")) + + def test_refuses_to_adopt_different_existing_workflow(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = self.create_source(root) + target = self.target_workflow(root) + target.parent.mkdir(parents=True) + target.write_text("name: Local\n", encoding="utf-8") + + report = sync_consumer( + source, + root / "target", + ("reuse.yml",), + self.lock_path(root), + ) + + self.assertFalse(report["ok"]) + self.assertEqual(report["results"][0]["status"], "failed") + self.assertEqual(target.read_text(encoding="utf-8"), "name: Local\n") + + def test_updates_when_target_matches_lock(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = self.create_source(root, "name: New\n") + target = self.target_workflow(root) + target.parent.mkdir(parents=True) + target.write_text("name: Old\n", encoding="utf-8") + old_hash = hashlib.sha256(b"name: Old\n").hexdigest() + lock = self.lock_path(root) + lock.parent.mkdir(parents=True) + lock.write_text( + "# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors\n" + "# SPDX-License-Identifier: MIT\n\n" + f"{old_hash} reuse.yml\n", + encoding="utf-8", + ) + + report = sync_consumer( + source, + root / "target", + ("reuse.yml",), + lock, + ) + + self.assertTrue(report["ok"]) + self.assertEqual(report["results"][0]["status"], "updated") + self.assertEqual(target.read_text(encoding="utf-8"), "name: New\n") + self.assertEqual(parse_lock(lock)["reuse.yml"], sha256(source / "reuse.yml")) + + def test_refuses_local_divergence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = self.create_source(root, "name: New\n") + target = self.target_workflow(root) + target.parent.mkdir(parents=True) + target.write_text("name: Local\n", encoding="utf-8") + old_hash = hashlib.sha256(b"name: Old\n").hexdigest() + lock = self.lock_path(root) + lock.parent.mkdir(parents=True) + lock.write_text( + "# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors\n" + "# SPDX-License-Identifier: MIT\n\n" + f"{old_hash} reuse.yml\n", + encoding="utf-8", + ) + + report = sync_consumer( + source, + root / "target", + ("reuse.yml",), + lock, + ) + + self.assertFalse(report["ok"]) + self.assertEqual(report["results"][0]["status"], "failed") + self.assertEqual(target.read_text(encoding="utf-8"), "name: Local\n") + self.assertEqual(parse_lock(lock)["reuse.yml"], old_hash) + + def test_reports_unchanged(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = self.create_source(root) + target = self.target_workflow(root) + target.parent.mkdir(parents=True) + target.write_bytes((source / "reuse.yml").read_bytes()) + digest = sha256(source / "reuse.yml") + lock = self.lock_path(root) + lock.parent.mkdir(parents=True) + lock.write_text( + "# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors\n" + "# SPDX-License-Identifier: MIT\n\n" + f"{digest} reuse.yml\n", + encoding="utf-8", + ) + + report = sync_consumer( + source, + root / "target", + ("reuse.yml",), + lock, + ) + + self.assertTrue(report["ok"]) + self.assertEqual(report["results"][0]["status"], "unchanged") + + def test_pull_request_body_explains_divergence_protection(self) -> None: + body = render_pull_request_body( + "LibreCodeCoop/extract", + { + "ok": True, + "results": [ + { + "workflow": "reuse.yml", + "status": "updated", + "message": "workflow updated to the current template", + } + ], + }, + ) + + self.assertIn("LibreCodeCoop/extract", body) + self.assertIn("SHA-256", body) + self.assertIn("never overwritten silently", body) + + +if __name__ == "__main__": + unittest.main() From 36138d59947415e44e344edf56f31840c5b70dd6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 01:00:33 -0300 Subject: [PATCH 4/9] feat: automate managed consumer workflow updates --- .github/workflows/sync-consumers.yml | 97 ++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 .github/workflows/sync-consumers.yml diff --git a/.github/workflows/sync-consumers.yml b/.github/workflows/sync-consumers.yml new file mode 100644 index 0000000..21dc69c --- /dev/null +++ b/.github/workflows/sync-consumers.yml @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Sync consumer workflows + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - 'workflow-templates/**' + - 'consumers.json' + - 'scripts/sync_consumer.py' + - '.github/workflows/sync-consumers.yml' + schedule: + - cron: '41 4 * * 0' + +permissions: + contents: read + +jobs: + consumers: + name: Build consumer matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - name: Checkout workflow source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build matrix + id: matrix + run: echo "matrix=$(python3 scripts/sync_consumer.py matrix consumers.json)" >> "$GITHUB_OUTPUT" + + sync: + name: Sync ${{ matrix.repository }} + needs: consumers + if: ${{ needs.consumers.outputs.matrix != '{"include":[]}' }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.consumers.outputs.matrix) }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout workflow source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: source + + - name: Checkout consumer + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ matrix.repository }} + token: ${{ secrets.WORKFLOW_UPDATE_TOKEN }} + persist-credentials: false + path: target + + - name: Synchronize managed workflows + id: sync + continue-on-error: true + working-directory: source + run: >- + python3 scripts/sync_consumer.py sync + consumers.json + "${{ matrix.repository }}" + workflow-templates + ../target + --report "../consumer-sync-report.json" + --body "../consumer-sync-pr.md" + + - name: Create consumer update pull request + if: ${{ steps.sync.outcome == 'success' }} + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ secrets.WORKFLOW_UPDATE_TOKEN }} + path: target + commit-message: 'ci: sync LibreCode workflow templates' + committer: GitHub + author: github-workflows bot + signoff: true + branch: 'automated/sync-librcode-workflows' + delete-branch: true + title: 'ci: sync LibreCode workflow templates' + body-path: consumer-sync-pr.md + add-paths: | + .github/workflows/** + .github/librecode-workflows.lock + + - name: Fail on consumer divergence + if: ${{ steps.sync.outcome == 'failure' }} + run: | + cat consumer-sync-pr.md + exit 1 From c01c01f5631e89b2304e46e27141cbf7bf7d07a1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 01:00:41 -0300 Subject: [PATCH 5/9] chore: license consumer manifest --- consumers.json.license | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 consumers.json.license diff --git a/consumers.json.license b/consumers.json.license new file mode 100644 index 0000000..1ce4e0c --- /dev/null +++ b/consumers.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +SPDX-License-Identifier: AGPL-3.0-or-later From 9d25b8dae1671430bd5fb450e75f01c7e94dc408 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 01:01:24 -0300 Subject: [PATCH 6/9] test: allow existing consumer metadata directory --- tests/test_sync_consumer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_sync_consumer.py b/tests/test_sync_consumer.py index 15b0c02..ad105fa 100644 --- a/tests/test_sync_consumer.py +++ b/tests/test_sync_consumer.py @@ -111,7 +111,7 @@ def test_updates_when_target_matches_lock(self) -> None: target.write_text("name: Old\n", encoding="utf-8") old_hash = hashlib.sha256(b"name: Old\n").hexdigest() lock = self.lock_path(root) - lock.parent.mkdir(parents=True) + lock.parent.mkdir(parents=True, exist_ok=True) lock.write_text( "# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors\n" "# SPDX-License-Identifier: MIT\n\n" @@ -140,7 +140,7 @@ def test_refuses_local_divergence(self) -> None: target.write_text("name: Local\n", encoding="utf-8") old_hash = hashlib.sha256(b"name: Old\n").hexdigest() lock = self.lock_path(root) - lock.parent.mkdir(parents=True) + lock.parent.mkdir(parents=True, exist_ok=True) lock.write_text( "# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors\n" "# SPDX-License-Identifier: MIT\n\n" @@ -169,7 +169,7 @@ def test_reports_unchanged(self) -> None: target.write_bytes((source / "reuse.yml").read_bytes()) digest = sha256(source / "reuse.yml") lock = self.lock_path(root) - lock.parent.mkdir(parents=True) + lock.parent.mkdir(parents=True, exist_ok=True) lock.write_text( "# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors\n" "# SPDX-License-Identifier: MIT\n\n" From 8d339245ab52c6222d4d7b47e0e0a863b8451477 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 01:01:27 -0300 Subject: [PATCH 7/9] fix: pass consumer repository through environment --- .github/workflows/sync-consumers.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-consumers.yml b/.github/workflows/sync-consumers.yml index 21dc69c..9a4a1aa 100644 --- a/.github/workflows/sync-consumers.yml +++ b/.github/workflows/sync-consumers.yml @@ -63,10 +63,12 @@ jobs: id: sync continue-on-error: true working-directory: source + env: + CONSUMER_REPOSITORY: ${{ matrix.repository }} run: >- python3 scripts/sync_consumer.py sync consumers.json - "${{ matrix.repository }}" + "$CONSUMER_REPOSITORY" workflow-templates ../target --report "../consumer-sync-report.json" From 7f84aff0258b496f1c27a46ed59094d99b56928b Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 01:02:19 -0300 Subject: [PATCH 8/9] fix: avoid SPDX false positive in generated lock header --- scripts/sync_consumer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sync_consumer.py b/scripts/sync_consumer.py index 648be0d..916c3f6 100644 --- a/scripts/sync_consumer.py +++ b/scripts/sync_consumer.py @@ -12,7 +12,7 @@ LOCK_HEADER = ( "# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors\n" - "# SPDX-License-Identifier: MIT\n" + "# SPDX-" + "License-Identifier: MIT\n" ) From acc4ee0ab0f6942ff99ba7ec8229cedda5cbeeb9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 01:02:21 -0300 Subject: [PATCH 9/9] test: reuse generated lock header fixture --- tests/test_sync_consumer.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/test_sync_consumer.py b/tests/test_sync_consumer.py index ad105fa..e9f8099 100644 --- a/tests/test_sync_consumer.py +++ b/tests/test_sync_consumer.py @@ -8,6 +8,7 @@ from pathlib import Path from scripts.sync_consumer import ( + LOCK_HEADER, load_consumers, matrix, parse_lock, @@ -113,9 +114,7 @@ def test_updates_when_target_matches_lock(self) -> None: lock = self.lock_path(root) lock.parent.mkdir(parents=True, exist_ok=True) lock.write_text( - "# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors\n" - "# SPDX-License-Identifier: MIT\n\n" - f"{old_hash} reuse.yml\n", + LOCK_HEADER + "\n" + f"{old_hash} reuse.yml\n", encoding="utf-8", ) @@ -142,9 +141,7 @@ def test_refuses_local_divergence(self) -> None: lock = self.lock_path(root) lock.parent.mkdir(parents=True, exist_ok=True) lock.write_text( - "# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors\n" - "# SPDX-License-Identifier: MIT\n\n" - f"{old_hash} reuse.yml\n", + LOCK_HEADER + "\n" + f"{old_hash} reuse.yml\n", encoding="utf-8", ) @@ -171,9 +168,7 @@ def test_reports_unchanged(self) -> None: lock = self.lock_path(root) lock.parent.mkdir(parents=True, exist_ok=True) lock.write_text( - "# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors\n" - "# SPDX-License-Identifier: MIT\n\n" - f"{digest} reuse.yml\n", + LOCK_HEADER + "\n" + f"{digest} reuse.yml\n", encoding="utf-8", )