Complete guided release console workflow
This commit is contained in:
+46
-28
@@ -22,10 +22,17 @@ effects can be verified safely:
|
|||||||
- freeze a selective plan as a durable, resumable local release run
|
- freeze a selective plan as a durable, resumable local release run
|
||||||
- durably preflight a creation-time-bound repository, create its annotated tag,
|
- durably preflight a creation-time-bound repository, create its annotated tag,
|
||||||
and publish its branch/tag pair atomically
|
and publish its branch/tag pair atomically
|
||||||
|
- deterministically update recognized package/manifest version declarations and
|
||||||
|
commit only the receipt-bound metadata paths
|
||||||
|
- order selected module providers before consumers, create module tags before
|
||||||
|
Core, regenerate Core's selected WebUI release lock, and re-run alignment
|
||||||
|
before any remote push
|
||||||
- build selected Python wheels and generate a private, signed, receipt-bound
|
- build selected Python wheels and generate a private, signed, receipt-bound
|
||||||
catalog candidate
|
catalog candidate
|
||||||
- publish that exact candidate through a verified website commit and immutable
|
- publish that exact candidate through a verified website commit and immutable
|
||||||
tag after explicit confirmation
|
tag after explicit confirmation
|
||||||
|
- install selected candidate wheels into a private no-network/no-dependency
|
||||||
|
target and verify their installed metadata against the frozen plan
|
||||||
|
|
||||||
Start it from the meta repository:
|
Start it from the meta repository:
|
||||||
|
|
||||||
@@ -79,21 +86,23 @@ from the saved-run selector. Problems in unselected repositories remain visible
|
|||||||
as workspace notices but do not lock an unrelated release; the selective plan
|
as workspace notices but do not lock an unrelated release; the selective plan
|
||||||
is the authority for blockers in the selected repository set.
|
is the authority for blockers in the selected repository set.
|
||||||
|
|
||||||
Installation verification remains an explicit unavailable phase after a
|
Installation verification is an explicit durable step after catalog
|
||||||
durable run completes. It is currently enforced by release-integration CI,
|
publication. It verifies the exact candidate receipt, installs every selected
|
||||||
rather than being presented as a successful local-console step. This prevents
|
Python wheel into a temporary target with network and dependency resolution
|
||||||
the guide from treating source publication as proof that the published package
|
disabled, and compares installed names and versions with the frozen plan.
|
||||||
can be installed and started.
|
Deployment startup, database upgrades, and module-combination smoke tests remain
|
||||||
|
release-integration CI gates; the console does not represent its local wheel
|
||||||
|
check as a production deployment.
|
||||||
|
|
||||||
`Build Plan` also returns structured release-gate findings for each selected
|
`Build Plan` also returns structured release-gate findings for each selected
|
||||||
repository. The plan names the recommended next action and gives an explicit
|
repository. The plan names the recommended next action and gives an explicit
|
||||||
remediation for source-version, lockfile, Core WebUI composition, Git state, and
|
remediation for source-version, lockfile, Core WebUI composition, Git state, and
|
||||||
worktree findings. A target version that has not yet been applied consistently
|
worktree findings. A target version that differs from internally consistent
|
||||||
to the selected source tree is therefore explained before the source-tag
|
source metadata becomes a bounded `UPDATE` step. Unsupported, missing, or
|
||||||
preflight rather than appearing only as an error after the operator tries to
|
internally inconsistent declarations remain a blocker with exact remediation.
|
||||||
tag. `source_preflight_ready` means that the plan-visible source gates pass; the
|
`source_preflight_ready` means that plan-visible source gates pass or have a
|
||||||
non-mutating `Preview Tag + Publish` remains mandatory for remote, manifest, and
|
bounded deterministic mutation; the non-mutating `Preview Tag + Publish`
|
||||||
immutable-tag checks.
|
remains mandatory for remote, manifest, and immutable-tag checks.
|
||||||
|
|
||||||
## Durable release runs
|
## Durable release runs
|
||||||
|
|
||||||
@@ -103,11 +112,12 @@ requires the plan to resolve exactly the requested repositories and target
|
|||||||
versions; the browser cannot submit or replace the plan snapshot. The input and
|
versions; the browser cannot submit or replace the plan snapshot. The input and
|
||||||
plan are then immutable and covered by a canonical SHA-256 integrity digest.
|
plan are then immutable and covered by a canonical SHA-256 integrity digest.
|
||||||
Every executable repository step also carries its creation-time full HEAD,
|
Every executable repository step also carries its creation-time full HEAD,
|
||||||
branch, worktree state, target tag, and a SHA-256 over both the fetch and push
|
branch, target tag, a SHA-256 over both the fetch and push URLs of `origin`, and
|
||||||
URLs of `origin`. Both URLs must exactly equal the remote registered in
|
a SHA-256 over bounded dirty-path names and bytes. Both URLs must exactly equal
|
||||||
`repositories.json`; a changed HEAD, branch, worktree, remote, or push URL
|
the remote registered in `repositories.json`; a changed HEAD, branch, worktree,
|
||||||
requires a new run rather than silently retargeting the frozen compatibility
|
remote, push URL, or metadata byte requires a new run or explicit
|
||||||
decision.
|
interrupted-step reconciliation rather than silently retargeting the frozen
|
||||||
|
compatibility decision.
|
||||||
The complete record also has a checksum so a valid-looking manual edit to its
|
The complete record also has a checksum so a valid-looking manual edit to its
|
||||||
mutable state fails closed. File permissions remain the authority boundary;
|
mutable state fails closed. File permissions remain the authority boundary;
|
||||||
these digests detect accidental or manual corruption, not an attacker who can
|
these digests detect accidental or manual corruption, not an attacker who can
|
||||||
@@ -195,24 +205,32 @@ retry. The UI keeps unavailable controls visible and disabled.
|
|||||||
|
|
||||||
Supported executors durably claim the step before invoking an effect. Exact
|
Supported executors durably claim the step before invoking an effect. Exact
|
||||||
attempt replays return the recorded outcome and never invoke the executor a
|
attempt replays return the recorded outcome and never invoke the executor a
|
||||||
second time. Successful repository preflight, tag, and push steps persist a
|
second time. Successful repository preflight, version, commit, Core-bundle,
|
||||||
bounded repository-state receipt. Tag reconciliation independently requires an
|
tag, and push steps persist a bounded repository-state receipt. Version
|
||||||
|
reconciliation requires aligned declarations in the same commit; commit
|
||||||
|
reconciliation requires the expected single-parent release commit and only
|
||||||
|
recognized metadata paths. Tag reconciliation independently requires an
|
||||||
annotated local tag at the frozen HEAD; push reconciliation additionally
|
annotated local tag at the frozen HEAD; push reconciliation additionally
|
||||||
requires both the remote annotated tag object and remote branch to match.
|
requires both the remote annotated tag object and remote branch to match.
|
||||||
Catalog generation persists
|
Catalog generation persists
|
||||||
only its server-issued opaque candidate ID and canonical catalog SHA-256, then
|
only its server-issued opaque candidate ID and canonical catalog SHA-256, then
|
||||||
re-resolves and re-hashes that private candidate before publication.
|
re-resolves and re-hashes that private candidate before publication.
|
||||||
|
|
||||||
The safe baseline is intentionally narrower than the complete dry-run plan.
|
Repository capabilities are frozen into each plan unit (`python-package`,
|
||||||
Dirty worktrees and version changes still show commit/version steps, but those
|
`webui-package`, `module-manifest`, `database-migrations`, `documentation`,
|
||||||
steps have no durable executor because the frozen run does not bind the exact
|
`core-release-bundle`, and the universal `git-source`) and determine which
|
||||||
proposed file content. A run selecting Core together with one or more modules
|
steps appear. Internally aligned version changes are rendered deterministically
|
||||||
begins with a disabled dependency-ordering barrier: local module tags, Core
|
from recognized TOML, JSON, lockfile, manifest, and package declarations.
|
||||||
release-lock regeneration/commit, Core tagging, alignment verification, and
|
Pre-existing dirty worktrees remain visible but have no commit executor; the
|
||||||
pushes need an explicit DAG executor before that composition can mutate.
|
console never absorbs unrelated operator changes.
|
||||||
Operators must prepare and review those changes outside the console and create
|
|
||||||
a new run from the resulting clean HEAD. The console never skips these steps or
|
For mixed releases, module interface providers are ordered before consumers
|
||||||
claims an end-to-end release succeeded.
|
and Core is tagged last. The durable sequence creates and commits module
|
||||||
|
metadata, creates local module tags, updates Core's selected WebUI references,
|
||||||
|
regenerates the release lock against those local tags, commits/tags Core, and
|
||||||
|
runs a receipt-bound alignment gate before exposing any atomic branch/tag push.
|
||||||
|
A failed step stops later steps while preserving prior receipts for explicit
|
||||||
|
retry or reconciliation.
|
||||||
|
|
||||||
The browser likewise retains the request identifier for an uncertain
|
The browser likewise retains the request identifier for an uncertain
|
||||||
resume/retry/reconciliation response and replays it after reload. A successful
|
resume/retry/reconciliation response and replays it after reload. A successful
|
||||||
|
|||||||
+176
-13
@@ -9,6 +9,7 @@ import tempfile
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
|
||||||
META_ROOT = Path(__file__).resolve().parents[1]
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -17,6 +18,7 @@ if str(RELEASE_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(RELEASE_ROOT))
|
sys.path.insert(0, str(RELEASE_ROOT))
|
||||||
|
|
||||||
from govoplan_release.model import RepositorySpec # noqa: E402
|
from govoplan_release.model import RepositorySpec # noqa: E402
|
||||||
|
from govoplan_release.artifact_identity import inspect_python_wheel # noqa: E402
|
||||||
from govoplan_release.git_state import sanitized_git_environment # noqa: E402
|
from govoplan_release.git_state import sanitized_git_environment # noqa: E402
|
||||||
from govoplan_release.release_execution import ( # noqa: E402
|
from govoplan_release.release_execution import ( # noqa: E402
|
||||||
ExecutorSpec,
|
ExecutorSpec,
|
||||||
@@ -30,6 +32,7 @@ from govoplan_release.release_execution import ( # noqa: E402
|
|||||||
require_trusted_release_runtime,
|
require_trusted_release_runtime,
|
||||||
verify_frozen_repository_tag_receipt,
|
verify_frozen_repository_tag_receipt,
|
||||||
verify_repository_preflight_binding,
|
verify_repository_preflight_binding,
|
||||||
|
verify_candidate_installation,
|
||||||
_clone_catalog_sources,
|
_clone_catalog_sources,
|
||||||
_checkout_frozen_source,
|
_checkout_frozen_source,
|
||||||
_flatpak_proxy_environment,
|
_flatpak_proxy_environment,
|
||||||
@@ -489,20 +492,180 @@ class ReleaseExecutionTests(unittest.TestCase):
|
|||||||
expected_receipt={"kind": "repository_state"},
|
expected_receipt={"kind": "repository_state"},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_commit_step_has_no_durable_executor(self) -> None:
|
def test_commit_step_has_narrow_confirmation(self) -> None:
|
||||||
self.assertIsNone(
|
spec = executor_spec(
|
||||||
executor_spec(
|
{
|
||||||
{
|
"id": "govoplan-files:commit",
|
||||||
"id": "govoplan-files:commit",
|
"status": "planned",
|
||||||
"status": "planned",
|
"mutating": True,
|
||||||
"mutating": True,
|
"repo": "govoplan-files",
|
||||||
"repo": "govoplan-files",
|
"source_binding": {
|
||||||
"source_binding": {
|
"kind": "repository_state",
|
||||||
"kind": "repository_state",
|
},
|
||||||
},
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
self.assertIsNotNone(spec)
|
||||||
|
self.assertEqual("commit", spec.kind)
|
||||||
|
self.assertEqual("COMMIT", spec.confirmation)
|
||||||
|
|
||||||
|
def test_version_and_commit_executors_bind_only_release_metadata(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
workspace = Path(temp_dir)
|
||||||
|
repository = workspace / "govoplan-files"
|
||||||
|
remote = workspace / "files.git"
|
||||||
|
_git(workspace, "init", "--bare", str(remote))
|
||||||
|
_git(workspace, "init", "-b", "main", str(repository))
|
||||||
|
_git(repository, "config", "user.name", "Release Test")
|
||||||
|
_git(repository, "config", "user.email", "release@example.test")
|
||||||
|
(repository / "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "govoplan-files"\nversion = "1.2.3"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
_git(repository, "add", "pyproject.toml")
|
||||||
|
_git(repository, "commit", "-m", "Initial")
|
||||||
|
_git(repository, "remote", "add", "origin", str(remote))
|
||||||
|
_git(repository, "push", "-u", "origin", "main")
|
||||||
|
repository_spec = _repository_spec(remote)
|
||||||
|
plan_step = {
|
||||||
|
"id": "govoplan-files:version",
|
||||||
|
"repo": "govoplan-files",
|
||||||
|
"source_binding": {"kind": "repository_state"},
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_release.release_execution.load_repository_specs",
|
||||||
|
return_value=(repository_spec,),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_release.release_execution.require_trusted_release_runtime"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
before = repository_state_receipt(
|
||||||
|
repo="govoplan-files",
|
||||||
|
target_tag="v1.2.4",
|
||||||
|
workspace_root=workspace,
|
||||||
|
)
|
||||||
|
_result, updated = execute_repository_step(
|
||||||
|
spec=ExecutorSpec(
|
||||||
|
"version",
|
||||||
|
"UPDATE",
|
||||||
|
"version_metadata_updated",
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
plan_step=plan_step,
|
||||||
|
repo_versions={"govoplan-files": "1.2.4"},
|
||||||
|
workspace_root=workspace,
|
||||||
|
remote="origin",
|
||||||
|
expected_receipt=before,
|
||||||
|
)
|
||||||
|
self.assertFalse(updated["worktree_clean"])
|
||||||
|
self.assertNotEqual(
|
||||||
|
before["worktree_sha256"],
|
||||||
|
updated["worktree_sha256"],
|
||||||
|
)
|
||||||
|
|
||||||
|
plan_step["id"] = "govoplan-files:commit"
|
||||||
|
_result, committed = execute_repository_step(
|
||||||
|
spec=ExecutorSpec(
|
||||||
|
"commit",
|
||||||
|
"COMMIT",
|
||||||
|
"release_commit_created",
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
plan_step=plan_step,
|
||||||
|
repo_versions={"govoplan-files": "1.2.4"},
|
||||||
|
workspace_root=workspace,
|
||||||
|
remote="origin",
|
||||||
|
expected_receipt=updated,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(committed["worktree_clean"])
|
||||||
|
self.assertNotEqual(before["head"], committed["head"])
|
||||||
|
self.assertIn(
|
||||||
|
'version = "1.2.4"',
|
||||||
|
(repository / "pyproject.toml").read_text(encoding="utf-8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_candidate_installation_verifies_installed_metadata(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
workspace = Path(temp_dir)
|
||||||
|
repository = workspace / "govoplan-files"
|
||||||
|
repository.mkdir()
|
||||||
|
(repository / "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "govoplan-files"\nversion = "1.2.4"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
artifacts = workspace / "candidate" / "artifacts"
|
||||||
|
artifacts.mkdir(parents=True)
|
||||||
|
wheel = artifacts / "govoplan_files-1.2.4-py3-none-any.whl"
|
||||||
|
members = {
|
||||||
|
"govoplan_files/__init__.py": '__version__ = "1.2.4"\n',
|
||||||
|
"govoplan_files-1.2.4.dist-info/METADATA": (
|
||||||
|
"Metadata-Version: 2.1\n"
|
||||||
|
"Name: govoplan-files\n"
|
||||||
|
"Version: 1.2.4\n"
|
||||||
|
),
|
||||||
|
"govoplan_files-1.2.4.dist-info/WHEEL": (
|
||||||
|
"Wheel-Version: 1.0\n"
|
||||||
|
"Generator: release-test\n"
|
||||||
|
"Root-Is-Purelib: true\n"
|
||||||
|
"Tag: py3-none-any\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
record = "\n".join(f"{name},," for name in members)
|
||||||
|
record += "\ngovoplan_files-1.2.4.dist-info/RECORD,,\n"
|
||||||
|
with zipfile.ZipFile(wheel, "w") as archive:
|
||||||
|
for name, content in members.items():
|
||||||
|
archive.writestr(name, content)
|
||||||
|
archive.writestr(
|
||||||
|
"govoplan_files-1.2.4.dist-info/RECORD",
|
||||||
|
record,
|
||||||
|
)
|
||||||
|
identity = inspect_python_wheel(wheel)
|
||||||
|
channels = workspace / "candidate" / "channels"
|
||||||
|
channels.mkdir()
|
||||||
|
(channels / "stable.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"release": {
|
||||||
|
"selected_units": [
|
||||||
|
{
|
||||||
|
"repo": "govoplan-files",
|
||||||
|
"version": "1.2.4",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"package_name": "govoplan-files",
|
||||||
|
"archive_sha256": identity.archive_sha256,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"core_release": {},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"python_package": "govoplan-files",
|
||||||
|
"python_ref": (
|
||||||
|
"govoplan-files @ "
|
||||||
|
"git+ssh://git@example.test/GovOPlaN/"
|
||||||
|
"govoplan-files.git@v1.2.4"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = verify_candidate_installation(
|
||||||
|
candidate_path=workspace / "candidate",
|
||||||
|
repo_versions={"govoplan-files": "1.2.4"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("verified", result["status"])
|
||||||
|
self.assertEqual("govoplan-files", result["packages"][0]["repo"])
|
||||||
|
self.assertEqual("1.2.4", result["packages"][0]["version"])
|
||||||
|
|
||||||
def test_frozen_checkout_uses_receipt_commit_not_live_worktree(self) -> None:
|
def test_frozen_checkout_uses_receipt_commit_not_live_worktree(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
|||||||
@@ -47,6 +47,38 @@ branch_labels: Union[str, Sequence[str], None] = None
|
|||||||
self.assertEqual(migration.depends_on, ("core",))
|
self.assertEqual(migration.depends_on, ("core",))
|
||||||
self.assertEqual(migration.branch_labels, ())
|
self.assertEqual(migration.branch_labels, ())
|
||||||
|
|
||||||
|
def test_development_wrapper_uses_release_revision_metadata(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
versions = root / "versions"
|
||||||
|
development = root / "dev_versions"
|
||||||
|
versions.mkdir()
|
||||||
|
development.mkdir()
|
||||||
|
release = versions / "1234_example.py"
|
||||||
|
release.write_text(
|
||||||
|
'revision = "1234"\n'
|
||||||
|
'down_revision = "base"\n'
|
||||||
|
'depends_on = "core"\n'
|
||||||
|
"branch_labels = None\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
wrapper = development / release.name
|
||||||
|
wrapper.write_text(
|
||||||
|
"revision = _migration.revision\n"
|
||||||
|
"down_revision = _migration.down_revision\n"
|
||||||
|
"depends_on = _migration.depends_on\n"
|
||||||
|
"branch_labels = _migration.branch_labels\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
migration = audit.parse_migration_file("govoplan-core", wrapper)
|
||||||
|
|
||||||
|
self.assertIsNotNone(migration)
|
||||||
|
self.assertEqual("1234", migration.revision)
|
||||||
|
self.assertEqual(("base",), migration.down_revisions)
|
||||||
|
self.assertEqual(("core",), migration.depends_on)
|
||||||
|
|
||||||
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
|
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
|
||||||
audit = load_audit_module()
|
audit = load_audit_module()
|
||||||
migrations = [
|
migrations = [
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from govoplan_release.selective_planner import build_selective_release_plan # n
|
|||||||
|
|
||||||
|
|
||||||
class ReleasePlanGuidanceTests(unittest.TestCase):
|
class ReleasePlanGuidanceTests(unittest.TestCase):
|
||||||
def test_unprepared_target_has_structured_remediation_and_recommendation(
|
def test_unprepared_target_gets_bounded_version_and_commit_steps(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
@@ -40,20 +40,25 @@ class ReleasePlanGuidanceTests(unittest.TestCase):
|
|||||||
repo_versions={"govoplan-files": "1.2.4"},
|
repo_versions={"govoplan-files": "1.2.4"},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual("blocked", plan.status)
|
self.assertEqual("attention", plan.status)
|
||||||
self.assertFalse(plan.source_preflight_ready)
|
self.assertTrue(plan.source_preflight_ready)
|
||||||
finding = next(
|
self.assertFalse(
|
||||||
item
|
any(
|
||||||
for item in plan.gate_findings
|
item.code == "repository_version_alignment"
|
||||||
if item.code == "repository_version_alignment"
|
for item in plan.gate_findings
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual("govoplan-files", finding.repo)
|
steps = {step.id: step for step in plan.dry_run_steps}
|
||||||
self.assertEqual("pyproject.toml", finding.source)
|
self.assertEqual("planned", steps["govoplan-files:version"].status)
|
||||||
self.assertEqual("1.2.4", finding.expected)
|
self.assertEqual("planned", steps["govoplan-files:commit"].status)
|
||||||
self.assertEqual("1.2.3", finding.actual)
|
self.assertIn("pyproject.toml", steps["govoplan-files:version"].detail)
|
||||||
self.assertIn("Regenerate lockfiles", finding.remediation)
|
self.assertTrue(
|
||||||
self.assertEqual("resolve_release_gate", plan.recommended_action.id)
|
any(
|
||||||
self.assertEqual("govoplan-files", plan.recommended_action.repo)
|
"version metadata will be updated" in warning
|
||||||
|
for warning in plan.units[0].warnings
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIn("python-package", plan.units[0].capabilities)
|
||||||
|
|
||||||
def test_aligned_target_recommends_non_mutating_tag_preview(self) -> None:
|
def test_aligned_target_recommends_non_mutating_tag_preview(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
@@ -74,6 +79,91 @@ class ReleasePlanGuidanceTests(unittest.TestCase):
|
|||||||
self.assertEqual("preview_source_release", plan.recommended_action.id)
|
self.assertEqual("preview_source_release", plan.recommended_action.id)
|
||||||
self.assertIn("Preview Tag + Publish", plan.recommended_action.remediation)
|
self.assertIn("Preview Tag + Publish", plan.recommended_action.remediation)
|
||||||
|
|
||||||
|
def test_mixed_plan_tags_modules_before_core_and_aligns_before_push(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
workspace = Path(temp_dir)
|
||||||
|
for repo_name in ("govoplan-core", "govoplan-files"):
|
||||||
|
repo_path = workspace / repo_name
|
||||||
|
repo_path.mkdir()
|
||||||
|
(repo_path / "pyproject.toml").write_text(
|
||||||
|
f'[project]\nname = "{repo_name}"\nversion = "1.2.3"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
repositories = tuple(
|
||||||
|
RepositorySnapshot(
|
||||||
|
spec=RepositorySpec(
|
||||||
|
name=repo_name,
|
||||||
|
category="core" if repo_name == "govoplan-core" else "module",
|
||||||
|
subtype="platform" if repo_name == "govoplan-core" else "infrastructure",
|
||||||
|
remote=f"git@example.test:GovOPlaN/{repo_name}.git",
|
||||||
|
path=repo_name,
|
||||||
|
),
|
||||||
|
absolute_path=str(workspace / repo_name),
|
||||||
|
exists=True,
|
||||||
|
is_git=True,
|
||||||
|
has_head=True,
|
||||||
|
branch="main",
|
||||||
|
versions=VersionSnapshot(pyproject="1.2.3"),
|
||||||
|
local_target_tag_exists=False,
|
||||||
|
)
|
||||||
|
for repo_name in ("govoplan-core", "govoplan-files")
|
||||||
|
)
|
||||||
|
base = dashboard(workspace=workspace, version="1.2.3")
|
||||||
|
mixed = ReleaseDashboard(
|
||||||
|
generated_at=base.generated_at,
|
||||||
|
meta_root=base.meta_root,
|
||||||
|
workspace_root=base.workspace_root,
|
||||||
|
target_version=None,
|
||||||
|
target_tag=None,
|
||||||
|
online=False,
|
||||||
|
include_migrations=False,
|
||||||
|
summary=DashboardSummary(
|
||||||
|
repository_count=2,
|
||||||
|
missing_count=0,
|
||||||
|
dirty_count=0,
|
||||||
|
ahead_count=0,
|
||||||
|
behind_count=0,
|
||||||
|
no_head_count=0,
|
||||||
|
error_count=0,
|
||||||
|
safe_directory_count=0,
|
||||||
|
local_target_tag_missing_count=0,
|
||||||
|
status="ready",
|
||||||
|
),
|
||||||
|
repositories=repositories,
|
||||||
|
catalog=base.catalog,
|
||||||
|
)
|
||||||
|
plan = build_selective_release_plan(
|
||||||
|
mixed,
|
||||||
|
selected_repos=("govoplan-core", "govoplan-files"),
|
||||||
|
repo_versions={
|
||||||
|
"govoplan-core": "1.2.3",
|
||||||
|
"govoplan-files": "1.2.3",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["govoplan-files", "govoplan-core"],
|
||||||
|
[unit.repo for unit in plan.units],
|
||||||
|
)
|
||||||
|
step_ids = [step.id for step in plan.dry_run_steps]
|
||||||
|
self.assertLess(
|
||||||
|
step_ids.index("govoplan-files:tag"),
|
||||||
|
step_ids.index("govoplan-core:tag"),
|
||||||
|
)
|
||||||
|
self.assertLess(
|
||||||
|
step_ids.index("govoplan-core:tag"),
|
||||||
|
step_ids.index("release:alignment"),
|
||||||
|
)
|
||||||
|
self.assertLess(
|
||||||
|
step_ids.index("release:alignment"),
|
||||||
|
step_ids.index("govoplan-files:push"),
|
||||||
|
)
|
||||||
|
self.assertEqual("release:install-verify", step_ids[-1])
|
||||||
|
core = next(unit for unit in plan.units if unit.repo == "govoplan-core")
|
||||||
|
self.assertIn("core-release-bundle", core.capabilities)
|
||||||
|
|
||||||
def test_malformed_version_metadata_is_a_structured_blocker(self) -> None:
|
def test_malformed_version_metadata_is_a_structured_blocker(self) -> None:
|
||||||
cases = (
|
cases = (
|
||||||
("package.json", "{not-json\n", "JSONDecodeError"),
|
("package.json", "{not-json\n", "JSONDecodeError"),
|
||||||
@@ -167,7 +257,8 @@ class ReleasePlanGuidanceTests(unittest.TestCase):
|
|||||||
self.assertIn("data-run-step-id", webui)
|
self.assertIn("data-run-step-id", webui)
|
||||||
self.assertIn("invalidateReleaseDraft", webui)
|
self.assertIn("invalidateReleaseDraft", webui)
|
||||||
self.assertIn("Installation Verification", webui)
|
self.assertIn("Installation Verification", webui)
|
||||||
self.assertIn("external release-integration CI gate", webui)
|
self.assertIn("receipt-bound local gate", webui)
|
||||||
|
self.assertIn('step.id === "release:install-verify"', webui)
|
||||||
self.assertIn('const inspectionNotices = (summary.missing_count || 0)', webui)
|
self.assertIn('const inspectionNotices = (summary.missing_count || 0)', webui)
|
||||||
self.assertIn('(summary.repository_count || 0) === 0', webui)
|
self.assertIn('(summary.repository_count || 0) === 0', webui)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
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.version_metadata import ( # noqa: E402
|
||||||
|
apply_version_metadata_mutations,
|
||||||
|
version_metadata_mutations,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseVersionMetadataTests(unittest.TestCase):
|
||||||
|
def test_updates_recognized_metadata_without_changing_interface_versions(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
package = root / "src" / "govoplan_example"
|
||||||
|
backend = package / "backend"
|
||||||
|
webui = root / "webui"
|
||||||
|
backend.mkdir(parents=True)
|
||||||
|
webui.mkdir()
|
||||||
|
(root / "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "govoplan-example"\nversion = "1.2.3"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(root / "package.json").write_text(
|
||||||
|
'{"name":"root","version":"1.2.3"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(webui / "package.json").write_text(
|
||||||
|
'{"name":"@govoplan/example-webui","version":"1.2.3"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(webui / "package-lock.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "@govoplan/example-webui",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@govoplan/example-webui",
|
||||||
|
"version": "1.2.3",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(backend / "manifest.py").write_text(
|
||||||
|
"def get_manifest():\n"
|
||||||
|
" return ModuleManifest(\n"
|
||||||
|
' id="example",\n'
|
||||||
|
' version="1.2.3",\n'
|
||||||
|
" provides_interfaces=(\n"
|
||||||
|
' ModuleInterfaceProvider(name="example.api", version="4.0"),\n'
|
||||||
|
" ),\n"
|
||||||
|
" )\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(package / "__init__.py").write_text(
|
||||||
|
'__version__ = "1.2.3"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
preview = version_metadata_mutations(
|
||||||
|
root,
|
||||||
|
target_version="1.2.4",
|
||||||
|
)
|
||||||
|
changed = apply_version_metadata_mutations(
|
||||||
|
root,
|
||||||
|
target_version="1.2.4",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{mutation.path for mutation in preview},
|
||||||
|
set(changed),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"1.2.4",
|
||||||
|
json.loads((webui / "package-lock.json").read_text())[
|
||||||
|
"packages"
|
||||||
|
][""]["version"],
|
||||||
|
)
|
||||||
|
manifest = (backend / "manifest.py").read_text(encoding="utf-8")
|
||||||
|
self.assertIn('version="1.2.4"', manifest)
|
||||||
|
self.assertIn('version="4.0"', manifest)
|
||||||
|
self.assertEqual(
|
||||||
|
'__version__ = "1.2.4"\n',
|
||||||
|
(package / "__init__.py").read_text(encoding="utf-8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -222,6 +222,7 @@ class ReleasePlanUnit:
|
|||||||
status: str
|
status: str
|
||||||
blockers: tuple[str, ...] = ()
|
blockers: tuple[str, ...] = ()
|
||||||
warnings: tuple[str, ...] = ()
|
warnings: tuple[str, ...] = ()
|
||||||
|
capabilities: tuple[str, ...] = ()
|
||||||
provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = ()
|
provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = ()
|
||||||
requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = ()
|
requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = ()
|
||||||
gate_findings: tuple[ReleaseGateFinding, ...] = ()
|
gate_findings: tuple[ReleaseGateFinding, ...] = ()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2218,7 +2218,7 @@ def _validated_result_receipt(
|
|||||||
"Catalog publication receipt identity is invalid."
|
"Catalog publication receipt identity is invalid."
|
||||||
)
|
)
|
||||||
return dict(value)
|
return dict(value)
|
||||||
if set(value) != {
|
repository_fields = {
|
||||||
"kind",
|
"kind",
|
||||||
"repo",
|
"repo",
|
||||||
"head",
|
"head",
|
||||||
@@ -2228,7 +2228,8 @@ def _validated_result_receipt(
|
|||||||
"worktree_clean",
|
"worktree_clean",
|
||||||
"target_tag",
|
"target_tag",
|
||||||
"tag_object",
|
"tag_object",
|
||||||
}:
|
}
|
||||||
|
if set(value) not in (repository_fields, repository_fields | {"worktree_sha256"}):
|
||||||
raise ReleaseRunConflict("Repository result receipt is invalid.")
|
raise ReleaseRunConflict("Repository result receipt is invalid.")
|
||||||
if (
|
if (
|
||||||
not isinstance(value.get("repo"), str)
|
not isinstance(value.get("repo"), str)
|
||||||
@@ -2241,6 +2242,10 @@ def _validated_result_receipt(
|
|||||||
or value.get("remote") != "origin"
|
or value.get("remote") != "origin"
|
||||||
or not _is_sha256(value.get("remote_sha256"))
|
or not _is_sha256(value.get("remote_sha256"))
|
||||||
or type(value.get("worktree_clean")) is not bool
|
or type(value.get("worktree_clean")) is not bool
|
||||||
|
or (
|
||||||
|
"worktree_sha256" in value
|
||||||
|
and not _is_sha256(value.get("worktree_sha256"))
|
||||||
|
)
|
||||||
or not isinstance(value.get("target_tag"), str)
|
or not isinstance(value.get("target_tag"), str)
|
||||||
or len(value["target_tag"]) > 160
|
or len(value["target_tag"]) > 160
|
||||||
or (
|
or (
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ from .version_alignment import (
|
|||||||
selected_release_webui_bundle_issues,
|
selected_release_webui_bundle_issues,
|
||||||
selected_repository_version_issues,
|
selected_repository_version_issues,
|
||||||
)
|
)
|
||||||
|
from .version_metadata import (
|
||||||
|
VersionMetadataError,
|
||||||
|
version_metadata_mutations,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_selective_release_plan(
|
def build_selective_release_plan(
|
||||||
@@ -46,6 +50,7 @@ def build_selective_release_plan(
|
|||||||
)
|
)
|
||||||
for repo in repositories
|
for repo in repositories
|
||||||
)
|
)
|
||||||
|
units = dependency_ordered_units(units)
|
||||||
units = apply_repository_version_gate(
|
units = apply_repository_version_gate(
|
||||||
units, workspace=Path(dashboard.workspace_root)
|
units, workspace=Path(dashboard.workspace_root)
|
||||||
)
|
)
|
||||||
@@ -103,7 +108,35 @@ def apply_repository_version_gate(
|
|||||||
workspace: Path,
|
workspace: Path,
|
||||||
) -> tuple[ReleasePlanUnit, ...]:
|
) -> tuple[ReleasePlanUnit, ...]:
|
||||||
issues_by_repo: dict[str, list[ReleaseGateFinding]] = {}
|
issues_by_repo: dict[str, list[ReleaseGateFinding]] = {}
|
||||||
|
version_update_supported_by_repo: dict[str, bool] = {}
|
||||||
|
deferred_core_lock_repos: set[str] = set()
|
||||||
for unit in units:
|
for unit in units:
|
||||||
|
version_update_supported = unit.current_version == unit.target_version
|
||||||
|
if unit.current_version and unit.current_version != unit.target_version:
|
||||||
|
try:
|
||||||
|
version_update_supported = bool(
|
||||||
|
version_metadata_mutations(
|
||||||
|
workspace / unit.repo,
|
||||||
|
target_version=unit.target_version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except VersionMetadataError as exc:
|
||||||
|
issues_by_repo.setdefault(unit.repo, []).append(
|
||||||
|
ReleaseGateFinding(
|
||||||
|
code="repository_version_mutation_unsupported",
|
||||||
|
severity="blocker",
|
||||||
|
message=str(exc),
|
||||||
|
remediation=(
|
||||||
|
"Align the unsupported version declarations manually, "
|
||||||
|
"commit the reviewed result, then rebuild this plan."
|
||||||
|
),
|
||||||
|
repo=unit.repo,
|
||||||
|
source="repository version metadata",
|
||||||
|
expected=unit.target_version,
|
||||||
|
actual=unit.current_version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
version_update_supported_by_repo[unit.repo] = version_update_supported
|
||||||
try:
|
try:
|
||||||
issues = selected_repository_version_issues(
|
issues = selected_repository_version_issues(
|
||||||
repo_versions={unit.repo: unit.target_version},
|
repo_versions={unit.repo: unit.target_version},
|
||||||
@@ -130,6 +163,18 @@ def apply_repository_version_gate(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
for issue in issues:
|
for issue in issues:
|
||||||
|
if (
|
||||||
|
version_update_supported
|
||||||
|
and issue.message
|
||||||
|
== "source version must match the requested release version"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
unit.repo == "govoplan-core"
|
||||||
|
and issue.source.startswith("package-lock.release.json")
|
||||||
|
):
|
||||||
|
deferred_core_lock_repos.add(unit.repo)
|
||||||
|
continue
|
||||||
issues_by_repo.setdefault(issue.repo, []).append(
|
issues_by_repo.setdefault(issue.repo, []).append(
|
||||||
ReleaseGateFinding(
|
ReleaseGateFinding(
|
||||||
code="repository_version_alignment",
|
code="repository_version_alignment",
|
||||||
@@ -161,17 +206,68 @@ def apply_repository_version_gate(
|
|||||||
source_preflight_ready=False,
|
source_preflight_ready=False,
|
||||||
)
|
)
|
||||||
if unit.repo in issues_by_repo
|
if unit.repo in issues_by_repo
|
||||||
else unit
|
else replace(
|
||||||
|
unit,
|
||||||
|
warnings=planned_version_warnings(
|
||||||
|
unit,
|
||||||
|
defer_core_lock=unit.repo in deferred_core_lock_repos,
|
||||||
|
),
|
||||||
|
status=(
|
||||||
|
"attention"
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
unit.current_version
|
||||||
|
and unit.current_version != unit.target_version
|
||||||
|
)
|
||||||
|
or unit.repo in deferred_core_lock_repos
|
||||||
|
)
|
||||||
|
and unit.status == "ready"
|
||||||
|
else unit.status
|
||||||
|
),
|
||||||
|
source_preflight_ready=(
|
||||||
|
unit.source_preflight_ready
|
||||||
|
or (
|
||||||
|
bool(unit.current_version)
|
||||||
|
and unit.current_version != unit.target_version
|
||||||
|
and version_update_supported_by_repo.get(unit.repo, False)
|
||||||
|
and not any(
|
||||||
|
finding.code == "worktree_dirty"
|
||||||
|
for finding in unit.gate_findings
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
for unit in units
|
for unit in units
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def planned_version_warnings(
|
||||||
|
unit: ReleasePlanUnit,
|
||||||
|
*,
|
||||||
|
defer_core_lock: bool,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
warnings = list(unit.warnings)
|
||||||
|
if unit.current_version and unit.current_version != unit.target_version:
|
||||||
|
warnings.append(
|
||||||
|
"version metadata will be updated by the bounded release executor"
|
||||||
|
)
|
||||||
|
if defer_core_lock:
|
||||||
|
warnings.append(
|
||||||
|
"Core release lock metadata will be regenerated by the bounded "
|
||||||
|
"bundle executor"
|
||||||
|
)
|
||||||
|
return tuple(warnings)
|
||||||
|
|
||||||
|
|
||||||
def apply_release_webui_bundle_gate(
|
def apply_release_webui_bundle_gate(
|
||||||
units: tuple[ReleasePlanUnit, ...],
|
units: tuple[ReleasePlanUnit, ...],
|
||||||
*,
|
*,
|
||||||
workspace: Path,
|
workspace: Path,
|
||||||
) -> tuple[ReleasePlanUnit, ...]:
|
) -> tuple[ReleasePlanUnit, ...]:
|
||||||
issues_by_repo: dict[str, list[ReleaseGateFinding]] = {}
|
issues_by_repo: dict[str, list[ReleaseGateFinding]] = {}
|
||||||
|
selected = {unit.repo for unit in units}
|
||||||
|
core_selected = "govoplan-core" in selected
|
||||||
|
planned_bundle_repos: set[str] = set()
|
||||||
try:
|
try:
|
||||||
issues = selected_release_webui_bundle_issues(
|
issues = selected_release_webui_bundle_issues(
|
||||||
repo_versions={unit.repo: unit.target_version for unit in units},
|
repo_versions={unit.repo: unit.target_version for unit in units},
|
||||||
@@ -200,6 +296,9 @@ def apply_release_webui_bundle_gate(
|
|||||||
)
|
)
|
||||||
issues = ()
|
issues = ()
|
||||||
for issue in issues:
|
for issue in issues:
|
||||||
|
if core_selected and issue.repo in selected and issue.repo != "govoplan-core":
|
||||||
|
planned_bundle_repos.add(issue.repo)
|
||||||
|
continue
|
||||||
issues_by_repo.setdefault(issue.repo, []).append(
|
issues_by_repo.setdefault(issue.repo, []).append(
|
||||||
ReleaseGateFinding(
|
ReleaseGateFinding(
|
||||||
code="release_webui_composition_alignment",
|
code="release_webui_composition_alignment",
|
||||||
@@ -230,7 +329,25 @@ def apply_release_webui_bundle_gate(
|
|||||||
source_preflight_ready=False,
|
source_preflight_ready=False,
|
||||||
)
|
)
|
||||||
if unit.repo in issues_by_repo
|
if unit.repo in issues_by_repo
|
||||||
else unit
|
else replace(
|
||||||
|
unit,
|
||||||
|
warnings=unit.warnings
|
||||||
|
+ (
|
||||||
|
(
|
||||||
|
"Core release WebUI references will be regenerated after "
|
||||||
|
"the selected module tags are created"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if unit.repo == "govoplan-core" and planned_bundle_repos
|
||||||
|
else unit.warnings,
|
||||||
|
status=(
|
||||||
|
"attention"
|
||||||
|
if unit.repo == "govoplan-core"
|
||||||
|
and planned_bundle_repos
|
||||||
|
and unit.status == "ready"
|
||||||
|
else unit.status
|
||||||
|
),
|
||||||
|
)
|
||||||
for unit in units
|
for unit in units
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -408,6 +525,7 @@ def build_unit(
|
|||||||
status="blocked" if blockers else "attention" if warnings else "ready",
|
status="blocked" if blockers else "attention" if warnings else "ready",
|
||||||
blockers=tuple(blockers),
|
blockers=tuple(blockers),
|
||||||
warnings=tuple(warnings),
|
warnings=tuple(warnings),
|
||||||
|
capabilities=repository_capabilities(repo, contracts=contracts),
|
||||||
provides_interfaces=provides,
|
provides_interfaces=provides,
|
||||||
requires_interfaces=requires,
|
requires_interfaces=requires,
|
||||||
gate_findings=tuple(findings),
|
gate_findings=tuple(findings),
|
||||||
@@ -416,11 +534,83 @@ def build_unit(
|
|||||||
and not repo.dirty
|
and not repo.dirty
|
||||||
and repo.has_head
|
and repo.has_head
|
||||||
and bool(repo.branch)
|
and bool(repo.branch)
|
||||||
and current_version == resolved_target
|
and current_version is not None
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def repository_capabilities(
|
||||||
|
repo: RepositorySnapshot,
|
||||||
|
*,
|
||||||
|
contracts: ModuleContractSnapshot | None,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
"""Describe the release operations applicable to one repository shape."""
|
||||||
|
|
||||||
|
root = Path(repo.absolute_path)
|
||||||
|
capabilities = {"git-source"}
|
||||||
|
if (root / "pyproject.toml").is_file():
|
||||||
|
capabilities.add("python-package")
|
||||||
|
if (root / "package.json").is_file() or (root / "webui/package.json").is_file():
|
||||||
|
capabilities.add("webui-package")
|
||||||
|
if contracts is not None:
|
||||||
|
capabilities.add("module-manifest")
|
||||||
|
if (
|
||||||
|
(root / "alembic/versions").is_dir()
|
||||||
|
or (root / "src").is_dir()
|
||||||
|
and any((root / "src").glob("**/migrations/versions"))
|
||||||
|
):
|
||||||
|
capabilities.add("database-migrations")
|
||||||
|
if (root / "docs").is_dir() or repo.spec.subtype in {"docs", "documentation"}:
|
||||||
|
capabilities.add("documentation")
|
||||||
|
if repo.spec.name == "govoplan-core":
|
||||||
|
capabilities.add("core-release-bundle")
|
||||||
|
if repo.spec.name == "govoplan":
|
||||||
|
capabilities.add("release-control-plane")
|
||||||
|
return tuple(sorted(capabilities))
|
||||||
|
|
||||||
|
|
||||||
|
def dependency_ordered_units(
|
||||||
|
units: tuple[ReleasePlanUnit, ...],
|
||||||
|
) -> tuple[ReleasePlanUnit, ...]:
|
||||||
|
"""Order module providers before consumers while keeping Core last."""
|
||||||
|
|
||||||
|
by_repo = {unit.repo: unit for unit in units}
|
||||||
|
providers: dict[str, set[str]] = {}
|
||||||
|
for unit in units:
|
||||||
|
if unit.repo == "govoplan-core":
|
||||||
|
continue
|
||||||
|
for provided in unit.provides_interfaces:
|
||||||
|
providers.setdefault(provided.name, set()).add(unit.repo)
|
||||||
|
|
||||||
|
dependencies: dict[str, set[str]] = {unit.repo: set() for unit in units}
|
||||||
|
for unit in units:
|
||||||
|
for requirement in unit.requires_interfaces:
|
||||||
|
dependencies[unit.repo].update(
|
||||||
|
provider
|
||||||
|
for provider in providers.get(requirement.name, set())
|
||||||
|
if provider != unit.repo
|
||||||
|
)
|
||||||
|
if "govoplan-core" in dependencies:
|
||||||
|
dependencies["govoplan-core"].update(
|
||||||
|
repo for repo in by_repo if repo != "govoplan-core"
|
||||||
|
)
|
||||||
|
|
||||||
|
ordered: list[ReleasePlanUnit] = []
|
||||||
|
remaining = set(by_repo)
|
||||||
|
while remaining:
|
||||||
|
ready = sorted(
|
||||||
|
repo
|
||||||
|
for repo in remaining
|
||||||
|
if not (dependencies[repo] & remaining)
|
||||||
|
)
|
||||||
|
if not ready:
|
||||||
|
ready = [sorted(remaining)[0]]
|
||||||
|
for repo in ready:
|
||||||
|
ordered.append(by_repo[repo])
|
||||||
|
remaining.remove(repo)
|
||||||
|
return tuple(ordered)
|
||||||
|
|
||||||
|
|
||||||
def gate_finding(
|
def gate_finding(
|
||||||
*,
|
*,
|
||||||
repo: str,
|
repo: str,
|
||||||
@@ -501,24 +691,38 @@ def dry_run_steps(
|
|||||||
) -> tuple[ReleasePlanStep, ...]:
|
) -> tuple[ReleasePlanStep, ...]:
|
||||||
steps: list[ReleasePlanStep] = []
|
steps: list[ReleasePlanStep] = []
|
||||||
snapshots = {repo.spec.name: repo for repo in dashboard.repositories}
|
snapshots = {repo.spec.name: repo for repo in dashboard.repositories}
|
||||||
selected_names = {unit.repo for unit in units}
|
core_unit = next((unit for unit in units if unit.repo == "govoplan-core"), None)
|
||||||
if "govoplan-core" in selected_names and len(selected_names) > 1:
|
non_core_units = tuple(unit for unit in units if unit.repo != "govoplan-core")
|
||||||
steps.append(
|
bundle_repos: tuple[str, ...] = ()
|
||||||
ReleasePlanStep(
|
core_lock_regeneration = False
|
||||||
id="release:cross-repository-ordering",
|
if core_unit is not None:
|
||||||
title="Resolve module/Core release ordering",
|
try:
|
||||||
detail=(
|
bundle_repos = tuple(
|
||||||
"A combined Core/module release needs a dependency-aware DAG: "
|
sorted(
|
||||||
"local module tags, Core release-lock regeneration and commit, "
|
{
|
||||||
"Core tag, full alignment, then atomic pushes. The current linear "
|
issue.repo
|
||||||
"executor must not publish a module before that lock is committed."
|
for issue in selected_release_webui_bundle_issues(
|
||||||
),
|
repo_versions={
|
||||||
command=None,
|
unit.repo: unit.target_version for unit in units
|
||||||
cwd=dashboard.meta_root,
|
},
|
||||||
mutating=True,
|
workspace=Path(dashboard.workspace_root),
|
||||||
status="needs-executor",
|
)
|
||||||
|
if issue.repo != "govoplan-core"
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
core_lock_regeneration = bool(bundle_repos) or any(
|
||||||
|
issue.source.startswith("package-lock.release.json")
|
||||||
|
for issue in selected_repository_version_issues(
|
||||||
|
repo_versions={
|
||||||
|
"govoplan-core": core_unit.target_version,
|
||||||
|
},
|
||||||
|
workspace=Path(dashboard.workspace_root),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - a structured gate already reports it.
|
||||||
|
bundle_repos = ()
|
||||||
|
|
||||||
for unit in units:
|
for unit in units:
|
||||||
steps.append(
|
steps.append(
|
||||||
ReleasePlanStep(
|
ReleasePlanStep(
|
||||||
@@ -531,32 +735,74 @@ def dry_run_steps(
|
|||||||
repo=unit.repo,
|
repo=unit.repo,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for unit in units:
|
||||||
if unit.current_version != unit.target_version:
|
if unit.current_version != unit.target_version:
|
||||||
|
try:
|
||||||
|
paths = tuple(
|
||||||
|
mutation.path
|
||||||
|
for mutation in version_metadata_mutations(
|
||||||
|
Path(dashboard.workspace_root) / unit.repo,
|
||||||
|
target_version=unit.target_version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except VersionMetadataError:
|
||||||
|
paths = ()
|
||||||
|
snapshot = snapshots.get(unit.repo)
|
||||||
|
version_supported = bool(
|
||||||
|
paths and snapshot is not None and not snapshot.dirty
|
||||||
|
)
|
||||||
steps.append(
|
steps.append(
|
||||||
ReleasePlanStep(
|
ReleasePlanStep(
|
||||||
id=f"{unit.repo}:version",
|
id=f"{unit.repo}:version",
|
||||||
title=f"Update {unit.repo} version metadata",
|
title=f"Update {unit.repo} version metadata",
|
||||||
detail=f"Prepare version files and manifest metadata for {unit.target_version}.",
|
detail=(
|
||||||
command=None,
|
f"Update only the recognized version declarations for "
|
||||||
|
f"{unit.target_version}: {', '.join(paths) or 'unsupported metadata'}."
|
||||||
|
),
|
||||||
|
command=(
|
||||||
|
"bounded version metadata update "
|
||||||
|
+ " ".join(shlex.quote(path) for path in paths)
|
||||||
|
)
|
||||||
|
if paths
|
||||||
|
else None,
|
||||||
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
||||||
mutating=True,
|
mutating=True,
|
||||||
repo=unit.repo,
|
repo=unit.repo,
|
||||||
status="needs-executor",
|
status="planned" if version_supported else "needs-executor",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for unit in non_core_units:
|
||||||
snapshot = snapshots.get(unit.repo)
|
snapshot = snapshots.get(unit.repo)
|
||||||
if unit.current_version != unit.target_version or (
|
needs_commit = unit.current_version != unit.target_version or (
|
||||||
snapshot is not None and snapshot.dirty
|
snapshot is not None and snapshot.dirty
|
||||||
):
|
)
|
||||||
|
if needs_commit:
|
||||||
|
commit_supported = bool(
|
||||||
|
unit.current_version != unit.target_version
|
||||||
|
and snapshot is not None
|
||||||
|
and not snapshot.dirty
|
||||||
|
)
|
||||||
steps.append(
|
steps.append(
|
||||||
ReleasePlanStep(
|
ReleasePlanStep(
|
||||||
id=f"{unit.repo}:commit",
|
id=f"{unit.repo}:commit",
|
||||||
title=f"Commit {unit.repo} release changes",
|
title=f"Commit {unit.repo} release changes",
|
||||||
detail="Commit reviewed changes for this release unit only.",
|
detail=(
|
||||||
command=f"git add -A && git commit -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}",
|
"Commit only the receipt-bound files written by the version "
|
||||||
|
"executor."
|
||||||
|
if commit_supported
|
||||||
|
else "Pre-existing worktree changes require review outside the release executor."
|
||||||
|
),
|
||||||
|
command=(
|
||||||
|
f"git commit -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}"
|
||||||
|
if commit_supported
|
||||||
|
else None
|
||||||
|
),
|
||||||
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
||||||
mutating=True,
|
mutating=True,
|
||||||
repo=unit.repo,
|
repo=unit.repo,
|
||||||
|
status="planned" if commit_supported else "needs-executor",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
steps.append(
|
steps.append(
|
||||||
@@ -570,6 +816,97 @@ def dry_run_steps(
|
|||||||
repo=unit.repo,
|
repo=unit.repo,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if core_unit is not None:
|
||||||
|
if core_lock_regeneration:
|
||||||
|
steps.append(
|
||||||
|
ReleasePlanStep(
|
||||||
|
id="govoplan-core:bundle",
|
||||||
|
title="Regenerate the Core release WebUI bundle",
|
||||||
|
detail=(
|
||||||
|
"After local module tags exist, update and lock the exact "
|
||||||
|
"selected WebUI references"
|
||||||
|
+ (
|
||||||
|
" for: " + ", ".join(bundle_repos)
|
||||||
|
if bundle_repos
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
+ ", and reconcile package/lock metadata."
|
||||||
|
),
|
||||||
|
command=(
|
||||||
|
"tools/release/generate-release-lock.sh "
|
||||||
|
+ " ".join(
|
||||||
|
f"--local-git-repo {shlex.quote(str(Path(dashboard.workspace_root) / repo))}"
|
||||||
|
for repo in bundle_repos
|
||||||
|
)
|
||||||
|
),
|
||||||
|
cwd=dashboard.meta_root,
|
||||||
|
mutating=True,
|
||||||
|
repo="govoplan-core",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
core_snapshot = snapshots.get(core_unit.repo)
|
||||||
|
core_needs_commit = (
|
||||||
|
core_unit.current_version != core_unit.target_version
|
||||||
|
or core_lock_regeneration
|
||||||
|
or (core_snapshot is not None and core_snapshot.dirty)
|
||||||
|
)
|
||||||
|
if core_needs_commit:
|
||||||
|
core_commit_supported = bool(
|
||||||
|
core_snapshot is not None
|
||||||
|
and not core_snapshot.dirty
|
||||||
|
and (
|
||||||
|
core_unit.current_version != core_unit.target_version
|
||||||
|
or core_lock_regeneration
|
||||||
|
)
|
||||||
|
)
|
||||||
|
steps.append(
|
||||||
|
ReleasePlanStep(
|
||||||
|
id="govoplan-core:commit",
|
||||||
|
title="Commit govoplan-core release changes",
|
||||||
|
detail=(
|
||||||
|
"Commit only the receipt-bound Core version and release-bundle files."
|
||||||
|
if core_commit_supported
|
||||||
|
else "Pre-existing Core worktree changes require review outside the release executor."
|
||||||
|
),
|
||||||
|
command=(
|
||||||
|
f"git commit -m {shlex.quote(f'Release govoplan-core {core_unit.target_tag}')}"
|
||||||
|
if core_commit_supported
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
cwd=f"{dashboard.workspace_root}/govoplan-core",
|
||||||
|
mutating=True,
|
||||||
|
repo="govoplan-core",
|
||||||
|
status="planned" if core_commit_supported else "needs-executor",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
steps.append(
|
||||||
|
ReleasePlanStep(
|
||||||
|
id="govoplan-core:tag",
|
||||||
|
title=f"Create govoplan-core tag {core_unit.target_tag}",
|
||||||
|
detail="Create the Core tag only after selected module tags and the release lock are verified.",
|
||||||
|
command=f"git tag -a {shlex.quote(core_unit.target_tag)} -m {shlex.quote(f'Release govoplan-core {core_unit.target_tag}')}",
|
||||||
|
cwd=f"{dashboard.workspace_root}/govoplan-core",
|
||||||
|
mutating=True,
|
||||||
|
repo="govoplan-core",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if units:
|
||||||
|
steps.append(
|
||||||
|
ReleasePlanStep(
|
||||||
|
id="release:alignment",
|
||||||
|
title="Verify frozen source and release composition",
|
||||||
|
detail=(
|
||||||
|
"Re-run version, contract, tag, and Core WebUI composition gates "
|
||||||
|
"after local mutations and before any remote publication."
|
||||||
|
),
|
||||||
|
command="receipt-bound release alignment",
|
||||||
|
cwd=dashboard.meta_root,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for unit in units:
|
||||||
steps.append(
|
steps.append(
|
||||||
ReleasePlanStep(
|
ReleasePlanStep(
|
||||||
id=f"{unit.repo}:push",
|
id=f"{unit.repo}:push",
|
||||||
@@ -640,6 +977,20 @@ def dry_run_steps(
|
|||||||
status="planned",
|
status="planned",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
steps.append(
|
||||||
|
ReleasePlanStep(
|
||||||
|
id="release:install-verify",
|
||||||
|
title="Verify candidate package installation",
|
||||||
|
detail=(
|
||||||
|
"Install every selected candidate wheel into a private "
|
||||||
|
"temporary target without network access or dependencies, "
|
||||||
|
"then verify installed package metadata against the frozen plan."
|
||||||
|
),
|
||||||
|
command="python -m pip install --no-index --no-deps <candidate wheels>",
|
||||||
|
cwd=dashboard.meta_root,
|
||||||
|
status="planned",
|
||||||
|
)
|
||||||
|
)
|
||||||
return tuple(steps)
|
return tuple(steps)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"""Deterministic, bounded updates for repository-owned version metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
|
||||||
|
MAX_VERSION_FILES = 64
|
||||||
|
MAX_VERSION_FILE_BYTES = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class VersionMetadataError(RuntimeError):
|
||||||
|
"""Raised when release version metadata cannot be changed safely."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VersionFileMutation:
|
||||||
|
path: str
|
||||||
|
before: bytes
|
||||||
|
after: bytes
|
||||||
|
|
||||||
|
|
||||||
|
def version_metadata_mutations(
|
||||||
|
repo_path: Path,
|
||||||
|
*,
|
||||||
|
target_version: str,
|
||||||
|
) -> tuple[VersionFileMutation, ...]:
|
||||||
|
"""Render all recognized repository version files without writing them."""
|
||||||
|
|
||||||
|
version = target_version.removeprefix("v")
|
||||||
|
candidates: list[tuple[Path, str]] = []
|
||||||
|
if (repo_path / "pyproject.toml").is_file():
|
||||||
|
candidates.append((repo_path / "pyproject.toml", "pyproject"))
|
||||||
|
for package_name, lock_name in (
|
||||||
|
("package.json", "package-lock.json"),
|
||||||
|
("webui/package.json", "webui/package-lock.json"),
|
||||||
|
("webui/package.release.json", "webui/package-lock.release.json"),
|
||||||
|
):
|
||||||
|
package = repo_path / package_name
|
||||||
|
if not package.is_file():
|
||||||
|
continue
|
||||||
|
candidates.append((package, "package"))
|
||||||
|
lock = repo_path / lock_name
|
||||||
|
if lock.is_file():
|
||||||
|
candidates.append((lock, "lock"))
|
||||||
|
src = repo_path / "src"
|
||||||
|
if src.is_dir():
|
||||||
|
candidates.extend(
|
||||||
|
(path, "manifest")
|
||||||
|
for path in sorted(src.glob("**/backend/manifest.py"))
|
||||||
|
if path.is_file()
|
||||||
|
)
|
||||||
|
candidates.extend(
|
||||||
|
(path, "package_init")
|
||||||
|
for path in sorted(src.glob("*/__init__.py"))
|
||||||
|
if path.is_file()
|
||||||
|
)
|
||||||
|
if len(candidates) > MAX_VERSION_FILES:
|
||||||
|
raise VersionMetadataError("Repository version metadata exceeds its file bound.")
|
||||||
|
|
||||||
|
mutations: list[VersionFileMutation] = []
|
||||||
|
declarations = 0
|
||||||
|
for path, kind in candidates:
|
||||||
|
before = _read_bounded(path)
|
||||||
|
if kind == "pyproject":
|
||||||
|
after, found = _render_pyproject(before, version=version)
|
||||||
|
elif kind == "package":
|
||||||
|
after, found = _render_package_json(before, version=version)
|
||||||
|
elif kind == "lock":
|
||||||
|
after, found = _render_package_lock(before, version=version)
|
||||||
|
elif kind == "manifest":
|
||||||
|
after, found = _render_python_version(
|
||||||
|
before,
|
||||||
|
path=path,
|
||||||
|
target_name="ModuleManifest",
|
||||||
|
keyword_name="version",
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
after, found = _render_python_assignment(
|
||||||
|
before,
|
||||||
|
path=path,
|
||||||
|
assignment_name="__version__",
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
if not found:
|
||||||
|
continue
|
||||||
|
declarations += 1
|
||||||
|
if before != after:
|
||||||
|
mutations.append(
|
||||||
|
VersionFileMutation(
|
||||||
|
path=path.relative_to(repo_path).as_posix(),
|
||||||
|
before=before,
|
||||||
|
after=after,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if declarations == 0:
|
||||||
|
raise VersionMetadataError(
|
||||||
|
"Repository has no supported version declaration to update."
|
||||||
|
)
|
||||||
|
return tuple(mutations)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_version_metadata_mutations(
|
||||||
|
repo_path: Path,
|
||||||
|
*,
|
||||||
|
target_version: str,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
"""Apply one deterministic version update, rolling back on write failure."""
|
||||||
|
|
||||||
|
mutations = version_metadata_mutations(
|
||||||
|
repo_path,
|
||||||
|
target_version=target_version,
|
||||||
|
)
|
||||||
|
written: list[VersionFileMutation] = []
|
||||||
|
try:
|
||||||
|
for mutation in mutations:
|
||||||
|
destination = repo_path / mutation.path
|
||||||
|
if _read_bounded(destination) != mutation.before:
|
||||||
|
raise VersionMetadataError(
|
||||||
|
f"Version metadata changed before update: {mutation.path}"
|
||||||
|
)
|
||||||
|
_atomic_write(destination, mutation.after)
|
||||||
|
written.append(mutation)
|
||||||
|
except Exception:
|
||||||
|
for mutation in reversed(written):
|
||||||
|
_atomic_write(repo_path / mutation.path, mutation.before)
|
||||||
|
raise
|
||||||
|
return tuple(mutation.path for mutation in mutations)
|
||||||
|
|
||||||
|
|
||||||
|
def version_metadata_paths(
|
||||||
|
repo_path: Path,
|
||||||
|
*,
|
||||||
|
target_version: str,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
"""Return the exact paths a deterministic target-version update owns."""
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
mutation.path
|
||||||
|
for mutation in version_metadata_mutations(
|
||||||
|
repo_path,
|
||||||
|
target_version=target_version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_bounded(path: Path) -> bytes:
|
||||||
|
try:
|
||||||
|
metadata = path.lstat()
|
||||||
|
except OSError as exc:
|
||||||
|
raise VersionMetadataError(
|
||||||
|
f"Version metadata is unavailable: {path.name}"
|
||||||
|
) from exc
|
||||||
|
if not path.is_file() or path.is_symlink():
|
||||||
|
raise VersionMetadataError(
|
||||||
|
f"Version metadata must be a regular file: {path.name}"
|
||||||
|
)
|
||||||
|
if metadata.st_size > MAX_VERSION_FILE_BYTES:
|
||||||
|
raise VersionMetadataError(
|
||||||
|
f"Version metadata exceeds its size bound: {path.name}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return path.read_bytes()
|
||||||
|
except OSError as exc:
|
||||||
|
raise VersionMetadataError(
|
||||||
|
f"Version metadata could not be read: {path.name}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(payload: bytes, *, path: Path | None = None) -> str:
|
||||||
|
try:
|
||||||
|
return payload.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
label = path.name if path is not None else "file"
|
||||||
|
raise VersionMetadataError(
|
||||||
|
f"Version metadata is not UTF-8: {label}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _render_pyproject(payload: bytes, *, version: str) -> tuple[bytes, bool]:
|
||||||
|
text = _decode(payload)
|
||||||
|
try:
|
||||||
|
parsed = tomllib.loads(text)
|
||||||
|
except tomllib.TOMLDecodeError as exc:
|
||||||
|
raise VersionMetadataError("pyproject.toml is malformed.") from exc
|
||||||
|
project = parsed.get("project")
|
||||||
|
if not isinstance(project, dict) or not isinstance(project.get("version"), str):
|
||||||
|
return payload, False
|
||||||
|
section = re.search(
|
||||||
|
r"(?ms)^\[project\][^\n]*\n(?P<body>.*?)(?=^\[[^\n]+\]|\Z)",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
if section is None:
|
||||||
|
raise VersionMetadataError("pyproject.toml has no editable [project] block.")
|
||||||
|
body = section.group("body")
|
||||||
|
match = re.search(
|
||||||
|
r'(?m)^(?P<prefix>\s*version\s*=\s*)(?P<quote>["\'])(?P<value>[^"\']+)(?P=quote)(?P<suffix>\s*(?:#.*)?)$',
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
if match is None:
|
||||||
|
raise VersionMetadataError(
|
||||||
|
"pyproject.toml project.version is not a supported literal."
|
||||||
|
)
|
||||||
|
start = section.start("body") + match.start("value")
|
||||||
|
end = section.start("body") + match.end("value")
|
||||||
|
rendered = f"{text[:start]}{version}{text[end:]}"
|
||||||
|
tomllib.loads(rendered)
|
||||||
|
return rendered.encode("utf-8"), True
|
||||||
|
|
||||||
|
|
||||||
|
def _render_package_json(payload: bytes, *, version: str) -> tuple[bytes, bool]:
|
||||||
|
data = _json_object(payload, label="package metadata")
|
||||||
|
if not isinstance(data.get("version"), str):
|
||||||
|
return payload, False
|
||||||
|
data["version"] = version
|
||||||
|
return _json_bytes(data), True
|
||||||
|
|
||||||
|
|
||||||
|
def _render_package_lock(payload: bytes, *, version: str) -> tuple[bytes, bool]:
|
||||||
|
data = _json_object(payload, label="package lock")
|
||||||
|
found = False
|
||||||
|
if isinstance(data.get("version"), str):
|
||||||
|
data["version"] = version
|
||||||
|
found = True
|
||||||
|
packages = data.get("packages")
|
||||||
|
root = packages.get("") if isinstance(packages, dict) else None
|
||||||
|
if isinstance(root, dict) and isinstance(root.get("version"), str):
|
||||||
|
root["version"] = version
|
||||||
|
found = True
|
||||||
|
return (_json_bytes(data), True) if found else (payload, False)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_object(payload: bytes, *, label: str) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
value = json.loads(_decode(payload))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise VersionMetadataError(f"{label.capitalize()} is malformed.") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise VersionMetadataError(f"{label.capitalize()} must be an object.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _json_bytes(value: dict[str, object]) -> bytes:
|
||||||
|
return (
|
||||||
|
json.dumps(value, indent=2, ensure_ascii=True, separators=(",", ": "))
|
||||||
|
+ "\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_python_version(
|
||||||
|
payload: bytes,
|
||||||
|
*,
|
||||||
|
path: Path,
|
||||||
|
target_name: str,
|
||||||
|
keyword_name: str,
|
||||||
|
version: str,
|
||||||
|
) -> tuple[bytes, bool]:
|
||||||
|
text = _decode(payload, path=path)
|
||||||
|
try:
|
||||||
|
tree = ast.parse(text, filename=str(path))
|
||||||
|
except SyntaxError as exc:
|
||||||
|
raise VersionMetadataError(f"Python metadata is malformed: {path.name}") from exc
|
||||||
|
values: list[ast.Constant] = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call) or _call_name(node.func) != target_name:
|
||||||
|
continue
|
||||||
|
for keyword in node.keywords:
|
||||||
|
if (
|
||||||
|
keyword.arg == keyword_name
|
||||||
|
and isinstance(keyword.value, ast.Constant)
|
||||||
|
and isinstance(keyword.value.value, str)
|
||||||
|
):
|
||||||
|
values.append(keyword.value)
|
||||||
|
if not values:
|
||||||
|
return payload, False
|
||||||
|
if len(values) != 1:
|
||||||
|
raise VersionMetadataError(
|
||||||
|
f"Python metadata has multiple {target_name}.{keyword_name} values: {path.name}"
|
||||||
|
)
|
||||||
|
return _replace_python_literal(text, values[0], version=version, path=path), True
|
||||||
|
|
||||||
|
|
||||||
|
def _render_python_assignment(
|
||||||
|
payload: bytes,
|
||||||
|
*,
|
||||||
|
path: Path,
|
||||||
|
assignment_name: str,
|
||||||
|
version: str,
|
||||||
|
) -> tuple[bytes, bool]:
|
||||||
|
text = _decode(payload, path=path)
|
||||||
|
try:
|
||||||
|
tree = ast.parse(text, filename=str(path))
|
||||||
|
except SyntaxError as exc:
|
||||||
|
raise VersionMetadataError(f"Python metadata is malformed: {path.name}") from exc
|
||||||
|
values: list[ast.Constant] = []
|
||||||
|
for node in tree.body:
|
||||||
|
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||||||
|
continue
|
||||||
|
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
||||||
|
value = node.value
|
||||||
|
if (
|
||||||
|
any(
|
||||||
|
isinstance(target, ast.Name) and target.id == assignment_name
|
||||||
|
for target in targets
|
||||||
|
)
|
||||||
|
and isinstance(value, ast.Constant)
|
||||||
|
and isinstance(value.value, str)
|
||||||
|
):
|
||||||
|
values.append(value)
|
||||||
|
if not values:
|
||||||
|
return payload, False
|
||||||
|
if len(values) != 1:
|
||||||
|
raise VersionMetadataError(
|
||||||
|
f"Python metadata has multiple {assignment_name} assignments: {path.name}"
|
||||||
|
)
|
||||||
|
return _replace_python_literal(text, values[0], version=version, path=path), True
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_python_literal(
|
||||||
|
text: str,
|
||||||
|
node: ast.Constant,
|
||||||
|
*,
|
||||||
|
version: str,
|
||||||
|
path: Path,
|
||||||
|
) -> bytes:
|
||||||
|
if (
|
||||||
|
node.lineno != node.end_lineno
|
||||||
|
or node.end_col_offset is None
|
||||||
|
or node.col_offset < 0
|
||||||
|
):
|
||||||
|
raise VersionMetadataError(
|
||||||
|
f"Python version declaration must be a single-line literal: {path.name}"
|
||||||
|
)
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
line = lines[node.lineno - 1]
|
||||||
|
lines[node.lineno - 1] = (
|
||||||
|
line[: node.col_offset]
|
||||||
|
+ json.dumps(version)
|
||||||
|
+ line[node.end_col_offset :]
|
||||||
|
)
|
||||||
|
rendered = "".join(lines)
|
||||||
|
ast.parse(rendered, filename=str(path))
|
||||||
|
return rendered.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _call_name(node: ast.expr) -> str | None:
|
||||||
|
if isinstance(node, ast.Name):
|
||||||
|
return node.id
|
||||||
|
if isinstance(node, ast.Attribute):
|
||||||
|
return node.attr
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write(path: Path, payload: bytes) -> None:
|
||||||
|
metadata = path.stat()
|
||||||
|
descriptor, temporary_name = tempfile.mkstemp(
|
||||||
|
prefix=f".{path.name}.",
|
||||||
|
dir=path.parent,
|
||||||
|
)
|
||||||
|
temporary = Path(temporary_name)
|
||||||
|
try:
|
||||||
|
os.fchmod(descriptor, metadata.st_mode & 0o777)
|
||||||
|
with os.fdopen(descriptor, "wb") as handle:
|
||||||
|
handle.write(payload)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
temporary.replace(path)
|
||||||
|
directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||||
|
try:
|
||||||
|
os.fsync(directory)
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.close(descriptor)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
@@ -170,18 +170,30 @@ def owner_for_versions_dir(versions_dir: Path) -> str:
|
|||||||
def parse_migration_file(owner: str, path: Path) -> Migration | None:
|
def parse_migration_file(owner: str, path: Path) -> Migration | None:
|
||||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
values: dict[str, Any] = {}
|
values: dict[str, Any] = {}
|
||||||
|
wrapped: Migration | None = None
|
||||||
|
release_peer = path.parent.parent / "versions" / path.name
|
||||||
|
if path.parent.name == "dev_versions" and release_peer.is_file():
|
||||||
|
wrapped = parse_migration_file(owner, release_peer)
|
||||||
for statement in tree.body:
|
for statement in tree.body:
|
||||||
if isinstance(statement, ast.Assign):
|
if isinstance(statement, ast.Assign):
|
||||||
for target in statement.targets:
|
for target in statement.targets:
|
||||||
if isinstance(target, ast.Name) and target.id in {"revision", "down_revision", "depends_on", "branch_labels"}:
|
if isinstance(target, ast.Name) and target.id in {"revision", "down_revision", "depends_on", "branch_labels"}:
|
||||||
values[target.id] = ast.literal_eval(statement.value)
|
values[target.id] = _migration_assignment_value(
|
||||||
|
target.id,
|
||||||
|
statement.value,
|
||||||
|
wrapped=wrapped,
|
||||||
|
)
|
||||||
elif (
|
elif (
|
||||||
isinstance(statement, ast.AnnAssign)
|
isinstance(statement, ast.AnnAssign)
|
||||||
and isinstance(statement.target, ast.Name)
|
and isinstance(statement.target, ast.Name)
|
||||||
and statement.target.id in {"revision", "down_revision", "depends_on", "branch_labels"}
|
and statement.target.id in {"revision", "down_revision", "depends_on", "branch_labels"}
|
||||||
and statement.value is not None
|
and statement.value is not None
|
||||||
):
|
):
|
||||||
values[statement.target.id] = ast.literal_eval(statement.value)
|
values[statement.target.id] = _migration_assignment_value(
|
||||||
|
statement.target.id,
|
||||||
|
statement.value,
|
||||||
|
wrapped=wrapped,
|
||||||
|
)
|
||||||
revision = values.get("revision")
|
revision = values.get("revision")
|
||||||
if not isinstance(revision, str):
|
if not isinstance(revision, str):
|
||||||
return None
|
return None
|
||||||
@@ -195,6 +207,31 @@ def parse_migration_file(owner: str, path: Path) -> Migration | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_assignment_value(
|
||||||
|
name: str,
|
||||||
|
value: ast.expr,
|
||||||
|
*,
|
||||||
|
wrapped: Migration | None,
|
||||||
|
) -> Any:
|
||||||
|
try:
|
||||||
|
return ast.literal_eval(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
if (
|
||||||
|
wrapped is None
|
||||||
|
or not isinstance(value, ast.Attribute)
|
||||||
|
or not isinstance(value.value, ast.Name)
|
||||||
|
or value.value.id != "_migration"
|
||||||
|
or value.attr != name
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"revision": wrapped.revision,
|
||||||
|
"down_revision": wrapped.down_revisions,
|
||||||
|
"depends_on": wrapped.depends_on,
|
||||||
|
"branch_labels": wrapped.branch_labels,
|
||||||
|
}[name]
|
||||||
|
|
||||||
|
|
||||||
def _normalize_revision_tuple(value: Any) -> tuple[str, ...]:
|
def _normalize_revision_tuple(value: Any) -> tuple[str, ...]:
|
||||||
if value is None:
|
if value is None:
|
||||||
return ()
|
return ()
|
||||||
|
|||||||
+51
-15
@@ -45,7 +45,9 @@ from govoplan_release.release_execution import (
|
|||||||
verify_catalog_publication_precondition,
|
verify_catalog_publication_precondition,
|
||||||
verify_repository_preflight_binding,
|
verify_repository_preflight_binding,
|
||||||
verify_repository_step_precondition,
|
verify_repository_step_precondition,
|
||||||
|
verify_release_alignment,
|
||||||
verify_release_runtime_binding,
|
verify_release_runtime_binding,
|
||||||
|
verify_candidate_installation,
|
||||||
)
|
)
|
||||||
from govoplan_release.release_run import (
|
from govoplan_release.release_run import (
|
||||||
ReleaseRunConflict,
|
ReleaseRunConflict,
|
||||||
@@ -532,7 +534,13 @@ def create_app(
|
|||||||
) == "interrupted":
|
) == "interrupted":
|
||||||
plan_step = release_run_plan_step(run, step_id)
|
plan_step = release_run_plan_step(run, step_id)
|
||||||
spec = executor_spec(plan_step)
|
spec = executor_spec(plan_step)
|
||||||
if spec is not None and spec.kind in {"tag", "push"}:
|
if spec is not None and spec.kind in {
|
||||||
|
"version",
|
||||||
|
"commit",
|
||||||
|
"core_bundle",
|
||||||
|
"tag",
|
||||||
|
"push",
|
||||||
|
}:
|
||||||
repo = str(plan_step["repo"])
|
repo = str(plan_step["repo"])
|
||||||
receipt = reconciled_repository_receipt(
|
receipt = reconciled_repository_receipt(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
@@ -542,6 +550,7 @@ def create_app(
|
|||||||
expected_receipt=preceding_repository_receipt(
|
expected_receipt=preceding_repository_receipt(
|
||||||
run, step_id=step_id, repo=repo
|
run, step_id=step_id, repo=repo
|
||||||
),
|
),
|
||||||
|
repo_versions=run["immutable"]["input"]["repo_versions"],
|
||||||
)
|
)
|
||||||
return release_run_view(
|
return release_run_view(
|
||||||
app.state.release_runs.reconcile_step(
|
app.state.release_runs.reconcile_step(
|
||||||
@@ -637,7 +646,15 @@ def create_app(
|
|||||||
plan_step=plan_step,
|
plan_step=plan_step,
|
||||||
workspace_root=app.state.workspace_root,
|
workspace_root=app.state.workspace_root,
|
||||||
)
|
)
|
||||||
if spec.kind in {"preflight", "tag", "push"}:
|
repository_kinds = {
|
||||||
|
"preflight",
|
||||||
|
"version",
|
||||||
|
"commit",
|
||||||
|
"core_bundle",
|
||||||
|
"tag",
|
||||||
|
"push",
|
||||||
|
}
|
||||||
|
if spec.kind in repository_kinds:
|
||||||
repo = str(plan_step["repo"])
|
repo = str(plan_step["repo"])
|
||||||
version = run["immutable"]["input"]["repo_versions"][repo]
|
version = run["immutable"]["input"]["repo_versions"][repo]
|
||||||
expected_repository_receipt = preceding_repository_receipt(
|
expected_repository_receipt = preceding_repository_receipt(
|
||||||
@@ -659,7 +676,21 @@ def create_app(
|
|||||||
)
|
)
|
||||||
|
|
||||||
receipt = None
|
receipt = None
|
||||||
if spec.kind in {"preflight", "tag", "push"}:
|
source_receipts = {
|
||||||
|
repo: source_receipt
|
||||||
|
for repo in run["immutable"]["input"]["repo_versions"]
|
||||||
|
if isinstance(
|
||||||
|
(
|
||||||
|
source_receipt := preceding_repository_receipt(
|
||||||
|
run,
|
||||||
|
step_id=step_id,
|
||||||
|
repo=repo,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
dict,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if spec.kind in repository_kinds:
|
||||||
result, receipt = execute_repository_step(
|
result, receipt = execute_repository_step(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
plan_step=plan_step,
|
plan_step=plan_step,
|
||||||
@@ -667,6 +698,13 @@ def create_app(
|
|||||||
workspace_root=app.state.workspace_root,
|
workspace_root=app.state.workspace_root,
|
||||||
remote=DURABLE_REMOTE,
|
remote=DURABLE_REMOTE,
|
||||||
expected_receipt=expected_repository_receipt,
|
expected_receipt=expected_repository_receipt,
|
||||||
|
source_receipts=source_receipts,
|
||||||
|
)
|
||||||
|
elif spec.kind == "alignment":
|
||||||
|
result = verify_release_alignment(
|
||||||
|
repo_versions=run["immutable"]["input"]["repo_versions"],
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
source_receipts=source_receipts,
|
||||||
)
|
)
|
||||||
elif spec.kind == "catalog_generate":
|
elif spec.kind == "catalog_generate":
|
||||||
attempt_fingerprint = (
|
attempt_fingerprint = (
|
||||||
@@ -690,18 +728,7 @@ def create_app(
|
|||||||
signing_keys=signing_keys,
|
signing_keys=signing_keys,
|
||||||
remote=DURABLE_REMOTE,
|
remote=DURABLE_REMOTE,
|
||||||
check_public=run["immutable"]["input"]["public_catalog"],
|
check_public=run["immutable"]["input"]["public_catalog"],
|
||||||
source_receipts={
|
source_receipts=source_receipts,
|
||||||
repo: receipt
|
|
||||||
for repo in run["immutable"]["input"]["repo_versions"]
|
|
||||||
if isinstance(
|
|
||||||
(
|
|
||||||
receipt := preceding_repository_receipt(
|
|
||||||
run, step_id=step_id, repo=repo
|
|
||||||
)
|
|
||||||
),
|
|
||||||
dict,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
base_catalog=base_catalog,
|
base_catalog=base_catalog,
|
||||||
base_keyring=base_keyring,
|
base_keyring=base_keyring,
|
||||||
)
|
)
|
||||||
@@ -738,6 +765,15 @@ def create_app(
|
|||||||
"Published candidate changed while its effect was in flight; "
|
"Published candidate changed while its effect was in flight; "
|
||||||
"reconcile the immutable website commit and remote tag."
|
"reconcile the immutable website commit and remote tag."
|
||||||
) from exc
|
) from exc
|
||||||
|
elif spec.kind == "install_verify":
|
||||||
|
candidate_path = verified_run_candidate(
|
||||||
|
run,
|
||||||
|
candidate_root=app.state.release_candidate_root,
|
||||||
|
)
|
||||||
|
result = verify_candidate_installation(
|
||||||
|
candidate_path=candidate_path,
|
||||||
|
repo_versions=run["immutable"]["input"]["repo_versions"],
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise ReleaseRunConflict("Release executor mapping is incomplete.")
|
raise ReleaseRunConflict("Release executor mapping is incomplete.")
|
||||||
|
|
||||||
|
|||||||
@@ -943,10 +943,10 @@
|
|||||||
<section id="installVerificationSection">
|
<section id="installVerificationSection">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>5. Installation Verification</h2>
|
<h2>5. Installation Verification</h2>
|
||||||
<small>external gate</small>
|
<small>receipt-bound local gate</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<p class="hint">The signed catalog and Python artifacts are produced by the durable candidate step. End-to-end installation verification still runs in release integration CI and is not yet a bounded local-console executor.</p>
|
<p class="hint">The durable verification step installs every selected candidate wheel into a private temporary target with network and dependency resolution disabled, then compares installed package metadata with the frozen release plan. Deployment-specific integration checks remain part of CI.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -1783,7 +1783,7 @@
|
|||||||
target: "releaseRunSection",
|
target: "releaseRunSection",
|
||||||
run,
|
run,
|
||||||
plan,
|
plan,
|
||||||
match: (step) => /:(preflight|tag|push)$/.test(step.id || ""),
|
match: (step) => /:(preflight|version|commit|bundle|tag|push)$/.test(step.id || "") || step.id === "release:alignment",
|
||||||
waitingStatus: "create run",
|
waitingStatus: "create run",
|
||||||
}),
|
}),
|
||||||
runWorkflowPhase({
|
runWorkflowPhase({
|
||||||
@@ -1804,13 +1804,15 @@
|
|||||||
match: (step) => step.id === "catalog:validate-sign-publish",
|
match: (step) => step.id === "catalog:validate-sign-publish",
|
||||||
waitingStatus: "waiting",
|
waitingStatus: "waiting",
|
||||||
}),
|
}),
|
||||||
{
|
runWorkflowPhase({
|
||||||
id: "verify",
|
id: "verify",
|
||||||
label: "Verify",
|
label: "Verify",
|
||||||
target: "installVerificationSection",
|
target: "installVerificationSection",
|
||||||
state: run?.state?.status === "completed" ? "unavailable" : "locked",
|
run,
|
||||||
status: run?.state?.status === "completed" ? "CI gate" : "waiting",
|
plan,
|
||||||
},
|
match: (step) => step.id === "release:install-verify",
|
||||||
|
waitingStatus: "waiting",
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
const firstOpen = phases.findIndex((phase) => ["current", "blocked", "unavailable"].includes(phase.state));
|
const firstOpen = phases.findIndex((phase) => ["current", "blocked", "unavailable"].includes(phase.state));
|
||||||
if (firstOpen >= 0) {
|
if (firstOpen >= 0) {
|
||||||
@@ -1924,10 +1926,10 @@
|
|||||||
};
|
};
|
||||||
} else if (run.state?.status === "completed") {
|
} else if (run.state?.status === "completed") {
|
||||||
guidance = {
|
guidance = {
|
||||||
title: "Run installation verification",
|
title: "Release run completed",
|
||||||
detail: "Every durable source, package, signing, and publication step has a recorded success receipt.",
|
detail: "Every durable source, package, signing, publication, and local installation-verification step has a recorded success.",
|
||||||
remediation: "End-to-end installation verification is still an external release-integration CI gate.",
|
remediation: "Review the run receipts and continue with deployment-specific integration checks where required.",
|
||||||
button: "Review verification gate",
|
button: "Review completed run",
|
||||||
action: { type: "scroll", target: "installVerificationSection" },
|
action: { type: "scroll", target: "installVerificationSection" },
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -2389,9 +2391,12 @@
|
|||||||
const blockers = unit.blockers.length ? `<p>${escapeHtml(unit.blockers.join("; "))}</p>` : "";
|
const blockers = unit.blockers.length ? `<p>${escapeHtml(unit.blockers.join("; "))}</p>` : "";
|
||||||
const warnings = unit.warnings.length ? `<p>${escapeHtml(unit.warnings.join("; "))}</p>` : "";
|
const warnings = unit.warnings.length ? `<p>${escapeHtml(unit.warnings.join("; "))}</p>` : "";
|
||||||
const kind = unit.status === "blocked" ? "block" : unit.status === "attention" ? "warn" : "ok";
|
const kind = unit.status === "blocked" ? "block" : unit.status === "attention" ? "warn" : "ok";
|
||||||
|
const capabilities = Array.isArray(unit.capabilities) && unit.capabilities.length
|
||||||
|
? `<p class="action-meta">Capabilities: ${escapeHtml(unit.capabilities.join(", "))}</p>`
|
||||||
|
: "";
|
||||||
return `<div class="action">
|
return `<div class="action">
|
||||||
<h3>${pill(unit.status, kind)} ${escapeHtml(unit.repo)} ${escapeHtml(unit.current_version || "-")} -> ${escapeHtml(unit.target_version)}</h3>
|
<h3>${pill(unit.status, kind)} ${escapeHtml(unit.repo)} ${escapeHtml(unit.current_version || "-")} -> ${escapeHtml(unit.target_version)}</h3>
|
||||||
${blockers}${warnings}
|
${capabilities}${blockers}${warnings}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
const compatibilityHtml = compatibility.map((issue) => {
|
const compatibilityHtml = compatibility.map((issue) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user