feat(release): publish verified source tags
This commit is contained in:
@@ -18,6 +18,8 @@ candidate/publish actions:
|
|||||||
- configure target versions per release unit in the web UI
|
- configure target versions per release unit in the web UI
|
||||||
- select the repositories that should advance through checkboxes
|
- select the repositories that should advance through checkboxes
|
||||||
- generate dry-run selective release plans for independently versioned packages
|
- generate dry-run selective release plans for independently versioned packages
|
||||||
|
- create annotated source release tags for selected clean, version-aligned
|
||||||
|
repositories and publish each branch/tag pair atomically
|
||||||
- generate signed catalog candidates for the selected release units
|
- generate signed catalog candidates for the selected release units
|
||||||
- preview/apply/push reviewed catalog candidates behind explicit confirmations
|
- preview/apply/push reviewed catalog candidates behind explicit confirmations
|
||||||
|
|
||||||
@@ -56,13 +58,50 @@ Plain repository pushes are separate from catalog publication. `Preview Push`
|
|||||||
shows the selected repository push commands. `Push Selected` requires `PUSH` in
|
shows the selected repository push commands. `Push Selected` requires `PUSH` in
|
||||||
the repository push confirmation field.
|
the repository push confirmation field.
|
||||||
|
|
||||||
|
The source release panel closes the former gap between a release plan and a
|
||||||
|
catalog candidate. `Preview Tag + Publish` is non-mutating. `Create Tags`
|
||||||
|
requires `TAG`; `Publish Tags` requires `PUBLISH` and atomically pushes the
|
||||||
|
selected branch and annotated tag. The gate requires an aligned target version,
|
||||||
|
a clean named branch with a HEAD, and a checkout that is not behind. Existing
|
||||||
|
local or remote tags must resolve to the selected HEAD and are never moved.
|
||||||
|
The local and remote annotated tag objects must also be identical, not merely
|
||||||
|
point at the same commit.
|
||||||
|
|
||||||
The catalog workflow panel can also operate on the same selected rows:
|
The catalog workflow panel can also operate on the same selected rows:
|
||||||
|
|
||||||
- `Generate` creates a signed candidate below `runtime/release-candidates/`.
|
- `Generate` creates a signed candidate below `runtime/release-candidates/`.
|
||||||
- `Preview` validates a candidate and shows what would be copied into the
|
- `Preview` validates a candidate and shows what would be copied into the
|
||||||
website repository.
|
website repository.
|
||||||
- `Apply + Tag` requires `APPLY` in the confirmation field.
|
- `Apply + Website Tag` requires `APPLY` in the confirmation field.
|
||||||
- `Push` requires `PUSH` in the confirmation field.
|
- `Push Website Release` requires `PUSH` in the confirmation field.
|
||||||
|
|
||||||
|
Source release tags belong to Core or module repositories. Website catalog
|
||||||
|
publication creates a separate catalog tag in the website repository; the UI
|
||||||
|
names these independently to avoid confusing the two immutable references.
|
||||||
|
|
||||||
|
Candidate signing is fail-closed: every Core and module source ref in the
|
||||||
|
complete post-update catalog must already have the requested annotated tag both
|
||||||
|
locally and on the configured source remote. Both tags must be the same
|
||||||
|
annotation object with version-aligned tagged package metadata. Selected tags
|
||||||
|
must additionally resolve to the selected clean, version-aligned HEAD, and the
|
||||||
|
signed `selected_units` record captures the peeled commit and tag-object
|
||||||
|
identifiers. Create and publish the source tags before using `Generate`.
|
||||||
|
|
||||||
|
Catalog publication repeats that check for selected units and also verifies
|
||||||
|
every Core and module source ref in the complete candidate, including entries
|
||||||
|
preserved from the previous catalog. Historical refs need not be at the current
|
||||||
|
worktree HEAD, but their local and remote annotated tags must match and their
|
||||||
|
tagged installable package and module-manifest metadata must declare the catalog
|
||||||
|
version. New selected releases additionally pass the stricter current-source
|
||||||
|
alignment gate, including public runtime version declarations. A candidate
|
||||||
|
cannot be applied or published while any referenced source tag is absent or
|
||||||
|
inconsistent; the preview and API response list each repository and missing or
|
||||||
|
invalid ref so the operator can repair the exact source releases first.
|
||||||
|
|
||||||
|
Read-only provenance checks derive a same-host HTTPS Git URL from conventional
|
||||||
|
SSH remotes when possible, with a non-interactive check of the configured remote
|
||||||
|
as fallback. Source tag creation and atomic publication always use the explicit
|
||||||
|
configured Git remote.
|
||||||
|
|
||||||
The default signing key is
|
The default signing key is
|
||||||
`$HOME/.config/govoplan/release-keys/release-key-1.pem` when no signing key is
|
`$HOME/.config/govoplan/release-keys/release-key-1.pem` when no signing key is
|
||||||
|
|||||||
211
tests/test_release_repository_tag.py
Normal file
211
tests/test_release_repository_tag.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release.repository_tag import tag_repositories # noqa: E402
|
||||||
|
from server.app import create_app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseRepositoryTagTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temporary.name)
|
||||||
|
self.workspace = self.root / "workspace"
|
||||||
|
self.workspace.mkdir()
|
||||||
|
self.repo, self.remote = create_release_repo(
|
||||||
|
root=self.root,
|
||||||
|
workspace=self.workspace,
|
||||||
|
name="govoplan-core",
|
||||||
|
version="0.1.10",
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
def test_core_preview_is_non_mutating_and_publish_is_idempotent(self) -> None:
|
||||||
|
preview = tag_repositories(
|
||||||
|
repos=("govoplan-core",),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=False,
|
||||||
|
push=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(preview["status"], "planned")
|
||||||
|
row = preview["repositories"][0]
|
||||||
|
self.assertEqual(row["status"], "planned")
|
||||||
|
self.assertIn("git tag -a v0.1.10", row["create_command"])
|
||||||
|
self.assertIn("git push --atomic origin", row["publish_command"])
|
||||||
|
self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
|
||||||
|
self.assertFalse(ref_exists(self.remote, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
published = tag_repositories(
|
||||||
|
repos=("govoplan-core",),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=True,
|
||||||
|
push=True,
|
||||||
|
)
|
||||||
|
head = git_text(self.repo, "rev-parse", "HEAD")
|
||||||
|
|
||||||
|
self.assertEqual(published["status"], "published")
|
||||||
|
self.assertEqual(git_text(self.repo, "cat-file", "-t", "refs/tags/v0.1.10"), "tag")
|
||||||
|
self.assertEqual(git_text(self.repo, "rev-parse", "refs/tags/v0.1.10^{commit}"), head)
|
||||||
|
self.assertEqual(git_text(self.remote, "rev-parse", "refs/tags/v0.1.10^{commit}"), head)
|
||||||
|
|
||||||
|
repeated = tag_repositories(
|
||||||
|
repos=("govoplan-core",),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=True,
|
||||||
|
push=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(repeated["status"], "published")
|
||||||
|
self.assertIn("already published", repeated["repositories"][0]["detail"])
|
||||||
|
self.assertEqual(git_text(self.remote, "rev-parse", "refs/tags/v0.1.10^{commit}"), head)
|
||||||
|
|
||||||
|
def test_core_alignment_and_clean_worktree_are_release_gates(self) -> None:
|
||||||
|
mismatch = tag_repositories(
|
||||||
|
repos=("govoplan-core",),
|
||||||
|
repo_versions={"govoplan-core": "0.1.11"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
)
|
||||||
|
self.assertEqual(mismatch["status"], "blocked")
|
||||||
|
self.assertIn("version alignment gate failed", mismatch["repositories"][0]["detail"])
|
||||||
|
|
||||||
|
(self.repo / "uncommitted.txt").write_text("not reviewed\n", encoding="utf-8")
|
||||||
|
dirty = tag_repositories(
|
||||||
|
repos=("govoplan-core",),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
)
|
||||||
|
self.assertEqual(dirty["status"], "blocked")
|
||||||
|
self.assertIn("worktree is not clean", dirty["repositories"][0]["detail"])
|
||||||
|
self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_batch_preflight_blocks_every_mutation_when_a_later_repo_is_dirty(self) -> None:
|
||||||
|
access, access_remote = create_release_repo(
|
||||||
|
root=self.root,
|
||||||
|
workspace=self.workspace,
|
||||||
|
name="govoplan-access",
|
||||||
|
version="0.1.10",
|
||||||
|
)
|
||||||
|
(access / "uncommitted.txt").write_text("not reviewed\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = tag_repositories(
|
||||||
|
repos=("govoplan-core", "govoplan-access"),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10", "govoplan-access": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=True,
|
||||||
|
push=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "blocked")
|
||||||
|
self.assertIn("no selected repository was mutated", result["detail"])
|
||||||
|
self.assertEqual(result["repositories"][0]["status"], "skipped")
|
||||||
|
self.assertEqual(result["repositories"][1]["status"], "blocked")
|
||||||
|
for repository in (self.repo, self.remote, access, access_remote):
|
||||||
|
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_api_requires_explicit_confirmation_and_ui_exposes_source_release(self) -> None:
|
||||||
|
with TestClient(create_app(workspace_root=self.workspace)) as client:
|
||||||
|
rejected = client.post(
|
||||||
|
"/api/repositories/tag",
|
||||||
|
json={
|
||||||
|
"repos": ["govoplan-core"],
|
||||||
|
"repo_versions": {"govoplan-core": "0.1.10"},
|
||||||
|
"apply": True,
|
||||||
|
"push": True,
|
||||||
|
"confirm": "TAG",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
preview = client.post(
|
||||||
|
"/api/repositories/tag",
|
||||||
|
json={
|
||||||
|
"repos": ["govoplan-core"],
|
||||||
|
"repo_versions": {"govoplan-core": "0.1.10"},
|
||||||
|
"apply": False,
|
||||||
|
"push": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ui = client.get("/")
|
||||||
|
|
||||||
|
self.assertEqual(rejected.status_code, 400)
|
||||||
|
self.assertEqual(preview.status_code, 200)
|
||||||
|
self.assertEqual(preview.json()["repositories"][0]["repo"], "govoplan-core")
|
||||||
|
self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
|
||||||
|
self.assertIn('id="publishReleaseTags"', ui.text)
|
||||||
|
self.assertIn('postJson("/api/repositories/tag"', ui.text)
|
||||||
|
self.assertIn("Signed Website Catalog", ui.text)
|
||||||
|
self.assertIn("Apply + Website Tag", ui.text)
|
||||||
|
|
||||||
|
|
||||||
|
def git(cwd: Path, *args: str) -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
("git", *args),
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(f"git {' '.join(args)} failed: {result.stderr or result.stdout}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_release_repo(*, root: Path, workspace: Path, name: str, version: str) -> tuple[Path, Path]:
|
||||||
|
remote = root / f"{name}.git"
|
||||||
|
repo = workspace / name
|
||||||
|
git(root, "init", "--bare", str(remote))
|
||||||
|
git(workspace, "init", "-b", "main", str(repo))
|
||||||
|
git(repo, "config", "user.name", "GovOPlaN Release Test")
|
||||||
|
git(repo, "config", "user.email", "release-test@example.invalid")
|
||||||
|
(repo / "pyproject.toml").write_text(
|
||||||
|
f'[project]\nname = "{name}"\nversion = "{version}"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
git(repo, "add", "pyproject.toml")
|
||||||
|
git(repo, "commit", "-m", f"{name} {version}")
|
||||||
|
git(repo, "remote", "add", "origin", str(remote))
|
||||||
|
git(repo, "push", "-u", "origin", "main")
|
||||||
|
return repo, remote
|
||||||
|
|
||||||
|
|
||||||
|
def git_text(cwd: Path, *args: str) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
("git", *args),
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(f"git {' '.join(args)} failed: {result.stderr or result.stdout}")
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def ref_exists(cwd: Path, ref: str) -> bool:
|
||||||
|
return subprocess.run(
|
||||||
|
("git", "rev-parse", "--verify", ref),
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
335
tests/test_release_source_provenance.py
Normal file
335
tests/test_release_source_provenance.py
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release.publisher import publish_catalog_candidate # noqa: E402
|
||||||
|
from govoplan_release.selective_catalog import ( # noqa: E402
|
||||||
|
build_selective_catalog_candidate,
|
||||||
|
enforce_selected_source_provenance,
|
||||||
|
)
|
||||||
|
from govoplan_release.source_provenance import ( # noqa: E402
|
||||||
|
catalog_source_selection,
|
||||||
|
read_only_https_remote,
|
||||||
|
source_tag_provenance_issues,
|
||||||
|
tagged_source_version_issues,
|
||||||
|
)
|
||||||
|
from server.app import create_app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseSourceProvenanceTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temporary.name)
|
||||||
|
self.workspace = self.root / "workspace"
|
||||||
|
self.workspace.mkdir()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
def test_selected_source_requires_local_and_remote_annotated_tag_at_clean_head(self) -> None:
|
||||||
|
repo, remote = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
||||||
|
|
||||||
|
missing = source_tag_provenance_issues(
|
||||||
|
repo_versions={"govoplan-core": "1.2.3"},
|
||||||
|
workspace=self.workspace,
|
||||||
|
require_head_repos=("govoplan-core",),
|
||||||
|
)
|
||||||
|
self.assertIn("missing from the local source repository", messages(missing))
|
||||||
|
self.assertIn("missing from remote 'origin'", messages(missing))
|
||||||
|
|
||||||
|
git(repo, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3")
|
||||||
|
git(repo, "push", "origin", "refs/tags/v1.2.3")
|
||||||
|
self.assertEqual(
|
||||||
|
(),
|
||||||
|
source_tag_provenance_issues(
|
||||||
|
repo_versions={"govoplan-core": "1.2.3"},
|
||||||
|
workspace=self.workspace,
|
||||||
|
require_head_repos=("govoplan-core",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
git(remote, "config", "user.name", "Different Release Actor")
|
||||||
|
git(remote, "config", "user.email", "different@example.invalid")
|
||||||
|
git(remote, "tag", "-f", "-a", "v1.2.3", git_text(repo, "rev-parse", "HEAD"), "-m", "Different annotation")
|
||||||
|
inconsistent = source_tag_provenance_issues(
|
||||||
|
repo_versions={"govoplan-core": "1.2.3"},
|
||||||
|
workspace=self.workspace,
|
||||||
|
require_head_repos=("govoplan-core",),
|
||||||
|
)
|
||||||
|
self.assertIn("local and remote annotated tag objects differ", messages(inconsistent))
|
||||||
|
|
||||||
|
(repo / "dirty.txt").write_text("not reviewed\n", encoding="utf-8")
|
||||||
|
dirty = source_tag_provenance_issues(
|
||||||
|
repo_versions={"govoplan-core": "1.2.3"},
|
||||||
|
workspace=self.workspace,
|
||||||
|
require_head_repos=("govoplan-core",),
|
||||||
|
)
|
||||||
|
self.assertIn("selected source worktree is not clean", messages(dirty))
|
||||||
|
self.assertEqual(git_text(remote, "rev-parse", "refs/tags/v1.2.3^{commit}"), git_text(repo, "rev-parse", "HEAD"))
|
||||||
|
|
||||||
|
def test_selected_source_gate_is_fail_closed_before_signing(self) -> None:
|
||||||
|
make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "Source tag provenance gate failed") as error:
|
||||||
|
enforce_selected_source_provenance(
|
||||||
|
repo_versions={"govoplan-core": "1.2.3"},
|
||||||
|
workspace=self.workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("v1.2.3", str(error.exception))
|
||||||
|
self.assertIn("missing", str(error.exception))
|
||||||
|
|
||||||
|
def test_historical_tag_uses_installable_and_manifest_version_metadata(self) -> None:
|
||||||
|
repo, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
||||||
|
package = repo / "src" / "govoplan_core"
|
||||||
|
package.mkdir(parents=True)
|
||||||
|
(package / "__init__.py").write_text('__version__ = "1.2.2"\n', encoding="utf-8")
|
||||||
|
git(repo, "add", "src/govoplan_core/__init__.py")
|
||||||
|
git(repo, "commit", "-m", "Keep legacy runtime version string")
|
||||||
|
git(repo, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3")
|
||||||
|
|
||||||
|
issues = tagged_source_version_issues(
|
||||||
|
path=repo,
|
||||||
|
repo="govoplan-core",
|
||||||
|
tag="v1.2.3",
|
||||||
|
expected_version="1.2.3",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((), issues)
|
||||||
|
|
||||||
|
def test_signed_candidate_records_selected_commit_and_tag_object(self) -> None:
|
||||||
|
core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
||||||
|
git(core, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3")
|
||||||
|
git(core, "push", "origin", "refs/tags/v1.2.3")
|
||||||
|
base_catalog = self.root / "base.json"
|
||||||
|
base_catalog.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"channel": "stable",
|
||||||
|
"sequence": 1,
|
||||||
|
"core_release": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"python_ref": "govoplan-core @ git+ssh://git@example.test/acme/govoplan-core.git@v1.2.2",
|
||||||
|
},
|
||||||
|
"modules": [],
|
||||||
|
"release": {"version": "1.2.2", "tag": "v1.2.2"},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
key_path = self.root / "release.pem"
|
||||||
|
key_path.write_bytes(
|
||||||
|
private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output = self.root / "candidate"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.selective_catalog.validate_module_package_catalog",
|
||||||
|
return_value={"valid": True, "warnings": [], "error": None},
|
||||||
|
):
|
||||||
|
result = build_selective_catalog_candidate(
|
||||||
|
repo_versions={"govoplan-core": "1.2.3"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
output_dir=output,
|
||||||
|
base_catalog=base_catalog,
|
||||||
|
signing_keys=(f"test-key={key_path}",),
|
||||||
|
check_public=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(Path(result.catalog_path).read_text(encoding="utf-8"))
|
||||||
|
selected = payload["release"]["selected_units"][0]
|
||||||
|
self.assertEqual(git_text(core, "rev-parse", "refs/tags/v1.2.3^{commit}"), selected["commit_sha"])
|
||||||
|
self.assertEqual(git_text(core, "rev-parse", "refs/tags/v1.2.3"), selected["tag_object_sha"])
|
||||||
|
self.assertEqual("test-key", payload["signatures"][0]["key_id"])
|
||||||
|
|
||||||
|
def test_catalog_publication_checks_every_preserved_source_ref(self) -> None:
|
||||||
|
core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
||||||
|
git(core, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3")
|
||||||
|
git(core, "push", "origin", "refs/tags/v1.2.3")
|
||||||
|
make_repo(self.workspace, self.root, "govoplan-access", "1.1.0")
|
||||||
|
|
||||||
|
candidate = self.root / "candidate"
|
||||||
|
(candidate / "channels").mkdir(parents=True)
|
||||||
|
payload = catalog_payload(
|
||||||
|
commit_sha="f" * 40,
|
||||||
|
tag_object_sha=git_text(core, "rev-parse", "refs/tags/v1.2.3"),
|
||||||
|
)
|
||||||
|
(candidate / "channels" / "stable.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
keyring = {"keys": [{"key_id": "release-key", "status": "active", "public_key": "trusted"}]}
|
||||||
|
(candidate / "keyring.json").write_text(json.dumps(keyring), encoding="utf-8")
|
||||||
|
web_root = self.root / "website"
|
||||||
|
target_keyring = web_root / "public" / "catalogs" / "v1" / "keyring.json"
|
||||||
|
target_keyring.parent.mkdir(parents=True)
|
||||||
|
target_keyring.write_text(json.dumps(keyring), encoding="utf-8")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.publisher.validate_module_package_catalog",
|
||||||
|
return_value={"valid": True, "warnings": [], "error": None},
|
||||||
|
):
|
||||||
|
result = publish_catalog_candidate(
|
||||||
|
candidate_dir=candidate,
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
web_root=web_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
detail = " ".join(result.notes)
|
||||||
|
self.assertEqual("blocked", result.status)
|
||||||
|
self.assertIn("govoplan-access v1.1.0", detail)
|
||||||
|
self.assertIn("missing from the local source repository", detail)
|
||||||
|
self.assertIn("missing from remote 'origin'", detail)
|
||||||
|
self.assertIn("no longer matches signed commit_sha", detail)
|
||||||
|
self.assertNotIn("govoplan-core v1.2.3: annotated tag is missing", detail)
|
||||||
|
|
||||||
|
def test_catalog_selection_includes_core_and_all_modules(self) -> None:
|
||||||
|
selection = catalog_source_selection(catalog_payload())
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{"govoplan-core": "1.2.3", "govoplan-access": "1.1.0"},
|
||||||
|
selection.all_versions,
|
||||||
|
)
|
||||||
|
self.assertEqual({"govoplan-core": "1.2.3"}, selection.selected_versions)
|
||||||
|
self.assertEqual((), selection.issues)
|
||||||
|
|
||||||
|
def test_console_renders_publication_provenance_notes(self) -> None:
|
||||||
|
ui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("Array.isArray(result.notes)", ui)
|
||||||
|
self.assertIn("Validation and provenance details", ui)
|
||||||
|
|
||||||
|
def test_console_returns_actionable_conflict_for_candidate_provenance_gate(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"server.app.build_selective_catalog_candidate",
|
||||||
|
side_effect=ValueError("Complete catalog source provenance gate failed: govoplan-core v1.2.3: missing"),
|
||||||
|
):
|
||||||
|
with TestClient(create_app(workspace_root=self.workspace)) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/catalog-candidates",
|
||||||
|
json={
|
||||||
|
"repo_versions": {"govoplan-core": "1.2.3"},
|
||||||
|
"signing_keys": ["test=/unused/key.pem"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(409, response.status_code)
|
||||||
|
self.assertIn("govoplan-core v1.2.3: missing", response.json()["detail"])
|
||||||
|
|
||||||
|
def test_read_only_ref_checks_can_reuse_the_same_gitea_https_endpoint(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
"https://git.example.test/acme/govoplan-core.git",
|
||||||
|
read_only_https_remote("git@git.example.test:acme/govoplan-core.git"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"https://git.example.test/acme/govoplan-core.git",
|
||||||
|
read_only_https_remote("ssh://git@git.example.test/acme/govoplan-core.git"),
|
||||||
|
)
|
||||||
|
self.assertIsNone(read_only_https_remote("ssh://git@git.example.test:2222/acme/govoplan-core.git"))
|
||||||
|
self.assertIsNone(read_only_https_remote("/srv/git/govoplan-core.git"))
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_payload(
|
||||||
|
*,
|
||||||
|
commit_sha: str = "0" * 40,
|
||||||
|
tag_object_sha: str = "1" * 40,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"channel": "stable",
|
||||||
|
"core_release": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"python_ref": "govoplan-core @ git+ssh://git@example.test/acme/govoplan-core.git@v1.2.3",
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"module_id": "access",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"python_package": "govoplan-access",
|
||||||
|
"python_ref": "govoplan-access @ git+ssh://git@example.test/acme/govoplan-access.git@v1.1.0",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"release": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"tag": "v1.2.3",
|
||||||
|
"selected_units": [
|
||||||
|
{
|
||||||
|
"repo": "govoplan-core",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"tag": "v1.2.3",
|
||||||
|
"commit_sha": commit_sha,
|
||||||
|
"tag_object_sha": tag_object_sha,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_repo(workspace: Path, root: Path, name: str, version: str) -> tuple[Path, Path]:
|
||||||
|
remote = root / f"{name}.git"
|
||||||
|
repo = workspace / name
|
||||||
|
git(root, "init", "--bare", str(remote))
|
||||||
|
git(workspace, "init", "-b", "main", str(repo))
|
||||||
|
git(repo, "config", "user.name", "GovOPlaN Release Test")
|
||||||
|
git(repo, "config", "user.email", "release-test@example.invalid")
|
||||||
|
(repo / "pyproject.toml").write_text(
|
||||||
|
f'[project]\nname = "{name}"\nversion = "{version}"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
git(repo, "add", "pyproject.toml")
|
||||||
|
git(repo, "commit", "-m", f"{name} {version}")
|
||||||
|
git(repo, "remote", "add", "origin", str(remote))
|
||||||
|
git(repo, "push", "-u", "origin", "main")
|
||||||
|
return repo, remote
|
||||||
|
|
||||||
|
|
||||||
|
def messages(issues: object) -> str:
|
||||||
|
return " ".join(issue.message for issue in issues) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def git(cwd: Path, *args: str) -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
("git", *args),
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(f"git {' '.join(args)} failed: {result.stderr or result.stdout}")
|
||||||
|
|
||||||
|
|
||||||
|
def git_text(cwd: Path, *args: str) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
("git", *args),
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(f"git {' '.join(args)} failed: {result.stderr or result.stdout}")
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -18,6 +18,7 @@ __all__ = [
|
|||||||
"prepare_repositories",
|
"prepare_repositories",
|
||||||
"push_repositories",
|
"push_repositories",
|
||||||
"sync_repositories",
|
"sync_repositories",
|
||||||
|
"tag_repositories",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -64,4 +65,8 @@ def _load_lazy_export(name: str):
|
|||||||
from .repository_sync import sync_repositories
|
from .repository_sync import sync_repositories
|
||||||
|
|
||||||
return sync_repositories
|
return sync_repositories
|
||||||
|
if name == "tag_repositories":
|
||||||
|
from .repository_tag import tag_repositories
|
||||||
|
|
||||||
|
return tag_repositories
|
||||||
raise AttributeError(name)
|
raise AttributeError(name)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .catalog import canonical_hash
|
|||||||
from .model import CatalogPublishResult, CatalogPublishStep
|
from .model import CatalogPublishResult, CatalogPublishStep
|
||||||
from .module_directory import write_module_directory
|
from .module_directory import write_module_directory
|
||||||
from .selective_catalog import trusted_keys_from_keyring
|
from .selective_catalog import trusted_keys_from_keyring
|
||||||
|
from .source_provenance import catalog_source_selection, source_tag_provenance_issues
|
||||||
from .version_alignment import candidate_catalog_version_issues
|
from .version_alignment import candidate_catalog_version_issues
|
||||||
from .workspace import DEFAULT_WORKSPACE_ROOT, resolve_workspace_root, website_root
|
from .workspace import DEFAULT_WORKSPACE_ROOT, resolve_workspace_root, website_root
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ def publish_catalog_candidate(
|
|||||||
tag: bool = False,
|
tag: bool = False,
|
||||||
push: bool = False,
|
push: bool = False,
|
||||||
remote: str = "origin",
|
remote: str = "origin",
|
||||||
|
source_remote: str = "origin",
|
||||||
branch: str | None = None,
|
branch: str | None = None,
|
||||||
npm: str = "npm",
|
npm: str = "npm",
|
||||||
tag_name: str | None = None,
|
tag_name: str | None = None,
|
||||||
@@ -79,6 +81,23 @@ def publish_catalog_candidate(
|
|||||||
f"expected {issue.expected!r} ({issue.message})"
|
f"expected {issue.expected!r} ({issue.message})"
|
||||||
for issue in version_issues
|
for issue in version_issues
|
||||||
)
|
)
|
||||||
|
source_selection = catalog_source_selection(candidate_payload)
|
||||||
|
blockers.extend(
|
||||||
|
f"source provenance: {issue.describe()}"
|
||||||
|
for issue in source_selection.issues
|
||||||
|
)
|
||||||
|
source_issues = source_tag_provenance_issues(
|
||||||
|
repo_versions=source_selection.all_versions,
|
||||||
|
workspace=workspace,
|
||||||
|
remote=source_remote,
|
||||||
|
require_head_repos=source_selection.selected_versions,
|
||||||
|
expected_commits=source_selection.selected_commits,
|
||||||
|
expected_tag_objects=source_selection.selected_tag_objects,
|
||||||
|
)
|
||||||
|
blockers.extend(
|
||||||
|
f"source provenance: {issue.describe()}"
|
||||||
|
for issue in source_issues
|
||||||
|
)
|
||||||
candidate_keys = trusted_keys_from_keyring(keyring_payload if isinstance(keyring_payload, dict) else {})
|
candidate_keys = trusted_keys_from_keyring(keyring_payload if isinstance(keyring_payload, dict) else {})
|
||||||
target_keyring_payload = read_json(target_keyring) if target_keyring.exists() else None
|
target_keyring_payload = read_json(target_keyring) if target_keyring.exists() else None
|
||||||
publication_trust = trusted_keys_from_keyring(
|
publication_trust = trusted_keys_from_keyring(
|
||||||
|
|||||||
426
tools/release/govoplan_release/repository_tag.py
Normal file
426
tools/release/govoplan_release/repository_tag.py
Normal 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")
|
||||||
@@ -19,6 +19,11 @@ from .contracts import collect_contracts
|
|||||||
from .git_state import collect_repository_snapshot
|
from .git_state import collect_repository_snapshot
|
||||||
from .model import CatalogEntryChange, SelectiveCatalogCandidate
|
from .model import CatalogEntryChange, SelectiveCatalogCandidate
|
||||||
from .module_directory import write_module_directory
|
from .module_directory import write_module_directory
|
||||||
|
from .source_provenance import (
|
||||||
|
catalog_source_selection,
|
||||||
|
selected_source_provenance,
|
||||||
|
source_tag_provenance_issues,
|
||||||
|
)
|
||||||
from .version_alignment import selected_repository_version_issues
|
from .version_alignment import selected_repository_version_issues
|
||||||
from .workspace import load_repository_specs, resolve_workspace_root, website_root
|
from .workspace import load_repository_specs, resolve_workspace_root, website_root
|
||||||
|
|
||||||
@@ -35,6 +40,7 @@ def build_selective_catalog_candidate(
|
|||||||
signing_keys: tuple[str, ...] = (),
|
signing_keys: tuple[str, ...] = (),
|
||||||
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
|
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
|
||||||
repository_base: str = GITEA_BASE,
|
repository_base: str = GITEA_BASE,
|
||||||
|
source_remote: str = "origin",
|
||||||
expires_days: int = 90,
|
expires_days: int = 90,
|
||||||
sequence: int | None = None,
|
sequence: int | None = None,
|
||||||
check_public: bool = True,
|
check_public: bool = True,
|
||||||
@@ -48,6 +54,15 @@ def build_selective_catalog_candidate(
|
|||||||
|
|
||||||
workspace = resolve_workspace_root(workspace_root)
|
workspace = resolve_workspace_root(workspace_root)
|
||||||
enforce_selected_version_alignment(repo_versions=repo_versions, workspace=workspace)
|
enforce_selected_version_alignment(repo_versions=repo_versions, workspace=workspace)
|
||||||
|
enforce_selected_source_provenance(
|
||||||
|
repo_versions=repo_versions,
|
||||||
|
workspace=workspace,
|
||||||
|
remote=source_remote,
|
||||||
|
)
|
||||||
|
selected_provenance = selected_source_provenance(
|
||||||
|
repo_versions=repo_versions,
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
web_root = website_root(workspace)
|
web_root = website_root(workspace)
|
||||||
source_catalog = resolve_base_catalog(base_catalog=base_catalog, web_root=web_root, channel=channel)
|
source_catalog = resolve_base_catalog(base_catalog=base_catalog, web_root=web_root, channel=channel)
|
||||||
payload = read_catalog(source_catalog)
|
payload = read_catalog(source_catalog)
|
||||||
@@ -66,7 +81,15 @@ def build_selective_catalog_candidate(
|
|||||||
release = {}
|
release = {}
|
||||||
release["catalog_url"] = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json"
|
release["catalog_url"] = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json"
|
||||||
release["keyring_url"] = f"{public_base_url.rstrip('/')}/catalogs/v1/keyring.json"
|
release["keyring_url"] = f"{public_base_url.rstrip('/')}/catalogs/v1/keyring.json"
|
||||||
release["selected_units"] = [{"repo": repo, "version": version, "tag": f"v{version.removeprefix('v')}"} for repo, version in sorted(repo_versions.items())]
|
release["selected_units"] = [
|
||||||
|
{
|
||||||
|
"repo": repo,
|
||||||
|
"version": version,
|
||||||
|
"tag": f"v{version.removeprefix('v')}",
|
||||||
|
**selected_provenance[repo],
|
||||||
|
}
|
||||||
|
for repo, version in sorted(repo_versions.items())
|
||||||
|
]
|
||||||
candidate["release"] = release
|
candidate["release"] = release
|
||||||
|
|
||||||
repo_contracts = contracts_by_repo(workspace)
|
repo_contracts = contracts_by_repo(workspace)
|
||||||
@@ -76,6 +99,11 @@ def build_selective_catalog_candidate(
|
|||||||
repo_contracts=repo_contracts,
|
repo_contracts=repo_contracts,
|
||||||
repository_base=repository_base.rstrip("/"),
|
repository_base=repository_base.rstrip("/"),
|
||||||
)
|
)
|
||||||
|
enforce_complete_catalog_source_provenance(
|
||||||
|
payload=candidate,
|
||||||
|
workspace=workspace,
|
||||||
|
remote=source_remote,
|
||||||
|
)
|
||||||
|
|
||||||
output_root = resolve_output_dir(output_dir=output_dir, channel=channel, generated_at=generated_at)
|
output_root = resolve_output_dir(output_dir=output_dir, channel=channel, generated_at=generated_at)
|
||||||
catalog_path = output_root / "channels" / f"{channel}.json"
|
catalog_path = output_root / "channels" / f"{channel}.json"
|
||||||
@@ -175,6 +203,50 @@ def enforce_selected_version_alignment(*, repo_versions: dict[str, str], workspa
|
|||||||
raise ValueError("Version alignment gate failed: " + "; ".join(failures))
|
raise ValueError("Version alignment gate failed: " + "; ".join(failures))
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_selected_source_provenance(
|
||||||
|
*,
|
||||||
|
repo_versions: dict[str, str],
|
||||||
|
workspace: Path,
|
||||||
|
remote: str = "origin",
|
||||||
|
) -> None:
|
||||||
|
failures = source_tag_provenance_issues(
|
||||||
|
repo_versions=repo_versions,
|
||||||
|
workspace=workspace,
|
||||||
|
remote=remote,
|
||||||
|
require_head_repos=repo_versions,
|
||||||
|
)
|
||||||
|
if failures:
|
||||||
|
raise ValueError(
|
||||||
|
"Source tag provenance gate failed: "
|
||||||
|
+ "; ".join(issue.describe() for issue in failures)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_complete_catalog_source_provenance(
|
||||||
|
*,
|
||||||
|
payload: object,
|
||||||
|
workspace: Path,
|
||||||
|
remote: str = "origin",
|
||||||
|
) -> None:
|
||||||
|
selection = catalog_source_selection(payload)
|
||||||
|
failures = [*selection.issues]
|
||||||
|
failures.extend(
|
||||||
|
source_tag_provenance_issues(
|
||||||
|
repo_versions=selection.all_versions,
|
||||||
|
workspace=workspace,
|
||||||
|
remote=remote,
|
||||||
|
require_head_repos=selection.selected_versions,
|
||||||
|
expected_commits=selection.selected_commits,
|
||||||
|
expected_tag_objects=selection.selected_tag_objects,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if failures:
|
||||||
|
raise ValueError(
|
||||||
|
"Complete catalog source provenance gate failed: "
|
||||||
|
+ "; ".join(issue.describe() for issue in failures)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def resolve_base_catalog(*, base_catalog: Path | str | None, web_root: Path, channel: str) -> Path | str:
|
def resolve_base_catalog(*, base_catalog: Path | str | None, web_root: Path, channel: str) -> Path | str:
|
||||||
if base_catalog is not None:
|
if base_catalog is not None:
|
||||||
value = str(base_catalog)
|
value = str(base_catalog)
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ def compatibility_issues(dashboard: ReleaseDashboard) -> tuple[CompatibilityIssu
|
|||||||
|
|
||||||
def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashboard, channel: str) -> tuple[ReleasePlanStep, ...]:
|
def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashboard, channel: str) -> tuple[ReleasePlanStep, ...]:
|
||||||
steps: list[ReleasePlanStep] = []
|
steps: list[ReleasePlanStep] = []
|
||||||
|
snapshots = {repo.spec.name: repo for repo in dashboard.repositories}
|
||||||
for unit in units:
|
for unit in units:
|
||||||
steps.append(
|
steps.append(
|
||||||
ReleasePlanStep(
|
ReleasePlanStep(
|
||||||
@@ -150,6 +151,8 @@ def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashb
|
|||||||
status="needs-executor",
|
status="needs-executor",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
snapshot = snapshots.get(unit.repo)
|
||||||
|
if unit.current_version != unit.target_version or (snapshot is not None and snapshot.dirty):
|
||||||
steps.append(
|
steps.append(
|
||||||
ReleasePlanStep(
|
ReleasePlanStep(
|
||||||
id=f"{unit.repo}:commit",
|
id=f"{unit.repo}:commit",
|
||||||
@@ -229,10 +232,16 @@ def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashb
|
|||||||
ReleasePlanStep(
|
ReleasePlanStep(
|
||||||
id="catalog:validate-sign-publish",
|
id="catalog:validate-sign-publish",
|
||||||
title=f"Validate, sign, and publish {channel}",
|
title=f"Validate, sign, and publish {channel}",
|
||||||
detail="The candidate writer validates and signs locally. Publishing to the website repo remains a separate apply step.",
|
detail=(
|
||||||
command="Review the candidate under runtime/release-candidates before publishing it to the website repository.",
|
"The candidate writer validates and signs locally. Preview and guarded apply/tag/push executors "
|
||||||
|
"are available in the Signed Website Catalog panel and the release-catalog CLI."
|
||||||
|
),
|
||||||
|
command=(
|
||||||
|
"tools/release/release-catalog.py publish-candidate "
|
||||||
|
f"--candidate-dir \"$CANDIDATE_DIR\" --channel {shlex.quote(channel)}"
|
||||||
|
),
|
||||||
cwd=dashboard.meta_root,
|
cwd=dashboard.meta_root,
|
||||||
status="needs-publish-executor",
|
status="planned",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return tuple(steps)
|
return tuple(steps)
|
||||||
|
|||||||
438
tools/release/govoplan_release/source_provenance.py
Normal file
438
tools/release/govoplan_release/source_provenance.py
Normal 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
|
||||||
@@ -27,6 +27,7 @@ def main() -> int:
|
|||||||
selective.add_argument("--output-dir", type=Path, help="Candidate output directory. Defaults to runtime/release-candidates/<channel>-<timestamp>.")
|
selective.add_argument("--output-dir", type=Path, help="Candidate output directory. Defaults to runtime/release-candidates/<channel>-<timestamp>.")
|
||||||
selective.add_argument("--public-base-url", default="https://govoplan.add-ideas.de")
|
selective.add_argument("--public-base-url", default="https://govoplan.add-ideas.de")
|
||||||
selective.add_argument("--repository-base", default="git+ssh://git@git.add-ideas.de/add-ideas")
|
selective.add_argument("--repository-base", default="git+ssh://git@git.add-ideas.de/add-ideas")
|
||||||
|
selective.add_argument("--source-remote", default="origin", help="Configured Git remote containing immutable source tags.")
|
||||||
selective.add_argument("--expires-days", type=int, default=90)
|
selective.add_argument("--expires-days", type=int, default=90)
|
||||||
selective.add_argument("--sequence", type=int)
|
selective.add_argument("--sequence", type=int)
|
||||||
selective.add_argument("--skip-public-check", action="store_true")
|
selective.add_argument("--skip-public-check", action="store_true")
|
||||||
@@ -43,6 +44,7 @@ def main() -> int:
|
|||||||
publish.add_argument("--tag", action="store_true", help="Create a website catalog publication tag. Implies --commit.")
|
publish.add_argument("--tag", action="store_true", help="Create a website catalog publication tag. Implies --commit.")
|
||||||
publish.add_argument("--push", action="store_true", help="Push the website branch and tag. Implies --commit.")
|
publish.add_argument("--push", action="store_true", help="Push the website branch and tag. Implies --commit.")
|
||||||
publish.add_argument("--remote", default="origin")
|
publish.add_argument("--remote", default="origin")
|
||||||
|
publish.add_argument("--source-remote", default="origin", help="Configured Git remote containing catalog source tags.")
|
||||||
publish.add_argument("--branch")
|
publish.add_argument("--branch")
|
||||||
publish.add_argument("--tag-name")
|
publish.add_argument("--tag-name")
|
||||||
publish.add_argument("--npm", default="npm")
|
publish.add_argument("--npm", default="npm")
|
||||||
@@ -68,6 +70,7 @@ def main() -> int:
|
|||||||
signing_keys=tuple(args.catalog_signing_key),
|
signing_keys=tuple(args.catalog_signing_key),
|
||||||
public_base_url=args.public_base_url,
|
public_base_url=args.public_base_url,
|
||||||
repository_base=args.repository_base,
|
repository_base=args.repository_base,
|
||||||
|
source_remote=args.source_remote,
|
||||||
expires_days=args.expires_days,
|
expires_days=args.expires_days,
|
||||||
sequence=args.sequence,
|
sequence=args.sequence,
|
||||||
check_public=not args.skip_public_check,
|
check_public=not args.skip_public_check,
|
||||||
@@ -90,6 +93,7 @@ def main() -> int:
|
|||||||
tag=args.tag,
|
tag=args.tag,
|
||||||
push=args.push,
|
push=args.push,
|
||||||
remote=args.remote,
|
remote=args.remote,
|
||||||
|
source_remote=args.source_remote,
|
||||||
branch=args.branch,
|
branch=args.branch,
|
||||||
npm=args.npm,
|
npm=args.npm,
|
||||||
tag_name=args.tag_name,
|
tag_name=args.tag_name,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from govoplan_release import (
|
|||||||
prepare_repositories,
|
prepare_repositories,
|
||||||
push_repositories,
|
push_repositories,
|
||||||
sync_repositories,
|
sync_repositories,
|
||||||
|
tag_repositories,
|
||||||
)
|
)
|
||||||
from govoplan_release.model import to_jsonable
|
from govoplan_release.model import to_jsonable
|
||||||
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT
|
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT
|
||||||
@@ -35,6 +36,7 @@ class CatalogCandidateRequest(BaseModel):
|
|||||||
sequence: int | None = None
|
sequence: int | None = None
|
||||||
public_base_url: str = "https://govoplan.add-ideas.de"
|
public_base_url: str = "https://govoplan.add-ideas.de"
|
||||||
repository_base: str = "git+ssh://git@git.add-ideas.de/add-ideas"
|
repository_base: str = "git+ssh://git@git.add-ideas.de/add-ideas"
|
||||||
|
source_remote: str = "origin"
|
||||||
check_public: bool = True
|
check_public: bool = True
|
||||||
|
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ class PublishCandidateRequest(BaseModel):
|
|||||||
tag: bool = False
|
tag: bool = False
|
||||||
push: bool = False
|
push: bool = False
|
||||||
remote: str = "origin"
|
remote: str = "origin"
|
||||||
|
source_remote: str = "origin"
|
||||||
branch: str | None = None
|
branch: str | None = None
|
||||||
tag_name: str | None = None
|
tag_name: str | None = None
|
||||||
npm: str = "npm"
|
npm: str = "npm"
|
||||||
@@ -77,6 +80,16 @@ class RepositoryPrepareRequest(BaseModel):
|
|||||||
confirm: str = ""
|
confirm: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryTagRequest(BaseModel):
|
||||||
|
repos: list[str] = Field(default_factory=list)
|
||||||
|
repo_versions: dict[str, str] = Field(default_factory=dict)
|
||||||
|
remote: str = "origin"
|
||||||
|
message: str | None = None
|
||||||
|
apply: bool = False # noqa: A003 - API field mirrors CLI.
|
||||||
|
push: bool = False
|
||||||
|
confirm: str = ""
|
||||||
|
|
||||||
|
|
||||||
def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | None = None) -> FastAPI:
|
def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | None = None) -> FastAPI:
|
||||||
app = FastAPI(title="GovOPlaN Release Console", version="0.1.0")
|
app = FastAPI(title="GovOPlaN Release Console", version="0.1.0")
|
||||||
app.state.workspace_root = workspace_root
|
app.state.workspace_root = workspace_root
|
||||||
@@ -203,6 +216,7 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No
|
|||||||
signing_keys = tuple(request.signing_keys or default_signing_keys())
|
signing_keys = tuple(request.signing_keys or default_signing_keys())
|
||||||
if not signing_keys:
|
if not signing_keys:
|
||||||
raise HTTPException(status_code=400, detail="No signing key supplied and default release key was not found")
|
raise HTTPException(status_code=400, detail="No signing key supplied and default release key was not found")
|
||||||
|
try:
|
||||||
candidate = build_selective_catalog_candidate(
|
candidate = build_selective_catalog_candidate(
|
||||||
repo_versions=repo_versions,
|
repo_versions=repo_versions,
|
||||||
channel=request.channel,
|
channel=request.channel,
|
||||||
@@ -212,10 +226,13 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No
|
|||||||
signing_keys=signing_keys,
|
signing_keys=signing_keys,
|
||||||
public_base_url=request.public_base_url,
|
public_base_url=request.public_base_url,
|
||||||
repository_base=request.repository_base,
|
repository_base=request.repository_base,
|
||||||
|
source_remote=request.source_remote,
|
||||||
expires_days=request.expires_days,
|
expires_days=request.expires_days,
|
||||||
sequence=request.sequence,
|
sequence=request.sequence,
|
||||||
check_public=request.check_public,
|
check_public=request.check_public,
|
||||||
)
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
return to_jsonable(candidate)
|
return to_jsonable(candidate)
|
||||||
|
|
||||||
@app.post("/api/catalog-candidates/publish")
|
@app.post("/api/catalog-candidates/publish")
|
||||||
@@ -235,6 +252,7 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No
|
|||||||
tag=request.tag,
|
tag=request.tag,
|
||||||
push=request.push,
|
push=request.push,
|
||||||
remote=request.remote,
|
remote=request.remote,
|
||||||
|
source_remote=request.source_remote,
|
||||||
branch=request.branch,
|
branch=request.branch,
|
||||||
tag_name=request.tag_name,
|
tag_name=request.tag_name,
|
||||||
npm=request.npm,
|
npm=request.npm,
|
||||||
@@ -278,6 +296,26 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No
|
|||||||
)
|
)
|
||||||
return to_jsonable(result)
|
return to_jsonable(result)
|
||||||
|
|
||||||
|
@app.post("/api/repositories/tag")
|
||||||
|
def repository_tag(request: RepositoryTagRequest) -> dict[str, object]:
|
||||||
|
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
|
||||||
|
if not repos:
|
||||||
|
raise HTTPException(status_code=400, detail="At least one repository must be selected")
|
||||||
|
if request.apply and request.push and request.confirm != "PUBLISH":
|
||||||
|
raise HTTPException(status_code=400, detail="Release tag publication requires confirm=PUBLISH")
|
||||||
|
if request.apply and not request.push and request.confirm != "TAG":
|
||||||
|
raise HTTPException(status_code=400, detail="Release tag creation requires confirm=TAG")
|
||||||
|
result = tag_repositories(
|
||||||
|
repos=repos,
|
||||||
|
repo_versions=request.repo_versions,
|
||||||
|
message=request.message,
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
remote=request.remote,
|
||||||
|
apply=request.apply,
|
||||||
|
push=request.push,
|
||||||
|
)
|
||||||
|
return to_jsonable(result)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -692,15 +692,34 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>3. Release Tag Plan</h2>
|
<h2>3. Source Release Tag</h2>
|
||||||
<small id="planStatus"></small>
|
<small><span id="tagStatus">idle</span> · plan <span id="planStatus">idle</span></small>
|
||||||
|
</div>
|
||||||
|
<div class="details">
|
||||||
|
<p class="hint">Creates an annotated source tag at the selected clean HEAD. Publish pushes the branch and tag atomically; existing release tags are never moved.</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="tagMessage">Tag message</label>
|
||||||
|
<input id="tagMessage" type="text" placeholder="default Release <repo> <tag>" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="tagConfirmText">Confirm</label>
|
||||||
|
<input id="tagConfirmText" type="text" placeholder="TAG or PUBLISH" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="secondary" id="previewReleaseTags">Preview Tag + Publish</button>
|
||||||
|
<button id="createReleaseTags">Create Tags</button>
|
||||||
|
<button class="danger" id="publishReleaseTags">Publish Tags</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions" id="tagOutput"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions" id="actions"></div>
|
<div class="actions" id="actions"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>4. Signed Release</h2>
|
<h2>4. Signed Website Catalog</h2>
|
||||||
<small id="workflowStatus"></small>
|
<small id="workflowStatus"></small>
|
||||||
</div>
|
</div>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
@@ -721,8 +740,8 @@
|
|||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button id="generateCandidate">Generate</button>
|
<button id="generateCandidate">Generate</button>
|
||||||
<button class="secondary" id="previewPublish">Preview</button>
|
<button class="secondary" id="previewPublish">Preview</button>
|
||||||
<button class="secondary" id="applyPublish">Apply + Tag</button>
|
<button class="secondary" id="applyPublish">Apply + Website Tag</button>
|
||||||
<button class="danger" id="pushPublish">Push</button>
|
<button class="danger" id="pushPublish">Push Website Release</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions" id="workflowOutput"></div>
|
<div class="actions" id="workflowOutput"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -801,6 +820,13 @@
|
|||||||
catalogStatus: document.getElementById("catalogStatus"),
|
catalogStatus: document.getElementById("catalogStatus"),
|
||||||
actions: document.getElementById("actions"),
|
actions: document.getElementById("actions"),
|
||||||
planStatus: document.getElementById("planStatus"),
|
planStatus: document.getElementById("planStatus"),
|
||||||
|
tagStatus: document.getElementById("tagStatus"),
|
||||||
|
tagMessage: document.getElementById("tagMessage"),
|
||||||
|
tagConfirmText: document.getElementById("tagConfirmText"),
|
||||||
|
tagOutput: document.getElementById("tagOutput"),
|
||||||
|
previewReleaseTags: document.getElementById("previewReleaseTags"),
|
||||||
|
createReleaseTags: document.getElementById("createReleaseTags"),
|
||||||
|
publishReleaseTags: document.getElementById("publishReleaseTags"),
|
||||||
signingKey: document.getElementById("signingKey"),
|
signingKey: document.getElementById("signingKey"),
|
||||||
candidateDir: document.getElementById("candidateDir"),
|
candidateDir: document.getElementById("candidateDir"),
|
||||||
confirmText: document.getElementById("confirmText"),
|
confirmText: document.getElementById("confirmText"),
|
||||||
@@ -858,6 +884,9 @@
|
|||||||
elements.previewPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: false }));
|
elements.previewPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: false }));
|
||||||
elements.commitPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: true }));
|
elements.commitPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: true }));
|
||||||
elements.buildSelectedPlan.addEventListener("click", buildSelectedPlan);
|
elements.buildSelectedPlan.addEventListener("click", buildSelectedPlan);
|
||||||
|
elements.previewReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: false, push: true }));
|
||||||
|
elements.createReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: true, push: false }));
|
||||||
|
elements.publishReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: true, push: true }));
|
||||||
elements.selectUnpushedUnits.addEventListener("click", selectUnpushedUnits);
|
elements.selectUnpushedUnits.addEventListener("click", selectUnpushedUnits);
|
||||||
elements.selectChangedUnits.addEventListener("click", selectChangedUnits);
|
elements.selectChangedUnits.addEventListener("click", selectChangedUnits);
|
||||||
elements.selectUnreleasedUnits.addEventListener("click", selectUnreleasedUnits);
|
elements.selectUnreleasedUnits.addEventListener("click", selectUnreleasedUnits);
|
||||||
@@ -974,18 +1003,18 @@
|
|||||||
: "No dirty worktrees in the current dashboard."
|
: "No dirty worktrees in the current dashboard."
|
||||||
),
|
),
|
||||||
workflowStage(
|
workflowStage(
|
||||||
"3. Generate release tag",
|
"3. Publish source release tags",
|
||||||
tagStatus,
|
tagStatus,
|
||||||
releaseBlockers ? "block" : selected ? "ok" : "warn",
|
releaseBlockers ? "block" : selected ? "ok" : "warn",
|
||||||
selected
|
selected
|
||||||
? `${selected} selected; cockpit builds the tag plan, release tagging still runs through the guarded release script.`
|
? `${selected} selected; use Preview Tag + Publish, then Create Tags or Publish Tags after reviewing the source-release preflight.`
|
||||||
: "Select repositories, set target versions, then build the release tag plan."
|
: "Select repositories, set target versions, then preview the source release tags."
|
||||||
),
|
),
|
||||||
workflowStage(
|
workflowStage(
|
||||||
"4. Sign and publish release",
|
"4. Sign and publish website catalog",
|
||||||
signedStatus,
|
signedStatus,
|
||||||
hasCandidate ? "ok" : "warn",
|
hasCandidate ? "ok" : "warn",
|
||||||
hasCandidate ? `Candidate: ${elements.candidateDir.value.trim()}` : "Generate a signed candidate, preview it, then apply/tag/push the website publication."
|
hasCandidate ? `Candidate: ${elements.candidateDir.value.trim()}` : "After every selected source tag is remotely published, generate a signed candidate, preview it, then apply/tag/push the website publication."
|
||||||
),
|
),
|
||||||
workflowStage(
|
workflowStage(
|
||||||
"5. Maintain install catalog",
|
"5. Maintain install catalog",
|
||||||
@@ -1595,6 +1624,65 @@
|
|||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function releaseSelectedTags(options) {
|
||||||
|
const repos = selectedRepoNames();
|
||||||
|
if (!repos.length) {
|
||||||
|
elements.tagStatus.textContent = "idle";
|
||||||
|
elements.tagOutput.innerHTML = `<div class="action"><h3>${pill("idle", "warn")} No repositories selected</h3><p>Select one or more repositories first.</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
elements.tagStatus.textContent = options.apply ? (options.push ? "Publishing..." : "Tagging...") : "Previewing...";
|
||||||
|
setBusy([elements.previewReleaseTags, elements.createReleaseTags, elements.publishReleaseTags], true);
|
||||||
|
try {
|
||||||
|
const result = await postJson("/api/repositories/tag", {
|
||||||
|
repos,
|
||||||
|
repo_versions: selectedRepoVersions(),
|
||||||
|
remote: elements.pushRemote.value.trim() || "origin",
|
||||||
|
message: elements.tagMessage.value.trim() || null,
|
||||||
|
apply: options.apply === true,
|
||||||
|
push: options.push === true,
|
||||||
|
confirm: elements.tagConfirmText.value.trim(),
|
||||||
|
});
|
||||||
|
elements.tagStatus.textContent = result.status;
|
||||||
|
renderTagResult(result);
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
elements.tagStatus.textContent = "error";
|
||||||
|
elements.tagOutput.innerHTML = `<div class="action"><h3>${pill("error", "block")} Source release failed</h3><p>${escapeHtml(error.message)}</p></div>`;
|
||||||
|
} finally {
|
||||||
|
setBusy([elements.previewReleaseTags, elements.createReleaseTags, elements.publishReleaseTags], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTagResult(result) {
|
||||||
|
const rows = Array.isArray(result.repositories) ? result.repositories : [];
|
||||||
|
if (!rows.length) {
|
||||||
|
elements.tagOutput.innerHTML = `<div class="muted">No source release actions returned.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
elements.tagOutput.innerHTML = rows.map((row) => {
|
||||||
|
const kind = row.status === "blocked" || row.status === "failed" ? "block" : row.status === "published" || row.status === "tagged" || row.status === "planned" ? "ok" : "warn";
|
||||||
|
const commands = [row.create_command, row.publish_command]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((command) => `<pre>${escapeHtml(command)}</pre>`)
|
||||||
|
.join("");
|
||||||
|
const output = [row.stdout, row.stderr].filter(Boolean).map((value) => `<pre>${escapeHtml(value)}</pre>`).join("");
|
||||||
|
const meta = [
|
||||||
|
row.tag,
|
||||||
|
row.branch ? `branch ${row.branch}` : "",
|
||||||
|
row.head ? `HEAD ${row.head}` : "",
|
||||||
|
row.remote ? `remote ${row.remote}` : "",
|
||||||
|
].filter(Boolean).join(" | ");
|
||||||
|
return `<div class="action">
|
||||||
|
<h3>${pill(row.status, kind)} ${escapeHtml(row.repo)}</h3>
|
||||||
|
<p>${escapeHtml(row.detail || "")}</p>
|
||||||
|
${meta ? `<p class="action-meta">${escapeHtml(meta)}</p>` : ""}
|
||||||
|
${commands}
|
||||||
|
${output}
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
async function syncSelectedRepos(options) {
|
async function syncSelectedRepos(options) {
|
||||||
const repos = selectedRepoNames();
|
const repos = selectedRepoNames();
|
||||||
if (!repos.length) {
|
if (!repos.length) {
|
||||||
@@ -1738,7 +1826,18 @@
|
|||||||
const steps = Array.isArray(result.steps)
|
const steps = Array.isArray(result.steps)
|
||||||
? result.steps.map((step) => `<div class="action"><h3>${pill(step.status, step.status === "blocked" ? "block" : step.status === "planned" ? "warn" : "ok")} ${escapeHtml(step.title)}</h3><p>${escapeHtml(step.detail)}</p>${step.command ? `<pre>${escapeHtml(step.command)}</pre>` : ""}</div>`).join("")
|
? result.steps.map((step) => `<div class="action"><h3>${pill(step.status, step.status === "blocked" ? "block" : step.status === "planned" ? "warn" : "ok")} ${escapeHtml(step.title)}</h3><p>${escapeHtml(step.detail)}</p>${step.command ? `<pre>${escapeHtml(step.command)}</pre>` : ""}</div>`).join("")
|
||||||
: "";
|
: "";
|
||||||
elements.workflowOutput.innerHTML = `<div class="action"><h3>${escapeHtml(title)}</h3>${lines.join("")}${changes}</div>${steps}`;
|
const notes = Array.isArray(result.notes)
|
||||||
|
? result.notes.map((note) => `<p>${escapeHtml(note)}</p>`).join("")
|
||||||
|
: "";
|
||||||
|
const resultKind = result.status === "blocked" || result.status === "failed" || result.status === "partial"
|
||||||
|
? "block"
|
||||||
|
: result.status === "ready" || result.status === "published" || result.status === "applied"
|
||||||
|
? "ok"
|
||||||
|
: "warn";
|
||||||
|
const noteDetails = notes
|
||||||
|
? `<div class="action"><details open><summary>Validation and provenance details</summary>${notes}</details></div>`
|
||||||
|
: "";
|
||||||
|
elements.workflowOutput.innerHTML = `<div class="action"><h3>${pill(result.status || "result", resultKind)} ${escapeHtml(title)}</h3>${lines.join("")}${changes}</div>${noteDetails}${steps}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBusy(buttons, busy) {
|
function setBusy(buttons, busy) {
|
||||||
|
|||||||
Reference in New Issue
Block a user