feat(release): publish verified source tags
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 14s
Security Audit / security-audit (push) Failing after 12s

This commit is contained in:
2026-07-22 01:17:07 +02:00
parent eca75cbc93
commit 30f9ed152a
12 changed files with 1735 additions and 40 deletions

View File

@@ -18,6 +18,8 @@ candidate/publish actions:
- configure target versions per release unit in the web UI - configure target versions per release unit in the web UI
- select the repositories that should advance through checkboxes - select the repositories that should advance through checkboxes
- generate dry-run selective release plans for independently versioned packages - 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 - generate signed catalog candidates for the selected release units
- preview/apply/push reviewed catalog candidates behind explicit confirmations - 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 shows the selected repository push commands. `Push Selected` requires `PUSH` in
the repository push confirmation field. 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: The catalog workflow panel can also operate on the same selected rows:
- `Generate` creates a signed candidate below `runtime/release-candidates/`. - `Generate` creates a signed candidate below `runtime/release-candidates/`.
- `Preview` validates a candidate and shows what would be copied into the - `Preview` validates a candidate and shows what would be copied into the
website repository. website repository.
- `Apply + Tag` requires `APPLY` in the confirmation field. - `Apply + Website Tag` requires `APPLY` in the confirmation field.
- `Push` requires `PUSH` 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 The default signing key is
`$HOME/.config/govoplan/release-keys/release-key-1.pem` when no signing key is `$HOME/.config/govoplan/release-keys/release-key-1.pem` when no signing key is

View File

@@ -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()

View File

@@ -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()

View File

@@ -18,6 +18,7 @@ __all__ = [
"prepare_repositories", "prepare_repositories",
"push_repositories", "push_repositories",
"sync_repositories", "sync_repositories",
"tag_repositories",
] ]
@@ -64,4 +65,8 @@ def _load_lazy_export(name: str):
from .repository_sync import sync_repositories from .repository_sync import sync_repositories
return sync_repositories return sync_repositories
if name == "tag_repositories":
from .repository_tag import tag_repositories
return tag_repositories
raise AttributeError(name) raise AttributeError(name)

View File

@@ -15,6 +15,7 @@ from .catalog import canonical_hash
from .model import CatalogPublishResult, CatalogPublishStep from .model import CatalogPublishResult, CatalogPublishStep
from .module_directory import write_module_directory from .module_directory import write_module_directory
from .selective_catalog import trusted_keys_from_keyring 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 .version_alignment import candidate_catalog_version_issues
from .workspace import DEFAULT_WORKSPACE_ROOT, resolve_workspace_root, website_root from .workspace import DEFAULT_WORKSPACE_ROOT, resolve_workspace_root, website_root
@@ -31,6 +32,7 @@ def publish_catalog_candidate(
tag: bool = False, tag: bool = False,
push: bool = False, push: bool = False,
remote: str = "origin", remote: str = "origin",
source_remote: str = "origin",
branch: str | None = None, branch: str | None = None,
npm: str = "npm", npm: str = "npm",
tag_name: str | None = None, tag_name: str | None = None,
@@ -79,6 +81,23 @@ def publish_catalog_candidate(
f"expected {issue.expected!r} ({issue.message})" f"expected {issue.expected!r} ({issue.message})"
for issue in version_issues 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 {}) 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 target_keyring_payload = read_json(target_keyring) if target_keyring.exists() else None
publication_trust = trusted_keys_from_keyring( publication_trust = trusted_keys_from_keyring(

View File

@@ -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")

View File

@@ -19,6 +19,11 @@ from .contracts import collect_contracts
from .git_state import collect_repository_snapshot from .git_state import collect_repository_snapshot
from .model import CatalogEntryChange, SelectiveCatalogCandidate from .model import CatalogEntryChange, SelectiveCatalogCandidate
from .module_directory import write_module_directory 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 .version_alignment import selected_repository_version_issues
from .workspace import load_repository_specs, resolve_workspace_root, website_root from .workspace import load_repository_specs, resolve_workspace_root, website_root
@@ -35,6 +40,7 @@ def build_selective_catalog_candidate(
signing_keys: tuple[str, ...] = (), signing_keys: tuple[str, ...] = (),
public_base_url: str = DEFAULT_PUBLIC_BASE_URL, public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
repository_base: str = GITEA_BASE, repository_base: str = GITEA_BASE,
source_remote: str = "origin",
expires_days: int = 90, expires_days: int = 90,
sequence: int | None = None, sequence: int | None = None,
check_public: bool = True, check_public: bool = True,
@@ -48,6 +54,15 @@ def build_selective_catalog_candidate(
workspace = resolve_workspace_root(workspace_root) workspace = resolve_workspace_root(workspace_root)
enforce_selected_version_alignment(repo_versions=repo_versions, workspace=workspace) 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) web_root = website_root(workspace)
source_catalog = resolve_base_catalog(base_catalog=base_catalog, web_root=web_root, channel=channel) source_catalog = resolve_base_catalog(base_catalog=base_catalog, web_root=web_root, channel=channel)
payload = read_catalog(source_catalog) payload = read_catalog(source_catalog)
@@ -66,7 +81,15 @@ def build_selective_catalog_candidate(
release = {} release = {}
release["catalog_url"] = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json" 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["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 candidate["release"] = release
repo_contracts = contracts_by_repo(workspace) repo_contracts = contracts_by_repo(workspace)
@@ -76,6 +99,11 @@ def build_selective_catalog_candidate(
repo_contracts=repo_contracts, repo_contracts=repo_contracts,
repository_base=repository_base.rstrip("/"), 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) output_root = resolve_output_dir(output_dir=output_dir, channel=channel, generated_at=generated_at)
catalog_path = output_root / "channels" / f"{channel}.json" 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)) 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: def resolve_base_catalog(*, base_catalog: Path | str | None, web_root: Path, channel: str) -> Path | str:
if base_catalog is not None: if base_catalog is not None:
value = str(base_catalog) value = str(base_catalog)

View File

@@ -126,6 +126,7 @@ def compatibility_issues(dashboard: ReleaseDashboard) -> tuple[CompatibilityIssu
def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashboard, channel: str) -> tuple[ReleasePlanStep, ...]: def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashboard, channel: str) -> tuple[ReleasePlanStep, ...]:
steps: list[ReleasePlanStep] = [] steps: list[ReleasePlanStep] = []
snapshots = {repo.spec.name: repo for repo in dashboard.repositories}
for unit in units: for unit in units:
steps.append( steps.append(
ReleasePlanStep( ReleasePlanStep(
@@ -150,17 +151,19 @@ def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashb
status="needs-executor", status="needs-executor",
) )
) )
steps.append( snapshot = snapshots.get(unit.repo)
ReleasePlanStep( if unit.current_version != unit.target_version or (snapshot is not None and snapshot.dirty):
id=f"{unit.repo}:commit", steps.append(
title=f"Commit {unit.repo} release changes", ReleasePlanStep(
detail="Commit reviewed changes for this release unit only.", id=f"{unit.repo}:commit",
command=f"git add -A && git commit -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}", title=f"Commit {unit.repo} release changes",
cwd=f"{dashboard.workspace_root}/{unit.repo}", detail="Commit reviewed changes for this release unit only.",
mutating=True, command=f"git add -A && git commit -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}",
repo=unit.repo, cwd=f"{dashboard.workspace_root}/{unit.repo}",
mutating=True,
repo=unit.repo,
)
) )
)
steps.append( steps.append(
ReleasePlanStep( ReleasePlanStep(
id=f"{unit.repo}:tag", id=f"{unit.repo}:tag",
@@ -229,10 +232,16 @@ def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashb
ReleasePlanStep( ReleasePlanStep(
id="catalog:validate-sign-publish", id="catalog:validate-sign-publish",
title=f"Validate, sign, and publish {channel}", 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.", detail=(
command="Review the candidate under runtime/release-candidates before publishing it to the website repository.", "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, cwd=dashboard.meta_root,
status="needs-publish-executor", status="planned",
) )
) )
return tuple(steps) return tuple(steps)

View File

@@ -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"/(?P<repo>govoplan-[a-z0-9-]+)\.git@v(?P<version>[^\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

View File

@@ -27,6 +27,7 @@ def main() -> int:
selective.add_argument("--output-dir", type=Path, help="Candidate output directory. Defaults to runtime/release-candidates/<channel>-<timestamp>.") selective.add_argument("--output-dir", type=Path, help="Candidate output directory. Defaults to runtime/release-candidates/<channel>-<timestamp>.")
selective.add_argument("--public-base-url", default="https://govoplan.add-ideas.de") 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("--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("--expires-days", type=int, default=90)
selective.add_argument("--sequence", type=int) selective.add_argument("--sequence", type=int)
selective.add_argument("--skip-public-check", action="store_true") 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("--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("--push", action="store_true", help="Push the website branch and tag. Implies --commit.")
publish.add_argument("--remote", default="origin") 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("--branch")
publish.add_argument("--tag-name") publish.add_argument("--tag-name")
publish.add_argument("--npm", default="npm") publish.add_argument("--npm", default="npm")
@@ -68,6 +70,7 @@ def main() -> int:
signing_keys=tuple(args.catalog_signing_key), signing_keys=tuple(args.catalog_signing_key),
public_base_url=args.public_base_url, public_base_url=args.public_base_url,
repository_base=args.repository_base, repository_base=args.repository_base,
source_remote=args.source_remote,
expires_days=args.expires_days, expires_days=args.expires_days,
sequence=args.sequence, sequence=args.sequence,
check_public=not args.skip_public_check, check_public=not args.skip_public_check,
@@ -90,6 +93,7 @@ def main() -> int:
tag=args.tag, tag=args.tag,
push=args.push, push=args.push,
remote=args.remote, remote=args.remote,
source_remote=args.source_remote,
branch=args.branch, branch=args.branch,
npm=args.npm, npm=args.npm,
tag_name=args.tag_name, tag_name=args.tag_name,

View File

@@ -18,6 +18,7 @@ from govoplan_release import (
prepare_repositories, prepare_repositories,
push_repositories, push_repositories,
sync_repositories, sync_repositories,
tag_repositories,
) )
from govoplan_release.model import to_jsonable from govoplan_release.model import to_jsonable
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT
@@ -35,6 +36,7 @@ class CatalogCandidateRequest(BaseModel):
sequence: int | None = None sequence: int | None = None
public_base_url: str = "https://govoplan.add-ideas.de" public_base_url: str = "https://govoplan.add-ideas.de"
repository_base: str = "git+ssh://git@git.add-ideas.de/add-ideas" repository_base: str = "git+ssh://git@git.add-ideas.de/add-ideas"
source_remote: str = "origin"
check_public: bool = True check_public: bool = True
@@ -48,6 +50,7 @@ class PublishCandidateRequest(BaseModel):
tag: bool = False tag: bool = False
push: bool = False push: bool = False
remote: str = "origin" remote: str = "origin"
source_remote: str = "origin"
branch: str | None = None branch: str | None = None
tag_name: str | None = None tag_name: str | None = None
npm: str = "npm" npm: str = "npm"
@@ -77,6 +80,16 @@ class RepositoryPrepareRequest(BaseModel):
confirm: str = "" 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: 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 = FastAPI(title="GovOPlaN Release Console", version="0.1.0")
app.state.workspace_root = workspace_root 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()) signing_keys = tuple(request.signing_keys or default_signing_keys())
if not signing_keys: if not signing_keys:
raise HTTPException(status_code=400, detail="No signing key supplied and default release key was not found") raise HTTPException(status_code=400, detail="No signing key supplied and default release key was not found")
candidate = build_selective_catalog_candidate( try:
repo_versions=repo_versions, candidate = build_selective_catalog_candidate(
channel=request.channel, repo_versions=repo_versions,
workspace_root=app.state.workspace_root, channel=request.channel,
output_dir=request.output_dir, workspace_root=app.state.workspace_root,
base_catalog=request.base_catalog, output_dir=request.output_dir,
signing_keys=signing_keys, base_catalog=request.base_catalog,
public_base_url=request.public_base_url, signing_keys=signing_keys,
repository_base=request.repository_base, public_base_url=request.public_base_url,
expires_days=request.expires_days, repository_base=request.repository_base,
sequence=request.sequence, source_remote=request.source_remote,
check_public=request.check_public, 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) return to_jsonable(candidate)
@app.post("/api/catalog-candidates/publish") @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, tag=request.tag,
push=request.push, push=request.push,
remote=request.remote, remote=request.remote,
source_remote=request.source_remote,
branch=request.branch, branch=request.branch,
tag_name=request.tag_name, tag_name=request.tag_name,
npm=request.npm, npm=request.npm,
@@ -278,6 +296,26 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No
) )
return to_jsonable(result) 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 return app

View File

@@ -692,15 +692,34 @@
<section> <section>
<div class="section-head"> <div class="section-head">
<h2>3. Release Tag Plan</h2> <h2>3. Source Release Tag</h2>
<small id="planStatus"></small> <small><span id="tagStatus">idle</span> · plan <span id="planStatus">idle</span></small>
</div>
<div class="details">
<p class="hint">Creates an annotated source tag at the selected clean HEAD. Publish pushes the branch and tag atomically; existing release tags are never moved.</p>
<div class="form-grid">
<div>
<label for="tagMessage">Tag message</label>
<input id="tagMessage" type="text" placeholder="default Release &lt;repo&gt; &lt;tag&gt;" />
</div>
<div>
<label for="tagConfirmText">Confirm</label>
<input id="tagConfirmText" type="text" placeholder="TAG or PUBLISH" />
</div>
</div>
<div class="button-row">
<button class="secondary" id="previewReleaseTags">Preview Tag + Publish</button>
<button id="createReleaseTags">Create Tags</button>
<button class="danger" id="publishReleaseTags">Publish Tags</button>
</div>
<div class="actions" id="tagOutput"></div>
</div> </div>
<div class="actions" id="actions"></div> <div class="actions" id="actions"></div>
</section> </section>
<section> <section>
<div class="section-head"> <div class="section-head">
<h2>4. Signed Release</h2> <h2>4. Signed Website Catalog</h2>
<small id="workflowStatus"></small> <small id="workflowStatus"></small>
</div> </div>
<div class="details"> <div class="details">
@@ -721,8 +740,8 @@
<div class="button-row"> <div class="button-row">
<button id="generateCandidate">Generate</button> <button id="generateCandidate">Generate</button>
<button class="secondary" id="previewPublish">Preview</button> <button class="secondary" id="previewPublish">Preview</button>
<button class="secondary" id="applyPublish">Apply + Tag</button> <button class="secondary" id="applyPublish">Apply + Website Tag</button>
<button class="danger" id="pushPublish">Push</button> <button class="danger" id="pushPublish">Push Website Release</button>
</div> </div>
<div class="actions" id="workflowOutput"></div> <div class="actions" id="workflowOutput"></div>
</div> </div>
@@ -801,6 +820,13 @@
catalogStatus: document.getElementById("catalogStatus"), catalogStatus: document.getElementById("catalogStatus"),
actions: document.getElementById("actions"), actions: document.getElementById("actions"),
planStatus: document.getElementById("planStatus"), 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"), signingKey: document.getElementById("signingKey"),
candidateDir: document.getElementById("candidateDir"), candidateDir: document.getElementById("candidateDir"),
confirmText: document.getElementById("confirmText"), confirmText: document.getElementById("confirmText"),
@@ -858,6 +884,9 @@
elements.previewPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: false })); elements.previewPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: false }));
elements.commitPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: true })); elements.commitPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: true }));
elements.buildSelectedPlan.addEventListener("click", buildSelectedPlan); 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.selectUnpushedUnits.addEventListener("click", selectUnpushedUnits);
elements.selectChangedUnits.addEventListener("click", selectChangedUnits); elements.selectChangedUnits.addEventListener("click", selectChangedUnits);
elements.selectUnreleasedUnits.addEventListener("click", selectUnreleasedUnits); elements.selectUnreleasedUnits.addEventListener("click", selectUnreleasedUnits);
@@ -974,18 +1003,18 @@
: "No dirty worktrees in the current dashboard." : "No dirty worktrees in the current dashboard."
), ),
workflowStage( workflowStage(
"3. Generate release tag", "3. Publish source release tags",
tagStatus, tagStatus,
releaseBlockers ? "block" : selected ? "ok" : "warn", releaseBlockers ? "block" : selected ? "ok" : "warn",
selected selected
? `${selected} selected; cockpit builds the tag plan, release tagging still runs through the guarded release script.` ? `${selected} selected; use Preview Tag + Publish, then Create Tags or Publish Tags after reviewing the source-release preflight.`
: "Select repositories, set target versions, then build the release tag plan." : "Select repositories, set target versions, then preview the source release tags."
), ),
workflowStage( workflowStage(
"4. Sign and publish release", "4. Sign and publish website catalog",
signedStatus, signedStatus,
hasCandidate ? "ok" : "warn", 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( workflowStage(
"5. Maintain install catalog", "5. Maintain install catalog",
@@ -1595,6 +1624,65 @@
}).join(""); }).join("");
} }
async function releaseSelectedTags(options) {
const repos = selectedRepoNames();
if (!repos.length) {
elements.tagStatus.textContent = "idle";
elements.tagOutput.innerHTML = `<div class="action"><h3>${pill("idle", "warn")} No repositories selected</h3><p>Select one or more repositories first.</p></div>`;
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 = `<div class="action"><h3>${pill("error", "block")} Source release failed</h3><p>${escapeHtml(error.message)}</p></div>`;
} 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 = `<div class="muted">No source release actions returned.</div>`;
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) => `<pre>${escapeHtml(command)}</pre>`)
.join("");
const output = [row.stdout, row.stderr].filter(Boolean).map((value) => `<pre>${escapeHtml(value)}</pre>`).join("");
const meta = [
row.tag,
row.branch ? `branch ${row.branch}` : "",
row.head ? `HEAD ${row.head}` : "",
row.remote ? `remote ${row.remote}` : "",
].filter(Boolean).join(" | ");
return `<div class="action">
<h3>${pill(row.status, kind)} ${escapeHtml(row.repo)}</h3>
<p>${escapeHtml(row.detail || "")}</p>
${meta ? `<p class="action-meta">${escapeHtml(meta)}</p>` : ""}
${commands}
${output}
</div>`;
}).join("");
}
async function syncSelectedRepos(options) { async function syncSelectedRepos(options) {
const repos = selectedRepoNames(); const repos = selectedRepoNames();
if (!repos.length) { if (!repos.length) {
@@ -1738,7 +1826,18 @@
const steps = Array.isArray(result.steps) const steps = Array.isArray(result.steps)
? result.steps.map((step) => `<div class="action"><h3>${pill(step.status, step.status === "blocked" ? "block" : step.status === "planned" ? "warn" : "ok")} ${escapeHtml(step.title)}</h3><p>${escapeHtml(step.detail)}</p>${step.command ? `<pre>${escapeHtml(step.command)}</pre>` : ""}</div>`).join("") ? result.steps.map((step) => `<div class="action"><h3>${pill(step.status, step.status === "blocked" ? "block" : step.status === "planned" ? "warn" : "ok")} ${escapeHtml(step.title)}</h3><p>${escapeHtml(step.detail)}</p>${step.command ? `<pre>${escapeHtml(step.command)}</pre>` : ""}</div>`).join("")
: ""; : "";
elements.workflowOutput.innerHTML = `<div class="action"><h3>${escapeHtml(title)}</h3>${lines.join("")}${changes}</div>${steps}`; const notes = Array.isArray(result.notes)
? result.notes.map((note) => `<p>${escapeHtml(note)}</p>`).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
? `<div class="action"><details open><summary>Validation and provenance details</summary>${notes}</details></div>`
: "";
elements.workflowOutput.innerHTML = `<div class="action"><h3>${pill(result.status || "result", resultKind)} ${escapeHtml(title)}</h3>${lines.join("")}${changes}</div>${noteDetails}${steps}`;
} }
function setBusy(buttons, busy) { function setBusy(buttons, busy) {