From 30f9ed152a045769c13a060f72ca119d5527fec5 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 01:17:07 +0200 Subject: [PATCH] feat(release): publish verified source tags --- docs/RELEASE_CONSOLE.md | 43 +- tests/test_release_repository_tag.py | 211 +++++++++ tests/test_release_source_provenance.py | 335 ++++++++++++++ tools/release/govoplan_release/__init__.py | 5 + tools/release/govoplan_release/publisher.py | 19 + .../govoplan_release/repository_tag.py | 426 +++++++++++++++++ .../govoplan_release/selective_catalog.py | 74 ++- .../govoplan_release/selective_planner.py | 35 +- .../govoplan_release/source_provenance.py | 438 ++++++++++++++++++ tools/release/release-catalog.py | 4 + tools/release/server/app.py | 64 ++- tools/release/webui/index.html | 121 ++++- 12 files changed, 1735 insertions(+), 40 deletions(-) create mode 100644 tests/test_release_repository_tag.py create mode 100644 tests/test_release_source_provenance.py create mode 100644 tools/release/govoplan_release/repository_tag.py create mode 100644 tools/release/govoplan_release/source_provenance.py diff --git a/docs/RELEASE_CONSOLE.md b/docs/RELEASE_CONSOLE.md index c7c311a..7430a79 100644 --- a/docs/RELEASE_CONSOLE.md +++ b/docs/RELEASE_CONSOLE.md @@ -18,6 +18,8 @@ candidate/publish actions: - configure target versions per release unit in the web UI - select the repositories that should advance through checkboxes - generate dry-run selective release plans for independently versioned packages +- create annotated source release tags for selected clean, version-aligned + repositories and publish each branch/tag pair atomically - generate signed catalog candidates for the selected release units - preview/apply/push reviewed catalog candidates behind explicit confirmations @@ -56,13 +58,50 @@ Plain repository pushes are separate from catalog publication. `Preview Push` shows the selected repository push commands. `Push Selected` requires `PUSH` in the repository push confirmation field. +The source release panel closes the former gap between a release plan and a +catalog candidate. `Preview Tag + Publish` is non-mutating. `Create Tags` +requires `TAG`; `Publish Tags` requires `PUBLISH` and atomically pushes the +selected branch and annotated tag. The gate requires an aligned target version, +a clean named branch with a HEAD, and a checkout that is not behind. Existing +local or remote tags must resolve to the selected HEAD and are never moved. +The local and remote annotated tag objects must also be identical, not merely +point at the same commit. + The catalog workflow panel can also operate on the same selected rows: - `Generate` creates a signed candidate below `runtime/release-candidates/`. - `Preview` validates a candidate and shows what would be copied into the website repository. -- `Apply + Tag` requires `APPLY` in the confirmation field. -- `Push` requires `PUSH` in the confirmation field. +- `Apply + Website Tag` requires `APPLY` in the confirmation field. +- `Push Website Release` requires `PUSH` in the confirmation field. + +Source release tags belong to Core or module repositories. Website catalog +publication creates a separate catalog tag in the website repository; the UI +names these independently to avoid confusing the two immutable references. + +Candidate signing is fail-closed: every Core and module source ref in the +complete post-update catalog must already have the requested annotated tag both +locally and on the configured source remote. Both tags must be the same +annotation object with version-aligned tagged package metadata. Selected tags +must additionally resolve to the selected clean, version-aligned HEAD, and the +signed `selected_units` record captures the peeled commit and tag-object +identifiers. Create and publish the source tags before using `Generate`. + +Catalog publication repeats that check for selected units and also verifies +every Core and module source ref in the complete candidate, including entries +preserved from the previous catalog. Historical refs need not be at the current +worktree HEAD, but their local and remote annotated tags must match and their +tagged installable package and module-manifest metadata must declare the catalog +version. New selected releases additionally pass the stricter current-source +alignment gate, including public runtime version declarations. A candidate +cannot be applied or published while any referenced source tag is absent or +inconsistent; the preview and API response list each repository and missing or +invalid ref so the operator can repair the exact source releases first. + +Read-only provenance checks derive a same-host HTTPS Git URL from conventional +SSH remotes when possible, with a non-interactive check of the configured remote +as fallback. Source tag creation and atomic publication always use the explicit +configured Git remote. The default signing key is `$HOME/.config/govoplan/release-keys/release-key-1.pem` when no signing key is diff --git a/tests/test_release_repository_tag.py b/tests/test_release_repository_tag.py new file mode 100644 index 0000000..822efbd --- /dev/null +++ b/tests/test_release_repository_tag.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from fastapi.testclient import TestClient + + +META_ROOT = Path(__file__).resolve().parents[1] +RELEASE_ROOT = META_ROOT / "tools" / "release" +if str(RELEASE_ROOT) not in sys.path: + sys.path.insert(0, str(RELEASE_ROOT)) + +from govoplan_release.repository_tag import tag_repositories # noqa: E402 +from server.app import create_app # noqa: E402 + + +class ReleaseRepositoryTagTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.workspace = self.root / "workspace" + self.workspace.mkdir() + self.repo, self.remote = create_release_repo( + root=self.root, + workspace=self.workspace, + name="govoplan-core", + version="0.1.10", + ) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_core_preview_is_non_mutating_and_publish_is_idempotent(self) -> None: + preview = tag_repositories( + repos=("govoplan-core",), + repo_versions={"govoplan-core": "0.1.10"}, + workspace_root=self.workspace, + apply=False, + push=True, + ) + + self.assertEqual(preview["status"], "planned") + row = preview["repositories"][0] + self.assertEqual(row["status"], "planned") + self.assertIn("git tag -a v0.1.10", row["create_command"]) + self.assertIn("git push --atomic origin", row["publish_command"]) + self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10")) + self.assertFalse(ref_exists(self.remote, "refs/tags/v0.1.10")) + + published = tag_repositories( + repos=("govoplan-core",), + repo_versions={"govoplan-core": "0.1.10"}, + workspace_root=self.workspace, + apply=True, + push=True, + ) + head = git_text(self.repo, "rev-parse", "HEAD") + + self.assertEqual(published["status"], "published") + self.assertEqual(git_text(self.repo, "cat-file", "-t", "refs/tags/v0.1.10"), "tag") + self.assertEqual(git_text(self.repo, "rev-parse", "refs/tags/v0.1.10^{commit}"), head) + self.assertEqual(git_text(self.remote, "rev-parse", "refs/tags/v0.1.10^{commit}"), head) + + repeated = tag_repositories( + repos=("govoplan-core",), + repo_versions={"govoplan-core": "0.1.10"}, + workspace_root=self.workspace, + apply=True, + push=True, + ) + self.assertEqual(repeated["status"], "published") + self.assertIn("already published", repeated["repositories"][0]["detail"]) + self.assertEqual(git_text(self.remote, "rev-parse", "refs/tags/v0.1.10^{commit}"), head) + + def test_core_alignment_and_clean_worktree_are_release_gates(self) -> None: + mismatch = tag_repositories( + repos=("govoplan-core",), + repo_versions={"govoplan-core": "0.1.11"}, + workspace_root=self.workspace, + ) + self.assertEqual(mismatch["status"], "blocked") + self.assertIn("version alignment gate failed", mismatch["repositories"][0]["detail"]) + + (self.repo / "uncommitted.txt").write_text("not reviewed\n", encoding="utf-8") + dirty = tag_repositories( + repos=("govoplan-core",), + repo_versions={"govoplan-core": "0.1.10"}, + workspace_root=self.workspace, + ) + self.assertEqual(dirty["status"], "blocked") + self.assertIn("worktree is not clean", dirty["repositories"][0]["detail"]) + self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10")) + + def test_batch_preflight_blocks_every_mutation_when_a_later_repo_is_dirty(self) -> None: + access, access_remote = create_release_repo( + root=self.root, + workspace=self.workspace, + name="govoplan-access", + version="0.1.10", + ) + (access / "uncommitted.txt").write_text("not reviewed\n", encoding="utf-8") + + result = tag_repositories( + repos=("govoplan-core", "govoplan-access"), + repo_versions={"govoplan-core": "0.1.10", "govoplan-access": "0.1.10"}, + workspace_root=self.workspace, + apply=True, + push=True, + ) + + self.assertEqual(result["status"], "blocked") + self.assertIn("no selected repository was mutated", result["detail"]) + self.assertEqual(result["repositories"][0]["status"], "skipped") + self.assertEqual(result["repositories"][1]["status"], "blocked") + for repository in (self.repo, self.remote, access, access_remote): + self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10")) + + def test_api_requires_explicit_confirmation_and_ui_exposes_source_release(self) -> None: + with TestClient(create_app(workspace_root=self.workspace)) as client: + rejected = client.post( + "/api/repositories/tag", + json={ + "repos": ["govoplan-core"], + "repo_versions": {"govoplan-core": "0.1.10"}, + "apply": True, + "push": True, + "confirm": "TAG", + }, + ) + preview = client.post( + "/api/repositories/tag", + json={ + "repos": ["govoplan-core"], + "repo_versions": {"govoplan-core": "0.1.10"}, + "apply": False, + "push": True, + }, + ) + ui = client.get("/") + + self.assertEqual(rejected.status_code, 400) + self.assertEqual(preview.status_code, 200) + self.assertEqual(preview.json()["repositories"][0]["repo"], "govoplan-core") + self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10")) + self.assertIn('id="publishReleaseTags"', ui.text) + self.assertIn('postJson("/api/repositories/tag"', ui.text) + self.assertIn("Signed Website Catalog", ui.text) + self.assertIn("Apply + Website Tag", ui.text) + + +def git(cwd: Path, *args: str) -> None: + result = subprocess.run( + ("git", *args), + cwd=cwd, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + raise AssertionError(f"git {' '.join(args)} failed: {result.stderr or result.stdout}") + + +def create_release_repo(*, root: Path, workspace: Path, name: str, version: str) -> tuple[Path, Path]: + remote = root / f"{name}.git" + repo = workspace / name + git(root, "init", "--bare", str(remote)) + git(workspace, "init", "-b", "main", str(repo)) + git(repo, "config", "user.name", "GovOPlaN Release Test") + git(repo, "config", "user.email", "release-test@example.invalid") + (repo / "pyproject.toml").write_text( + f'[project]\nname = "{name}"\nversion = "{version}"\n', + encoding="utf-8", + ) + git(repo, "add", "pyproject.toml") + git(repo, "commit", "-m", f"{name} {version}") + git(repo, "remote", "add", "origin", str(remote)) + git(repo, "push", "-u", "origin", "main") + return repo, remote + + +def git_text(cwd: Path, *args: str) -> str: + result = subprocess.run( + ("git", *args), + cwd=cwd, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + raise AssertionError(f"git {' '.join(args)} failed: {result.stderr or result.stdout}") + return result.stdout.strip() + + +def ref_exists(cwd: Path, ref: str) -> bool: + return subprocess.run( + ("git", "rev-parse", "--verify", ref), + cwd=cwd, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode == 0 + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_source_provenance.py b/tests/test_release_source_provenance.py new file mode 100644 index 0000000..bbc949b --- /dev/null +++ b/tests/test_release_source_provenance.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from fastapi.testclient import TestClient + + +META_ROOT = Path(__file__).resolve().parents[1] +RELEASE_ROOT = META_ROOT / "tools" / "release" +if str(RELEASE_ROOT) not in sys.path: + sys.path.insert(0, str(RELEASE_ROOT)) + +from govoplan_release.publisher import publish_catalog_candidate # noqa: E402 +from govoplan_release.selective_catalog import ( # noqa: E402 + build_selective_catalog_candidate, + enforce_selected_source_provenance, +) +from govoplan_release.source_provenance import ( # noqa: E402 + catalog_source_selection, + read_only_https_remote, + source_tag_provenance_issues, + tagged_source_version_issues, +) +from server.app import create_app # noqa: E402 + + +class ReleaseSourceProvenanceTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.workspace = self.root / "workspace" + self.workspace.mkdir() + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_selected_source_requires_local_and_remote_annotated_tag_at_clean_head(self) -> None: + repo, remote = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3") + + missing = source_tag_provenance_issues( + repo_versions={"govoplan-core": "1.2.3"}, + workspace=self.workspace, + require_head_repos=("govoplan-core",), + ) + self.assertIn("missing from the local source repository", messages(missing)) + self.assertIn("missing from remote 'origin'", messages(missing)) + + git(repo, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3") + git(repo, "push", "origin", "refs/tags/v1.2.3") + self.assertEqual( + (), + source_tag_provenance_issues( + repo_versions={"govoplan-core": "1.2.3"}, + workspace=self.workspace, + require_head_repos=("govoplan-core",), + ), + ) + + git(remote, "config", "user.name", "Different Release Actor") + git(remote, "config", "user.email", "different@example.invalid") + git(remote, "tag", "-f", "-a", "v1.2.3", git_text(repo, "rev-parse", "HEAD"), "-m", "Different annotation") + inconsistent = source_tag_provenance_issues( + repo_versions={"govoplan-core": "1.2.3"}, + workspace=self.workspace, + require_head_repos=("govoplan-core",), + ) + self.assertIn("local and remote annotated tag objects differ", messages(inconsistent)) + + (repo / "dirty.txt").write_text("not reviewed\n", encoding="utf-8") + dirty = source_tag_provenance_issues( + repo_versions={"govoplan-core": "1.2.3"}, + workspace=self.workspace, + require_head_repos=("govoplan-core",), + ) + self.assertIn("selected source worktree is not clean", messages(dirty)) + self.assertEqual(git_text(remote, "rev-parse", "refs/tags/v1.2.3^{commit}"), git_text(repo, "rev-parse", "HEAD")) + + def test_selected_source_gate_is_fail_closed_before_signing(self) -> None: + make_repo(self.workspace, self.root, "govoplan-core", "1.2.3") + + with self.assertRaisesRegex(ValueError, "Source tag provenance gate failed") as error: + enforce_selected_source_provenance( + repo_versions={"govoplan-core": "1.2.3"}, + workspace=self.workspace, + ) + + self.assertIn("v1.2.3", str(error.exception)) + self.assertIn("missing", str(error.exception)) + + def test_historical_tag_uses_installable_and_manifest_version_metadata(self) -> None: + repo, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3") + package = repo / "src" / "govoplan_core" + package.mkdir(parents=True) + (package / "__init__.py").write_text('__version__ = "1.2.2"\n', encoding="utf-8") + git(repo, "add", "src/govoplan_core/__init__.py") + git(repo, "commit", "-m", "Keep legacy runtime version string") + git(repo, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3") + + issues = tagged_source_version_issues( + path=repo, + repo="govoplan-core", + tag="v1.2.3", + expected_version="1.2.3", + ) + + self.assertEqual((), issues) + + def test_signed_candidate_records_selected_commit_and_tag_object(self) -> None: + core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3") + git(core, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3") + git(core, "push", "origin", "refs/tags/v1.2.3") + base_catalog = self.root / "base.json" + base_catalog.write_text( + json.dumps( + { + "channel": "stable", + "sequence": 1, + "core_release": { + "version": "1.2.2", + "python_ref": "govoplan-core @ git+ssh://git@example.test/acme/govoplan-core.git@v1.2.2", + }, + "modules": [], + "release": {"version": "1.2.2", "tag": "v1.2.2"}, + } + ), + encoding="utf-8", + ) + private_key = Ed25519PrivateKey.generate() + key_path = self.root / "release.pem" + key_path.write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + output = self.root / "candidate" + + with patch( + "govoplan_release.selective_catalog.validate_module_package_catalog", + return_value={"valid": True, "warnings": [], "error": None}, + ): + result = build_selective_catalog_candidate( + repo_versions={"govoplan-core": "1.2.3"}, + workspace_root=self.workspace, + output_dir=output, + base_catalog=base_catalog, + signing_keys=(f"test-key={key_path}",), + check_public=False, + ) + + payload = json.loads(Path(result.catalog_path).read_text(encoding="utf-8")) + selected = payload["release"]["selected_units"][0] + self.assertEqual(git_text(core, "rev-parse", "refs/tags/v1.2.3^{commit}"), selected["commit_sha"]) + self.assertEqual(git_text(core, "rev-parse", "refs/tags/v1.2.3"), selected["tag_object_sha"]) + self.assertEqual("test-key", payload["signatures"][0]["key_id"]) + + def test_catalog_publication_checks_every_preserved_source_ref(self) -> None: + core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3") + git(core, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3") + git(core, "push", "origin", "refs/tags/v1.2.3") + make_repo(self.workspace, self.root, "govoplan-access", "1.1.0") + + candidate = self.root / "candidate" + (candidate / "channels").mkdir(parents=True) + payload = catalog_payload( + commit_sha="f" * 40, + tag_object_sha=git_text(core, "rev-parse", "refs/tags/v1.2.3"), + ) + (candidate / "channels" / "stable.json").write_text(json.dumps(payload), encoding="utf-8") + keyring = {"keys": [{"key_id": "release-key", "status": "active", "public_key": "trusted"}]} + (candidate / "keyring.json").write_text(json.dumps(keyring), encoding="utf-8") + web_root = self.root / "website" + target_keyring = web_root / "public" / "catalogs" / "v1" / "keyring.json" + target_keyring.parent.mkdir(parents=True) + target_keyring.write_text(json.dumps(keyring), encoding="utf-8") + + with patch( + "govoplan_release.publisher.validate_module_package_catalog", + return_value={"valid": True, "warnings": [], "error": None}, + ): + result = publish_catalog_candidate( + candidate_dir=candidate, + workspace_root=self.workspace, + web_root=web_root, + ) + + detail = " ".join(result.notes) + self.assertEqual("blocked", result.status) + self.assertIn("govoplan-access v1.1.0", detail) + self.assertIn("missing from the local source repository", detail) + self.assertIn("missing from remote 'origin'", detail) + self.assertIn("no longer matches signed commit_sha", detail) + self.assertNotIn("govoplan-core v1.2.3: annotated tag is missing", detail) + + def test_catalog_selection_includes_core_and_all_modules(self) -> None: + selection = catalog_source_selection(catalog_payload()) + + self.assertEqual( + {"govoplan-core": "1.2.3", "govoplan-access": "1.1.0"}, + selection.all_versions, + ) + self.assertEqual({"govoplan-core": "1.2.3"}, selection.selected_versions) + self.assertEqual((), selection.issues) + + def test_console_renders_publication_provenance_notes(self) -> None: + ui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8") + + self.assertIn("Array.isArray(result.notes)", ui) + self.assertIn("Validation and provenance details", ui) + + def test_console_returns_actionable_conflict_for_candidate_provenance_gate(self) -> None: + with patch( + "server.app.build_selective_catalog_candidate", + side_effect=ValueError("Complete catalog source provenance gate failed: govoplan-core v1.2.3: missing"), + ): + with TestClient(create_app(workspace_root=self.workspace)) as client: + response = client.post( + "/api/catalog-candidates", + json={ + "repo_versions": {"govoplan-core": "1.2.3"}, + "signing_keys": ["test=/unused/key.pem"], + }, + ) + + self.assertEqual(409, response.status_code) + self.assertIn("govoplan-core v1.2.3: missing", response.json()["detail"]) + + def test_read_only_ref_checks_can_reuse_the_same_gitea_https_endpoint(self) -> None: + self.assertEqual( + "https://git.example.test/acme/govoplan-core.git", + read_only_https_remote("git@git.example.test:acme/govoplan-core.git"), + ) + self.assertEqual( + "https://git.example.test/acme/govoplan-core.git", + read_only_https_remote("ssh://git@git.example.test/acme/govoplan-core.git"), + ) + self.assertIsNone(read_only_https_remote("ssh://git@git.example.test:2222/acme/govoplan-core.git")) + self.assertIsNone(read_only_https_remote("/srv/git/govoplan-core.git")) + + +def catalog_payload( + *, + commit_sha: str = "0" * 40, + tag_object_sha: str = "1" * 40, +) -> dict[str, object]: + return { + "channel": "stable", + "core_release": { + "version": "1.2.3", + "python_ref": "govoplan-core @ git+ssh://git@example.test/acme/govoplan-core.git@v1.2.3", + }, + "modules": [ + { + "module_id": "access", + "version": "1.1.0", + "python_package": "govoplan-access", + "python_ref": "govoplan-access @ git+ssh://git@example.test/acme/govoplan-access.git@v1.1.0", + } + ], + "release": { + "version": "1.2.3", + "tag": "v1.2.3", + "selected_units": [ + { + "repo": "govoplan-core", + "version": "1.2.3", + "tag": "v1.2.3", + "commit_sha": commit_sha, + "tag_object_sha": tag_object_sha, + }, + ], + }, + } + + +def make_repo(workspace: Path, root: Path, name: str, version: str) -> tuple[Path, Path]: + remote = root / f"{name}.git" + repo = workspace / name + git(root, "init", "--bare", str(remote)) + git(workspace, "init", "-b", "main", str(repo)) + git(repo, "config", "user.name", "GovOPlaN Release Test") + git(repo, "config", "user.email", "release-test@example.invalid") + (repo / "pyproject.toml").write_text( + f'[project]\nname = "{name}"\nversion = "{version}"\n', + encoding="utf-8", + ) + git(repo, "add", "pyproject.toml") + git(repo, "commit", "-m", f"{name} {version}") + git(repo, "remote", "add", "origin", str(remote)) + git(repo, "push", "-u", "origin", "main") + return repo, remote + + +def messages(issues: object) -> str: + return " ".join(issue.message for issue in issues) # type: ignore[attr-defined] + + +def git(cwd: Path, *args: str) -> None: + result = subprocess.run( + ("git", *args), + cwd=cwd, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + raise AssertionError(f"git {' '.join(args)} failed: {result.stderr or result.stdout}") + + +def git_text(cwd: Path, *args: str) -> str: + result = subprocess.run( + ("git", *args), + cwd=cwd, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + raise AssertionError(f"git {' '.join(args)} failed: {result.stderr or result.stdout}") + return result.stdout.strip() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/release/govoplan_release/__init__.py b/tools/release/govoplan_release/__init__.py index 3d11806..b7b3679 100644 --- a/tools/release/govoplan_release/__init__.py +++ b/tools/release/govoplan_release/__init__.py @@ -18,6 +18,7 @@ __all__ = [ "prepare_repositories", "push_repositories", "sync_repositories", + "tag_repositories", ] @@ -64,4 +65,8 @@ def _load_lazy_export(name: str): from .repository_sync import sync_repositories return sync_repositories + if name == "tag_repositories": + from .repository_tag import tag_repositories + + return tag_repositories raise AttributeError(name) diff --git a/tools/release/govoplan_release/publisher.py b/tools/release/govoplan_release/publisher.py index e9c3869..c0a4ffc 100644 --- a/tools/release/govoplan_release/publisher.py +++ b/tools/release/govoplan_release/publisher.py @@ -15,6 +15,7 @@ from .catalog import canonical_hash from .model import CatalogPublishResult, CatalogPublishStep from .module_directory import write_module_directory from .selective_catalog import trusted_keys_from_keyring +from .source_provenance import catalog_source_selection, source_tag_provenance_issues from .version_alignment import candidate_catalog_version_issues from .workspace import DEFAULT_WORKSPACE_ROOT, resolve_workspace_root, website_root @@ -31,6 +32,7 @@ def publish_catalog_candidate( tag: bool = False, push: bool = False, remote: str = "origin", + source_remote: str = "origin", branch: str | None = None, npm: str = "npm", tag_name: str | None = None, @@ -79,6 +81,23 @@ def publish_catalog_candidate( f"expected {issue.expected!r} ({issue.message})" for issue in version_issues ) + source_selection = catalog_source_selection(candidate_payload) + blockers.extend( + f"source provenance: {issue.describe()}" + for issue in source_selection.issues + ) + source_issues = source_tag_provenance_issues( + repo_versions=source_selection.all_versions, + workspace=workspace, + remote=source_remote, + require_head_repos=source_selection.selected_versions, + expected_commits=source_selection.selected_commits, + expected_tag_objects=source_selection.selected_tag_objects, + ) + blockers.extend( + f"source provenance: {issue.describe()}" + for issue in source_issues + ) candidate_keys = trusted_keys_from_keyring(keyring_payload if isinstance(keyring_payload, dict) else {}) target_keyring_payload = read_json(target_keyring) if target_keyring.exists() else None publication_trust = trusted_keys_from_keyring( diff --git a/tools/release/govoplan_release/repository_tag.py b/tools/release/govoplan_release/repository_tag.py new file mode 100644 index 0000000..d813c0a --- /dev/null +++ b/tools/release/govoplan_release/repository_tag.py @@ -0,0 +1,426 @@ +"""Preview, create, and publish immutable release tags for selected repositories.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import re +import subprocess + +from .git_state import collect_repository_snapshot, git_text +from .model import RepositorySnapshot +from .repository_push import command_text, compact_output +from .version_alignment import repository_version_issues +from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root + + +_RELEASE_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") + + +def tag_repositories( + *, + repos: tuple[str, ...], + repo_versions: dict[str, str], + workspace_root: Path | str | None = None, + remote: str = "origin", + message: str | None = None, + apply: bool = False, # noqa: A002 - mirrors API field. + push: bool = False, +) -> dict[str, object]: + """Create annotated tags and optionally publish branch and tag atomically. + + A release tag is only created for a clean, aligned, non-behind worktree. + Both local and remote tags are resolved to commits before mutation so an + existing immutable tag can never be moved by this operation. + """ + + workspace = resolve_workspace_root(workspace_root) + remote = remote.strip() or "origin" + specs = {spec.name: spec for spec in load_repository_specs(include_website=False)} + selected = tuple(dict.fromkeys(repos)) + results: list[dict[str, object]] = [] + + if apply: + preflight = tag_repositories( + repos=selected, + repo_versions=repo_versions, + workspace_root=workspace, + remote=remote, + message=message, + apply=False, + push=push, + ) + preflight_rows = preflight.get("repositories") + if isinstance(preflight_rows, list) and any( + isinstance(item, dict) and item.get("status") in {"blocked", "failed"} + for item in preflight_rows + ): + blocked_rows = [] + for item in preflight_rows: + if not isinstance(item, dict) or item.get("status") in {"blocked", "failed"}: + blocked_rows.append(item) + continue + blocked_rows.append( + { + **item, + "status": "skipped", + "detail": "preflight passed, but no release tag was changed because another selected repository is blocked", + } + ) + return { + "status": "blocked", + "apply": True, + "push": push, + "remote": remote, + "detail": "batch preflight failed; no selected repository was mutated", + "repositories": blocked_rows, + } + + for repo in selected: + version = normalize_version(repo_versions.get(repo)) + tag = f"v{version}" if version else "" + spec = specs.get(repo) + if spec is None: + results.append({"repo": repo, "status": "blocked", "detail": "repository is not listed in repositories.json"}) + continue + snapshot = collect_repository_snapshot(spec, workspace_root=workspace, target_tag=tag or None, online=False) + path = resolve_repo_path(spec, workspace) + tag_message = message.strip() if message and message.strip() else f"Release {repo} {tag}" + create_command = ("git", "tag", "-a", tag, "-m", tag_message) if tag else () + publish_command = ( + "git", + "push", + "--atomic", + remote, + f"HEAD:refs/heads/{snapshot.branch or 'main'}", + f"refs/tags/{tag}", + ) if tag else () + row: dict[str, object] = { + "repo": repo, + "cwd": snapshot.absolute_path, + "branch": snapshot.branch, + "upstream": snapshot.upstream, + "head": snapshot.head, + "ahead": int(snapshot.ahead or 0), + "behind": int(snapshot.behind or 0), + "dirty_count": len(snapshot.dirty_entries), + "version": version, + "tag": tag, + "remote": remote, + "message": tag_message, + "create_command": command_text(create_command) if create_command else "", + "publish_command": command_text(publish_command) if publish_command else "", + } + blocker = basic_blocker(snapshot=snapshot, version=version) + if blocker: + results.append({**row, "status": "blocked", "detail": blocker}) + continue + if run(("git", "remote", "get-url", remote), cwd=path).returncode != 0: + results.append({**row, "status": "blocked", "detail": f"remote {remote!r} is not configured"}) + continue + + alignment = repository_version_issues(path, expected_version=version) + if alignment: + detail = "; ".join( + f"{issue.source}={issue.actual!r}, expected {issue.expected!r}" + for issue in alignment + ) + results.append({**row, "status": "blocked", "detail": f"version alignment gate failed: {detail}"}) + continue + + head_commit = git_text(path, "rev-parse", "HEAD") + local_commit = ref_commit(path, f"refs/tags/{tag}") + local_tag_object = git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}") if local_commit else None + local_tag_type = git_text(path, "cat-file", "-t", f"refs/tags/{tag}") if local_commit else None + if local_commit and local_tag_type != "tag": + results.append( + { + **row, + "status": "blocked", + "detail": f"local tag {tag} is not annotated; release tags must preserve an annotation object", + "local_tag_commit": local_commit, + "local_tag_type": local_tag_type, + } + ) + continue + if local_commit and local_commit != head_commit: + results.append( + { + **row, + "status": "blocked", + "detail": f"local immutable tag {tag} points to {local_commit[:12]}, not HEAD {head_commit[:12]}", + "local_tag_commit": local_commit, + } + ) + continue + + remote_result = remote_tag_commit(path, remote=remote, tag=tag) + if remote_result.error: + results.append({**row, "status": "blocked", "detail": remote_result.error}) + continue + if remote_result.commit and not remote_result.annotated: + results.append( + { + **row, + "status": "blocked", + "detail": f"remote tag {tag} is not annotated; immutable release tags must preserve an annotation object", + "local_tag_commit": local_commit, + "remote_tag_commit": remote_result.commit, + } + ) + continue + if remote_result.commit and remote_result.commit != head_commit: + results.append( + { + **row, + "status": "blocked", + "detail": f"remote immutable tag {tag} points to {remote_result.commit[:12]}, not HEAD {head_commit[:12]}", + "local_tag_commit": local_commit, + "remote_tag_commit": remote_result.commit, + } + ) + continue + if remote_result.tag_object and local_tag_object and remote_result.tag_object != local_tag_object: + results.append( + { + **row, + "status": "blocked", + "detail": f"local and remote immutable tag objects differ for {tag}; reconcile the annotation without moving either tag", + "local_tag_commit": local_commit, + "remote_tag_commit": remote_result.commit, + "local_tag_object": local_tag_object, + "remote_tag_object": remote_result.tag_object, + } + ) + continue + + row.update( + { + "head_commit": head_commit, + "local_tag_commit": local_commit, + "remote_tag_commit": remote_result.commit, + "local_tag_object": local_tag_object, + "remote_tag_object": remote_result.tag_object, + } + ) + if not apply: + detail = preview_detail(tag=tag, local_commit=local_commit, remote_commit=remote_result.commit, push=push) + status = "noop" if remote_result.commit or (local_commit and not push) else "planned" + results.append({**row, "status": status, "detail": detail}) + continue + + if remote_result.commit: + if not local_commit: + fetch_result = run(("git", "fetch", remote, f"refs/tags/{tag}:refs/tags/{tag}"), cwd=path) + if fetch_result.returncode != 0: + results.append( + { + **row, + "status": "failed", + "detail": f"remote tag {tag} exists at HEAD but could not be fetched locally", + "returncode": fetch_result.returncode, + "stdout": compact_output(fetch_result.stdout), + "stderr": compact_output(fetch_result.stderr), + } + ) + continue + results.append( + { + **row, + "status": "published", + "detail": f"immutable tag {tag} is already published at HEAD", + "after_local_tag_commit": head_commit, + "after_remote_tag_commit": head_commit, + } + ) + continue + + created = False + if not local_commit: + create_result = run(create_command, cwd=path) + if create_result.returncode != 0: + results.append( + { + **row, + "status": "failed", + "detail": f"could not create annotated tag {tag}", + "returncode": create_result.returncode, + "stdout": compact_output(create_result.stdout), + "stderr": compact_output(create_result.stderr), + } + ) + continue + created = True + + if not push: + results.append( + { + **row, + "status": "tagged" if created else "noop", + "detail": f"created annotated tag {tag} at HEAD" if created else f"annotated tag {tag} already exists at HEAD", + "after_local_tag_commit": head_commit, + } + ) + continue + + publish_result = run(publish_command, cwd=path) + if publish_result.returncode != 0: + results.append( + { + **row, + "status": "failed", + "detail": f"created local tag {tag}, but atomic branch and tag publication failed" if created else f"atomic branch and tag publication failed for {tag}", + "returncode": publish_result.returncode, + "after_local_tag_commit": head_commit, + "stdout": compact_output(publish_result.stdout), + "stderr": compact_output(publish_result.stderr), + } + ) + continue + after_local_object = git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}") + after_remote = remote_tag_commit(path, remote=remote, tag=tag) + if ( + after_remote.error + or not after_remote.annotated + or after_remote.commit != head_commit + or after_remote.tag_object != after_local_object + ): + verification_detail = after_remote.error or "remote tag did not resolve to the published annotated tag object at HEAD" + results.append( + { + **row, + "status": "failed", + "detail": f"Git push returned success, but the remote release-tag postcondition failed: {verification_detail}", + "returncode": publish_result.returncode, + "after_local_tag_commit": head_commit, + "after_local_tag_object": after_local_object, + "after_remote_tag_commit": after_remote.commit, + "after_remote_tag_object": after_remote.tag_object, + "stdout": compact_output(publish_result.stdout), + "stderr": compact_output(publish_result.stderr), + } + ) + continue + results.append( + { + **row, + "status": "published", + "detail": f"published branch {snapshot.branch} and immutable tag {tag} atomically to {remote}", + "returncode": publish_result.returncode, + "after_local_tag_commit": head_commit, + "after_remote_tag_commit": head_commit, + "after_local_tag_object": after_local_object, + "after_remote_tag_object": after_remote.tag_object, + "stdout": compact_output(publish_result.stdout), + "stderr": compact_output(publish_result.stderr), + } + ) + + if any(item["status"] in {"blocked", "failed"} for item in results): + status = "blocked" if not apply else "partial" + elif any(item["status"] == "published" for item in results): + status = "published" + elif any(item["status"] == "tagged" for item in results): + status = "tagged" + elif any(item["status"] == "planned" for item in results): + status = "planned" + else: + status = "noop" + return {"status": status, "apply": apply, "push": push, "remote": remote, "repositories": results} + + +def normalize_version(value: str | None) -> str: + normalized = (value or "").strip().removeprefix("v") + return normalized if _RELEASE_VERSION.fullmatch(normalized) else "" + + +def basic_blocker(*, snapshot: RepositorySnapshot, version: str) -> str | None: + if not snapshot.exists or not snapshot.is_git: + return "repository is missing or is not a Git repository" + if snapshot.safe_directory_required: + return "Git safe.directory approval is required" + if not snapshot.has_head: + return "repository has no commit to tag" + if not snapshot.branch: + return "repository is not on a named branch" + if snapshot.behind: + return f"repository is behind {snapshot.upstream}" + if snapshot.dirty_entries: + return "worktree is not clean; commit or discard the reviewed changes before tagging" + if not version: + return "a valid target version is required" + return None + + +@dataclass(frozen=True, slots=True) +class RemoteTagResult: + commit: str | None = None + tag_object: str | None = None + annotated: bool = False + error: str | None = None + + +def ref_commit(path: Path, ref: str) -> str | None: + value = git_text(path, "rev-parse", "--verify", f"{ref}^{{commit}}") + return value or None + + +def remote_tag_commit( + path: Path, + *, + remote: str, + tag: str, + timeout: int = 15, + env: dict[str, str] | None = None, +) -> RemoteTagResult: + result = run( + ("git", "ls-remote", "--tags", remote, f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}"), + cwd=path, + timeout=timeout, + env=env, + ) + if result.returncode != 0: + detail = compact_output(result.stderr) or compact_output(result.stdout) or "unknown Git error" + return RemoteTagResult(error=f"could not inspect immutable tag {tag} on {remote}: {detail}") + refs = {} + for line in result.stdout.splitlines(): + commit, separator, ref = line.partition("\t") + if separator: + refs[ref] = commit + peeled = refs.get(f"refs/tags/{tag}^{{}}") + tag_object = refs.get(f"refs/tags/{tag}") + return RemoteTagResult(commit=peeled or tag_object, tag_object=tag_object, annotated=peeled is not None) + + +def preview_detail(*, tag: str, local_commit: str | None, remote_commit: str | None, push: bool) -> str: + if remote_commit: + return f"immutable tag {tag} is already published at HEAD" + if local_commit and push: + return f"existing local tag {tag} will be published with the current branch" + if local_commit: + return f"annotated tag {tag} already exists at HEAD" + if push: + return f"annotated tag {tag} will be created and published atomically with the current branch" + return f"annotated tag {tag} will be created at HEAD" + + +def run( + command: tuple[str, ...], + *, + cwd: Path, + timeout: int = 120, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + command, + cwd=cwd, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + env=env, + ) + except subprocess.TimeoutExpired as exc: + return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "Git command timed out") diff --git a/tools/release/govoplan_release/selective_catalog.py b/tools/release/govoplan_release/selective_catalog.py index 54fbaa6..c65325d 100644 --- a/tools/release/govoplan_release/selective_catalog.py +++ b/tools/release/govoplan_release/selective_catalog.py @@ -19,6 +19,11 @@ from .contracts import collect_contracts from .git_state import collect_repository_snapshot from .model import CatalogEntryChange, SelectiveCatalogCandidate from .module_directory import write_module_directory +from .source_provenance import ( + catalog_source_selection, + selected_source_provenance, + source_tag_provenance_issues, +) from .version_alignment import selected_repository_version_issues from .workspace import load_repository_specs, resolve_workspace_root, website_root @@ -35,6 +40,7 @@ def build_selective_catalog_candidate( signing_keys: tuple[str, ...] = (), public_base_url: str = DEFAULT_PUBLIC_BASE_URL, repository_base: str = GITEA_BASE, + source_remote: str = "origin", expires_days: int = 90, sequence: int | None = None, check_public: bool = True, @@ -48,6 +54,15 @@ def build_selective_catalog_candidate( workspace = resolve_workspace_root(workspace_root) enforce_selected_version_alignment(repo_versions=repo_versions, workspace=workspace) + enforce_selected_source_provenance( + repo_versions=repo_versions, + workspace=workspace, + remote=source_remote, + ) + selected_provenance = selected_source_provenance( + repo_versions=repo_versions, + workspace=workspace, + ) web_root = website_root(workspace) source_catalog = resolve_base_catalog(base_catalog=base_catalog, web_root=web_root, channel=channel) payload = read_catalog(source_catalog) @@ -66,7 +81,15 @@ def build_selective_catalog_candidate( release = {} release["catalog_url"] = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json" release["keyring_url"] = f"{public_base_url.rstrip('/')}/catalogs/v1/keyring.json" - release["selected_units"] = [{"repo": repo, "version": version, "tag": f"v{version.removeprefix('v')}"} for repo, version in sorted(repo_versions.items())] + release["selected_units"] = [ + { + "repo": repo, + "version": version, + "tag": f"v{version.removeprefix('v')}", + **selected_provenance[repo], + } + for repo, version in sorted(repo_versions.items()) + ] candidate["release"] = release repo_contracts = contracts_by_repo(workspace) @@ -76,6 +99,11 @@ def build_selective_catalog_candidate( repo_contracts=repo_contracts, repository_base=repository_base.rstrip("/"), ) + enforce_complete_catalog_source_provenance( + payload=candidate, + workspace=workspace, + remote=source_remote, + ) output_root = resolve_output_dir(output_dir=output_dir, channel=channel, generated_at=generated_at) catalog_path = output_root / "channels" / f"{channel}.json" @@ -175,6 +203,50 @@ def enforce_selected_version_alignment(*, repo_versions: dict[str, str], workspa raise ValueError("Version alignment gate failed: " + "; ".join(failures)) +def enforce_selected_source_provenance( + *, + repo_versions: dict[str, str], + workspace: Path, + remote: str = "origin", +) -> None: + failures = source_tag_provenance_issues( + repo_versions=repo_versions, + workspace=workspace, + remote=remote, + require_head_repos=repo_versions, + ) + if failures: + raise ValueError( + "Source tag provenance gate failed: " + + "; ".join(issue.describe() for issue in failures) + ) + + +def enforce_complete_catalog_source_provenance( + *, + payload: object, + workspace: Path, + remote: str = "origin", +) -> None: + selection = catalog_source_selection(payload) + failures = [*selection.issues] + failures.extend( + source_tag_provenance_issues( + repo_versions=selection.all_versions, + workspace=workspace, + remote=remote, + require_head_repos=selection.selected_versions, + expected_commits=selection.selected_commits, + expected_tag_objects=selection.selected_tag_objects, + ) + ) + if failures: + raise ValueError( + "Complete catalog source provenance gate failed: " + + "; ".join(issue.describe() for issue in failures) + ) + + def resolve_base_catalog(*, base_catalog: Path | str | None, web_root: Path, channel: str) -> Path | str: if base_catalog is not None: value = str(base_catalog) diff --git a/tools/release/govoplan_release/selective_planner.py b/tools/release/govoplan_release/selective_planner.py index fef333a..3f34d59 100644 --- a/tools/release/govoplan_release/selective_planner.py +++ b/tools/release/govoplan_release/selective_planner.py @@ -126,6 +126,7 @@ def compatibility_issues(dashboard: ReleaseDashboard) -> tuple[CompatibilityIssu def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashboard, channel: str) -> tuple[ReleasePlanStep, ...]: steps: list[ReleasePlanStep] = [] + snapshots = {repo.spec.name: repo for repo in dashboard.repositories} for unit in units: steps.append( ReleasePlanStep( @@ -150,17 +151,19 @@ def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashb status="needs-executor", ) ) - steps.append( - ReleasePlanStep( - id=f"{unit.repo}:commit", - title=f"Commit {unit.repo} release changes", - detail="Commit reviewed changes for this release unit only.", - command=f"git add -A && git commit -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}", - cwd=f"{dashboard.workspace_root}/{unit.repo}", - mutating=True, - repo=unit.repo, + snapshot = snapshots.get(unit.repo) + if unit.current_version != unit.target_version or (snapshot is not None and snapshot.dirty): + steps.append( + ReleasePlanStep( + id=f"{unit.repo}:commit", + title=f"Commit {unit.repo} release changes", + detail="Commit reviewed changes for this release unit only.", + command=f"git add -A && git commit -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}", + cwd=f"{dashboard.workspace_root}/{unit.repo}", + mutating=True, + repo=unit.repo, + ) ) - ) steps.append( ReleasePlanStep( id=f"{unit.repo}:tag", @@ -229,10 +232,16 @@ def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashb ReleasePlanStep( id="catalog:validate-sign-publish", title=f"Validate, sign, and publish {channel}", - detail="The candidate writer validates and signs locally. Publishing to the website repo remains a separate apply step.", - command="Review the candidate under runtime/release-candidates before publishing it to the website repository.", + detail=( + "The candidate writer validates and signs locally. Preview and guarded apply/tag/push executors " + "are available in the Signed Website Catalog panel and the release-catalog CLI." + ), + command=( + "tools/release/release-catalog.py publish-candidate " + f"--candidate-dir \"$CANDIDATE_DIR\" --channel {shlex.quote(channel)}" + ), cwd=dashboard.meta_root, - status="needs-publish-executor", + status="planned", ) ) return tuple(steps) diff --git a/tools/release/govoplan_release/source_provenance.py b/tools/release/govoplan_release/source_provenance.py new file mode 100644 index 0000000..e39f6b6 --- /dev/null +++ b/tools/release/govoplan_release/source_provenance.py @@ -0,0 +1,438 @@ +"""Fail-closed provenance checks for catalog source release tags.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +from pathlib import Path +import re +import subprocess +import tomllib +from typing import Iterable +from urllib.parse import urlsplit + +from .git_state import collect_repository_snapshot, git_text +from .repository_tag import RemoteTagResult, ref_commit, remote_tag_commit +from .version_alignment import repository_version_issues +from .workspace import load_repository_specs, resolve_repo_path + + +_CATALOG_PYTHON_REF = re.compile( + r"/(?Pgovoplan-[a-z0-9-]+)\.git@v(?P[^\s;]+)$" +) +_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") + + +@dataclass(frozen=True, slots=True) +class SourceTagProvenanceIssue: + repo: str + tag: str + message: str + + def describe(self) -> str: + return f"{self.repo} {self.tag}: {self.message}" + + +@dataclass(frozen=True, slots=True) +class CatalogSourceSelection: + all_versions: dict[str, str] + selected_versions: dict[str, str] + selected_commits: dict[str, str] + selected_tag_objects: dict[str, str] + issues: tuple[SourceTagProvenanceIssue, ...] = () + + +def catalog_source_selection(payload: object) -> CatalogSourceSelection: + """Extract all immutable Python source refs and the explicitly selected units.""" + + if not isinstance(payload, dict): + return CatalogSourceSelection( + all_versions={}, + selected_versions={}, + selected_commits={}, + selected_tag_objects={}, + issues=(SourceTagProvenanceIssue("catalog", "", "catalog must be a JSON object"),), + ) + + versions: dict[str, str] = {} + issues: list[SourceTagProvenanceIssue] = [] + entries: list[tuple[str, object]] = [("core_release", payload.get("core_release"))] + modules = payload.get("modules") + if isinstance(modules, list): + entries.extend((f"modules[{index}]", entry) for index, entry in enumerate(modules)) + + for source, raw_entry in entries: + if not isinstance(raw_entry, dict): + continue + python_ref = raw_entry.get("python_ref") + match = _CATALOG_PYTHON_REF.search(python_ref) if isinstance(python_ref, str) else None + if match is None: + continue # The catalog version-alignment gate reports malformed refs. + repo = match.group("repo") + version = match.group("version").removeprefix("v") + previous = versions.setdefault(repo, version) + if previous != version: + issues.append( + SourceTagProvenanceIssue( + repo, + f"v{version}", + f"{source} conflicts with the catalog's earlier v{previous} source ref", + ) + ) + + release = payload.get("release") + selected_units = release.get("selected_units") if isinstance(release, dict) else None + selected: dict[str, str] = {} + selected_commits: dict[str, str] = {} + selected_tag_objects: dict[str, str] = {} + if not isinstance(selected_units, list) or not selected_units: + issues.append( + SourceTagProvenanceIssue( + "catalog", + "", + "release.selected_units is missing or empty; selected source provenance cannot be established", + ) + ) + else: + for unit in selected_units: + if not isinstance(unit, dict): + continue + repo = unit.get("repo") + version = unit.get("version") + if isinstance(repo, str) and isinstance(version, str): + selected[repo] = version.removeprefix("v") + commit = unit.get("commit_sha") + tag_object = unit.get("tag_object_sha") + if isinstance(commit, str) and re.fullmatch(r"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", commit): + selected_commits[repo] = commit.lower() + else: + issues.append( + SourceTagProvenanceIssue(repo, f"v{version.removeprefix('v')}", "selected unit has no valid commit_sha provenance") + ) + if isinstance(tag_object, str) and re.fullmatch(r"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", tag_object): + selected_tag_objects[repo] = tag_object.lower() + else: + issues.append( + SourceTagProvenanceIssue(repo, f"v{version.removeprefix('v')}", "selected unit has no valid tag_object_sha provenance") + ) + + return CatalogSourceSelection( + all_versions=versions, + selected_versions=selected, + selected_commits=selected_commits, + selected_tag_objects=selected_tag_objects, + issues=tuple(issues), + ) + + +def source_tag_provenance_issues( + *, + repo_versions: dict[str, str], + workspace: Path, + remote: str = "origin", + require_head_repos: Iterable[str] = (), + expected_commits: dict[str, str] | None = None, + expected_tag_objects: dict[str, str] | None = None, +) -> tuple[SourceTagProvenanceIssue, ...]: + """Verify immutable local/remote tags and selected clean source HEADs. + + Every requested ref must be an annotated tag in the local object store and + on the configured remote, and both tag objects must peel to the same commit. + Repositories in ``require_head_repos`` additionally have to be clean, + version-aligned worktrees whose requested tag peels to the current HEAD. + """ + + remote = remote.strip() or "origin" + require_head = set(require_head_repos) + expected_commits = expected_commits or {} + expected_tag_objects = expected_tag_objects or {} + requested = sorted(repo_versions.items()) + specs = {spec.name: spec for spec in load_repository_specs(include_website=False)} + issues: list[SourceTagProvenanceIssue] = [] + + for repo, requested_version in requested: + version = requested_version.removeprefix("v").strip() + tag = f"v{version}" + spec = specs.get(repo) + if spec is None: + issues.append(SourceTagProvenanceIssue(repo, tag, "repository is not registered in repositories.json")) + continue + if not _VERSION.fullmatch(version): + issues.append(SourceTagProvenanceIssue(repo, tag, "requested version is not a valid release version")) + continue + + path = resolve_repo_path(spec, workspace) + snapshot = collect_repository_snapshot(spec, workspace_root=workspace, target_tag=tag, online=False) + if not snapshot.exists or not snapshot.is_git: + issues.append(SourceTagProvenanceIssue(repo, tag, "local source repository is missing or is not a Git repository")) + continue + if snapshot.safe_directory_required: + issues.append(SourceTagProvenanceIssue(repo, tag, "Git safe.directory approval is required")) + continue + if not snapshot.has_head: + issues.append(SourceTagProvenanceIssue(repo, tag, "local source repository has no HEAD commit")) + continue + + head_commit = git_text(path, "rev-parse", "HEAD") + local_commit = ref_commit(path, f"refs/tags/{tag}") + local_object = git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}") if local_commit else None + local_type = git_text(path, "cat-file", "-t", f"refs/tags/{tag}") if local_commit else None + if not local_commit: + issues.append(SourceTagProvenanceIssue(repo, tag, "annotated tag is missing from the local source repository")) + elif local_type != "tag": + issues.append(SourceTagProvenanceIssue(repo, tag, "local release tag is lightweight; an annotated tag is required")) + + remote_url = git_text(path, "remote", "get-url", remote) + if remote_url == "": + issues.append(SourceTagProvenanceIssue(repo, tag, f"configured Git remote {remote!r} does not exist")) + remote_result = None + else: + remote_result = inspect_remote_tag(path=path, remote=remote, remote_url=remote_url, tag=tag) + if remote_result.error: + issues.append(SourceTagProvenanceIssue(repo, tag, remote_result.error)) + elif not remote_result.commit: + issues.append(SourceTagProvenanceIssue(repo, tag, f"annotated tag is missing from remote {remote!r}")) + elif not remote_result.annotated: + issues.append(SourceTagProvenanceIssue(repo, tag, f"remote {remote!r} release tag is lightweight; an annotated tag is required")) + + remote_commit = remote_result.commit if remote_result is not None and not remote_result.error else None + remote_annotated = remote_result.annotated if remote_result is not None else False + if local_commit and local_type == "tag" and remote_commit and remote_annotated and local_commit != remote_commit: + issues.append( + SourceTagProvenanceIssue( + repo, + tag, + f"local tag peels to {local_commit[:12]}, but remote {remote!r} peels to {remote_commit[:12]}", + ) + ) + if ( + local_object + and local_type == "tag" + and remote_result is not None + and remote_result.annotated + and remote_result.tag_object + and local_object != remote_result.tag_object + ): + issues.append( + SourceTagProvenanceIssue( + repo, + tag, + "local and remote annotated tag objects differ; the release annotation provenance is inconsistent", + ) + ) + + expected_commit = expected_commits.get(repo) + if expected_commit and local_commit and local_commit.lower() != expected_commit.lower(): + issues.append( + SourceTagProvenanceIssue( + repo, + tag, + f"local tag no longer matches signed commit_sha {expected_commit}", + ) + ) + expected_object = expected_tag_objects.get(repo) + if expected_object and local_object and local_object.lower() != expected_object.lower(): + issues.append( + SourceTagProvenanceIssue( + repo, + tag, + f"local tag no longer matches signed tag_object_sha {expected_object}", + ) + ) + + issues.extend(tagged_source_version_issues(path=path, repo=repo, tag=tag, expected_version=version)) + + if repo in require_head: + if not snapshot.branch: + issues.append(SourceTagProvenanceIssue(repo, tag, "selected source HEAD is detached; a named branch is required")) + if snapshot.dirty_entries: + issues.append(SourceTagProvenanceIssue(repo, tag, "selected source worktree is not clean")) + if snapshot.behind: + issues.append(SourceTagProvenanceIssue(repo, tag, f"selected source branch is behind {snapshot.upstream}")) + for issue in repository_version_issues(path, expected_version=version): + issues.append( + SourceTagProvenanceIssue( + repo, + tag, + f"selected source version alignment failed: {issue.source}={issue.actual!r}, expected {issue.expected!r}", + ) + ) + if local_commit and local_commit != head_commit: + issues.append( + SourceTagProvenanceIssue( + repo, + tag, + f"local tag peels to {local_commit[:12]}, not selected HEAD {head_commit[:12]}", + ) + ) + if remote_commit and remote_commit != head_commit: + issues.append( + SourceTagProvenanceIssue( + repo, + tag, + f"remote tag peels to {remote_commit[:12]}, not selected HEAD {head_commit[:12]}", + ) + ) + + return tuple(_deduplicate(issues)) + + +def selected_source_provenance( + *, + repo_versions: dict[str, str], + workspace: Path, +) -> dict[str, dict[str, str]]: + """Return immutable source object identifiers after the provenance gate passes.""" + + specs = {spec.name: spec for spec in load_repository_specs(include_website=False)} + result: dict[str, dict[str, str]] = {} + for repo, requested_version in sorted(repo_versions.items()): + spec = specs[repo] + path = resolve_repo_path(spec, workspace) + tag = f"v{requested_version.removeprefix('v')}" + result[repo] = { + "commit_sha": git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"), + "tag_object_sha": git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}"), + } + return result + + +def inspect_remote_tag(*, path: Path, remote: str, remote_url: str, tag: str) -> RemoteTagResult: + """Inspect refs over same-host HTTPS when possible, then fall back to the configured remote.""" + + environment = { + **os.environ, + "GIT_TERMINAL_PROMPT": "0", + "GIT_SSH_COMMAND": os.environ.get( + "GIT_SSH_COMMAND", + "ssh -o BatchMode=yes -o ConnectTimeout=8", + ), + } + inspection_remote = read_only_https_remote(remote_url) + if inspection_remote: + result = remote_tag_commit( + path, + remote=inspection_remote, + tag=tag, + timeout=5, + env=environment, + ) + if not result.error: + return result + return remote_tag_commit( + path, + remote=remote, + tag=tag, + timeout=12, + env=environment, + ) + + +def read_only_https_remote(remote_url: str) -> str | None: + """Derive the equivalent same-host HTTPS Git URL for a conventional SSH remote.""" + + value = remote_url.strip() + if value.startswith(("https://", "http://")): + return value + if value.startswith("ssh://"): + parsed = urlsplit(value) + if parsed.hostname and parsed.port in {None, 22} and parsed.path: + return f"https://{parsed.hostname}{parsed.path}" + return None + match = re.fullmatch(r"(?:[^@/:]+@)?([^/:]+):(.+)", value) + if match: + return f"https://{match.group(1)}/{match.group(2).lstrip('/')}" + return None + + +def tagged_source_version_issues( + *, + path: Path, + repo: str, + tag: str, + expected_version: str, +) -> tuple[SourceTagProvenanceIssue, ...]: + """Check version-bearing files from the tag without changing the worktree.""" + + if not ref_commit(path, f"refs/tags/{tag}"): + return () + tree = _git(path, "ls-tree", "-r", "--name-only", tag) + if tree.returncode != 0: + return (SourceTagProvenanceIssue(repo, tag, "could not inspect the local tag's source tree"),) + files = set(tree.stdout.splitlines()) + declarations: list[tuple[str, str | None]] = [] + + pyproject = _git_file(path, tag, "pyproject.toml") + if pyproject is None: + return (SourceTagProvenanceIssue(repo, tag, "tagged source has no pyproject.toml package metadata"),) + try: + parsed = tomllib.loads(pyproject) + project = parsed.get("project") + declarations.append(("pyproject.toml", project.get("version") if isinstance(project, dict) else None)) + except tomllib.TOMLDecodeError: + declarations.append(("pyproject.toml", None)) + + for name in ("package.json", "webui/package.json", "webui/package.release.json"): + if name not in files: + continue + declarations.append((name, _json_version(_git_file(path, tag, name)))) + + for name in sorted(files): + if name.startswith("src/") and name.endswith("/backend/manifest.py"): + text = _git_file(path, tag, name) or "" + match = re.search(r'(?m)^\s*(?:version|MODULE_VERSION)\s*=\s*["\']([^"\']+)["\']', text) + if match: + declarations.append((name, match.group(1))) + + return tuple( + SourceTagProvenanceIssue( + repo, + tag, + f"tagged {source} version is {actual!r}, expected {expected_version!r}", + ) + for source, actual in declarations + if actual != expected_version + ) + + +def _json_version(value: str | None) -> str | None: + if value is None: + return None + try: + payload = json.loads(value) + except json.JSONDecodeError: + return None + version = payload.get("version") if isinstance(payload, dict) else None + return version if isinstance(version, str) else None + + +def _git_file(path: Path, tag: str, name: str) -> str | None: + result = _git(path, "show", f"{tag}:{name}") + return result.stdout if result.returncode == 0 else None + + +def _git(path: Path, *args: str) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + ("git", *args), + cwd=path, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=30, + ) + except subprocess.TimeoutExpired as exc: + return subprocess.CompletedProcess(("git", *args), 124, exc.stdout or "", exc.stderr or "Git command timed out") + + +def _deduplicate(issues: list[SourceTagProvenanceIssue]) -> list[SourceTagProvenanceIssue]: + result: list[SourceTagProvenanceIssue] = [] + seen: set[tuple[str, str, str]] = set() + for issue in issues: + key = (issue.repo, issue.tag, issue.message) + if key not in seen: + seen.add(key) + result.append(issue) + return result diff --git a/tools/release/release-catalog.py b/tools/release/release-catalog.py index 24ec6ab..6e5b729 100644 --- a/tools/release/release-catalog.py +++ b/tools/release/release-catalog.py @@ -27,6 +27,7 @@ def main() -> int: selective.add_argument("--output-dir", type=Path, help="Candidate output directory. Defaults to runtime/release-candidates/-.") selective.add_argument("--public-base-url", default="https://govoplan.add-ideas.de") selective.add_argument("--repository-base", default="git+ssh://git@git.add-ideas.de/add-ideas") + selective.add_argument("--source-remote", default="origin", help="Configured Git remote containing immutable source tags.") selective.add_argument("--expires-days", type=int, default=90) selective.add_argument("--sequence", type=int) selective.add_argument("--skip-public-check", action="store_true") @@ -43,6 +44,7 @@ def main() -> int: publish.add_argument("--tag", action="store_true", help="Create a website catalog publication tag. Implies --commit.") publish.add_argument("--push", action="store_true", help="Push the website branch and tag. Implies --commit.") publish.add_argument("--remote", default="origin") + publish.add_argument("--source-remote", default="origin", help="Configured Git remote containing catalog source tags.") publish.add_argument("--branch") publish.add_argument("--tag-name") publish.add_argument("--npm", default="npm") @@ -68,6 +70,7 @@ def main() -> int: signing_keys=tuple(args.catalog_signing_key), public_base_url=args.public_base_url, repository_base=args.repository_base, + source_remote=args.source_remote, expires_days=args.expires_days, sequence=args.sequence, check_public=not args.skip_public_check, @@ -90,6 +93,7 @@ def main() -> int: tag=args.tag, push=args.push, remote=args.remote, + source_remote=args.source_remote, branch=args.branch, npm=args.npm, tag_name=args.tag_name, diff --git a/tools/release/server/app.py b/tools/release/server/app.py index 2f17154..f3ab4e5 100644 --- a/tools/release/server/app.py +++ b/tools/release/server/app.py @@ -18,6 +18,7 @@ from govoplan_release import ( prepare_repositories, push_repositories, sync_repositories, + tag_repositories, ) from govoplan_release.model import to_jsonable from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT @@ -35,6 +36,7 @@ class CatalogCandidateRequest(BaseModel): sequence: int | None = None public_base_url: str = "https://govoplan.add-ideas.de" repository_base: str = "git+ssh://git@git.add-ideas.de/add-ideas" + source_remote: str = "origin" check_public: bool = True @@ -48,6 +50,7 @@ class PublishCandidateRequest(BaseModel): tag: bool = False push: bool = False remote: str = "origin" + source_remote: str = "origin" branch: str | None = None tag_name: str | None = None npm: str = "npm" @@ -77,6 +80,16 @@ class RepositoryPrepareRequest(BaseModel): confirm: str = "" +class RepositoryTagRequest(BaseModel): + repos: list[str] = Field(default_factory=list) + repo_versions: dict[str, str] = Field(default_factory=dict) + remote: str = "origin" + message: str | None = None + apply: bool = False # noqa: A003 - API field mirrors CLI. + push: bool = False + confirm: str = "" + + def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | None = None) -> FastAPI: app = FastAPI(title="GovOPlaN Release Console", version="0.1.0") app.state.workspace_root = workspace_root @@ -203,19 +216,23 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No signing_keys = tuple(request.signing_keys or default_signing_keys()) if not signing_keys: raise HTTPException(status_code=400, detail="No signing key supplied and default release key was not found") - candidate = build_selective_catalog_candidate( - repo_versions=repo_versions, - channel=request.channel, - workspace_root=app.state.workspace_root, - output_dir=request.output_dir, - base_catalog=request.base_catalog, - signing_keys=signing_keys, - public_base_url=request.public_base_url, - repository_base=request.repository_base, - expires_days=request.expires_days, - sequence=request.sequence, - check_public=request.check_public, - ) + try: + candidate = build_selective_catalog_candidate( + repo_versions=repo_versions, + channel=request.channel, + workspace_root=app.state.workspace_root, + output_dir=request.output_dir, + base_catalog=request.base_catalog, + signing_keys=signing_keys, + public_base_url=request.public_base_url, + repository_base=request.repository_base, + source_remote=request.source_remote, + expires_days=request.expires_days, + sequence=request.sequence, + check_public=request.check_public, + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc return to_jsonable(candidate) @app.post("/api/catalog-candidates/publish") @@ -235,6 +252,7 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No tag=request.tag, push=request.push, remote=request.remote, + source_remote=request.source_remote, branch=request.branch, tag_name=request.tag_name, npm=request.npm, @@ -278,6 +296,26 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No ) return to_jsonable(result) + @app.post("/api/repositories/tag") + def repository_tag(request: RepositoryTagRequest) -> dict[str, object]: + repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip())) + if not repos: + raise HTTPException(status_code=400, detail="At least one repository must be selected") + if request.apply and request.push and request.confirm != "PUBLISH": + raise HTTPException(status_code=400, detail="Release tag publication requires confirm=PUBLISH") + if request.apply and not request.push and request.confirm != "TAG": + raise HTTPException(status_code=400, detail="Release tag creation requires confirm=TAG") + result = tag_repositories( + repos=repos, + repo_versions=request.repo_versions, + message=request.message, + workspace_root=app.state.workspace_root, + remote=request.remote, + apply=request.apply, + push=request.push, + ) + return to_jsonable(result) + return app diff --git a/tools/release/webui/index.html b/tools/release/webui/index.html index 7ada4dd..c3bbde5 100644 --- a/tools/release/webui/index.html +++ b/tools/release/webui/index.html @@ -692,15 +692,34 @@
-

3. Release Tag Plan

- +

3. Source Release Tag

+ idle ยท plan idle +
+
+

Creates an annotated source tag at the selected clean HEAD. Publish pushes the branch and tag atomically; existing release tags are never moved.

+
+
+ + +
+
+ + +
+
+
+ + + +
+
-

4. Signed Release

+

4. Signed Website Catalog

@@ -721,8 +740,8 @@
- - + +
@@ -801,6 +820,13 @@ catalogStatus: document.getElementById("catalogStatus"), actions: document.getElementById("actions"), planStatus: document.getElementById("planStatus"), + tagStatus: document.getElementById("tagStatus"), + tagMessage: document.getElementById("tagMessage"), + tagConfirmText: document.getElementById("tagConfirmText"), + tagOutput: document.getElementById("tagOutput"), + previewReleaseTags: document.getElementById("previewReleaseTags"), + createReleaseTags: document.getElementById("createReleaseTags"), + publishReleaseTags: document.getElementById("publishReleaseTags"), signingKey: document.getElementById("signingKey"), candidateDir: document.getElementById("candidateDir"), confirmText: document.getElementById("confirmText"), @@ -858,6 +884,9 @@ elements.previewPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: false })); elements.commitPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: true })); elements.buildSelectedPlan.addEventListener("click", buildSelectedPlan); + elements.previewReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: false, push: true })); + elements.createReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: true, push: false })); + elements.publishReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: true, push: true })); elements.selectUnpushedUnits.addEventListener("click", selectUnpushedUnits); elements.selectChangedUnits.addEventListener("click", selectChangedUnits); elements.selectUnreleasedUnits.addEventListener("click", selectUnreleasedUnits); @@ -974,18 +1003,18 @@ : "No dirty worktrees in the current dashboard." ), workflowStage( - "3. Generate release tag", + "3. Publish source release tags", tagStatus, releaseBlockers ? "block" : selected ? "ok" : "warn", selected - ? `${selected} selected; cockpit builds the tag plan, release tagging still runs through the guarded release script.` - : "Select repositories, set target versions, then build the release tag plan." + ? `${selected} selected; use Preview Tag + Publish, then Create Tags or Publish Tags after reviewing the source-release preflight.` + : "Select repositories, set target versions, then preview the source release tags." ), workflowStage( - "4. Sign and publish release", + "4. Sign and publish website catalog", signedStatus, hasCandidate ? "ok" : "warn", - hasCandidate ? `Candidate: ${elements.candidateDir.value.trim()}` : "Generate a signed candidate, preview it, then apply/tag/push the website publication." + hasCandidate ? `Candidate: ${elements.candidateDir.value.trim()}` : "After every selected source tag is remotely published, generate a signed candidate, preview it, then apply/tag/push the website publication." ), workflowStage( "5. Maintain install catalog", @@ -1595,6 +1624,65 @@ }).join(""); } + async function releaseSelectedTags(options) { + const repos = selectedRepoNames(); + if (!repos.length) { + elements.tagStatus.textContent = "idle"; + elements.tagOutput.innerHTML = `

${pill("idle", "warn")} No repositories selected

Select one or more repositories first.

`; + return; + } + elements.tagStatus.textContent = options.apply ? (options.push ? "Publishing..." : "Tagging...") : "Previewing..."; + setBusy([elements.previewReleaseTags, elements.createReleaseTags, elements.publishReleaseTags], true); + try { + const result = await postJson("/api/repositories/tag", { + repos, + repo_versions: selectedRepoVersions(), + remote: elements.pushRemote.value.trim() || "origin", + message: elements.tagMessage.value.trim() || null, + apply: options.apply === true, + push: options.push === true, + confirm: elements.tagConfirmText.value.trim(), + }); + elements.tagStatus.textContent = result.status; + renderTagResult(result); + await load(); + } catch (error) { + elements.tagStatus.textContent = "error"; + elements.tagOutput.innerHTML = `

${pill("error", "block")} Source release failed

${escapeHtml(error.message)}

`; + } finally { + setBusy([elements.previewReleaseTags, elements.createReleaseTags, elements.publishReleaseTags], false); + } + } + + function renderTagResult(result) { + const rows = Array.isArray(result.repositories) ? result.repositories : []; + if (!rows.length) { + elements.tagOutput.innerHTML = `
No source release actions returned.
`; + return; + } + elements.tagOutput.innerHTML = rows.map((row) => { + const kind = row.status === "blocked" || row.status === "failed" ? "block" : row.status === "published" || row.status === "tagged" || row.status === "planned" ? "ok" : "warn"; + const commands = [row.create_command, row.publish_command] + .filter(Boolean) + .map((command) => `
${escapeHtml(command)}
`) + .join(""); + const output = [row.stdout, row.stderr].filter(Boolean).map((value) => `
${escapeHtml(value)}
`).join(""); + const meta = [ + row.tag, + row.branch ? `branch ${row.branch}` : "", + row.head ? `HEAD ${row.head}` : "", + row.remote ? `remote ${row.remote}` : "", + ].filter(Boolean).join(" | "); + return `
+

${pill(row.status, kind)} ${escapeHtml(row.repo)}

+

${escapeHtml(row.detail || "")}

+ ${meta ? `

${escapeHtml(meta)}

` : ""} + ${commands} + ${output} +
`; + }).join(""); + } + async function syncSelectedRepos(options) { const repos = selectedRepoNames(); if (!repos.length) { @@ -1738,7 +1826,18 @@ const steps = Array.isArray(result.steps) ? result.steps.map((step) => `

${pill(step.status, step.status === "blocked" ? "block" : step.status === "planned" ? "warn" : "ok")} ${escapeHtml(step.title)}

${escapeHtml(step.detail)}

${step.command ? `
${escapeHtml(step.command)}
` : ""}
`).join("") : ""; - elements.workflowOutput.innerHTML = `

${escapeHtml(title)}

${lines.join("")}${changes}
${steps}`; + const notes = Array.isArray(result.notes) + ? result.notes.map((note) => `

${escapeHtml(note)}

`).join("") + : ""; + const resultKind = result.status === "blocked" || result.status === "failed" || result.status === "partial" + ? "block" + : result.status === "ready" || result.status === "published" || result.status === "applied" + ? "ok" + : "warn"; + const noteDetails = notes + ? `
Validation and provenance details${notes}
` + : ""; + elements.workflowOutput.innerHTML = `

${pill(result.status || "result", resultKind)} ${escapeHtml(title)}

${lines.join("")}${changes}
${noteDetails}${steps}`; } function setBusy(buttons, busy) {