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,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)

View File

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

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 .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)

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, ...]:
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)

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("--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,

View File

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

View File

@@ -692,15 +692,34 @@
<section>
<div class="section-head">
<h2>3. Release Tag Plan</h2>
<small id="planStatus"></small>
<h2>3. Source Release Tag</h2>
<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 class="actions" id="actions"></div>
</section>
<section>
<div class="section-head">
<h2>4. Signed Release</h2>
<h2>4. Signed Website Catalog</h2>
<small id="workflowStatus"></small>
</div>
<div class="details">
@@ -721,8 +740,8 @@
<div class="button-row">
<button id="generateCandidate">Generate</button>
<button class="secondary" id="previewPublish">Preview</button>
<button class="secondary" id="applyPublish">Apply + Tag</button>
<button class="danger" id="pushPublish">Push</button>
<button class="secondary" id="applyPublish">Apply + Website Tag</button>
<button class="danger" id="pushPublish">Push Website Release</button>
</div>
<div class="actions" id="workflowOutput"></div>
</div>
@@ -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 = `<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) {
const repos = selectedRepoNames();
if (!repos.length) {
@@ -1738,7 +1826,18 @@
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("")
: "";
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) {