feat(release): gate tags on scoped workflows
This commit is contained in:
@@ -65,7 +65,10 @@ 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
|
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.
|
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
|
The local and remote annotated tag objects must also be identical, not merely
|
||||||
point at the same commit.
|
point at the same commit. Before any source tag is created, the console loads
|
||||||
|
the cross-repository module registry. This release gate also rejects every
|
||||||
|
user-facing workflow documentation topic that has no scope condition, or has
|
||||||
|
an alternative condition without `required_scopes` or `any_scopes`.
|
||||||
|
|
||||||
The catalog workflow panel can also operate on the same selected rows:
|
The catalog workflow panel can also operate on the same selected rows:
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ class ReleaseEntrypointGateTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_lockstep_release_runs_source_and_full_gates_before_remote_push(self) -> None:
|
def test_lockstep_release_runs_source_and_full_gates_before_remote_push(self) -> None:
|
||||||
script = (META_ROOT / "tools" / "release" / "push-release-tag.sh").read_text()
|
script = (META_ROOT / "tools" / "release" / "push-release-tag.sh").read_text()
|
||||||
workflow = script.rsplit("\nconfirm_release\n", 1)[1]
|
manifest_gate = script.index("run_manifest_shape_gate\n\nconfirm_release")
|
||||||
|
confirm = script.index("\nconfirm_release\n", manifest_gate)
|
||||||
|
workflow = script[confirm:]
|
||||||
|
|
||||||
source_gate = workflow.index("run_version_alignment_gate source")
|
source_gate = workflow.index("run_version_alignment_gate source")
|
||||||
first_commit = workflow.index('run git -C "$repo" commit')
|
first_commit = workflow.index('run git -C "$repo" commit')
|
||||||
@@ -36,6 +38,7 @@ class ReleaseEntrypointGateTests(unittest.TestCase):
|
|||||||
self.assertLess(first_commit, lock_generation)
|
self.assertLess(first_commit, lock_generation)
|
||||||
self.assertLess(lock_generation, full_gate)
|
self.assertLess(lock_generation, full_gate)
|
||||||
self.assertLess(full_gate, first_push)
|
self.assertLess(full_gate, first_push)
|
||||||
|
self.assertLess(manifest_gate, confirm)
|
||||||
|
|
||||||
def test_source_catalog_generator_enforces_explicit_repo_versions(self) -> None:
|
def test_source_catalog_generator_enforces_explicit_repo_versions(self) -> None:
|
||||||
script = (META_ROOT / "tools" / "release" / "generate-release-catalog.py").read_text()
|
script = (META_ROOT / "tools" / "release" / "generate-release-catalog.py").read_text()
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
|||||||
name="govoplan-core",
|
name="govoplan-core",
|
||||||
version="0.1.10",
|
version="0.1.10",
|
||||||
)
|
)
|
||||||
|
self.manifest_repo, self.manifest_remote = create_release_repo(
|
||||||
|
root=self.root,
|
||||||
|
workspace=self.workspace,
|
||||||
|
name="govoplan-access",
|
||||||
|
version="0.1.10",
|
||||||
|
)
|
||||||
|
add_scoped_workflow_manifest(self.manifest_repo)
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
self.temporary.cleanup()
|
self.temporary.cleanup()
|
||||||
@@ -95,13 +102,42 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
|||||||
self.assertIn("worktree is not clean", dirty["repositories"][0]["detail"])
|
self.assertIn("worktree is not clean", dirty["repositories"][0]["detail"])
|
||||||
self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
|
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:
|
def test_user_workflow_scope_contract_blocks_source_tagging(self) -> None:
|
||||||
access, access_remote = create_release_repo(
|
replace_with_unscoped_workflow_manifest(self.manifest_repo)
|
||||||
|
|
||||||
|
result = tag_repositories(
|
||||||
|
repos=("govoplan-core",),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "blocked")
|
||||||
|
self.assertIn("scope-conditioned alternative", result["repositories"][0]["detail"])
|
||||||
|
self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_empty_manifest_workspace_blocks_source_tagging(self) -> None:
|
||||||
|
workspace = self.root / "empty-manifest-workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
core, _ = create_release_repo(
|
||||||
root=self.root,
|
root=self.root,
|
||||||
workspace=self.workspace,
|
workspace=workspace,
|
||||||
name="govoplan-access",
|
name="govoplan-core",
|
||||||
version="0.1.10",
|
version="0.1.10",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result = tag_repositories(
|
||||||
|
repos=("govoplan-core",),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10"},
|
||||||
|
workspace_root=workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "blocked")
|
||||||
|
self.assertIn("No module manifests found", result["repositories"][0]["detail"])
|
||||||
|
self.assertFalse(ref_exists(core, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_batch_preflight_blocks_every_mutation_when_a_later_repo_is_dirty(self) -> None:
|
||||||
|
access = self.manifest_repo
|
||||||
|
access_remote = self.manifest_remote
|
||||||
(access / "uncommitted.txt").write_text("not reviewed\n", encoding="utf-8")
|
(access / "uncommitted.txt").write_text("not reviewed\n", encoding="utf-8")
|
||||||
|
|
||||||
result = tag_repositories(
|
result = tag_repositories(
|
||||||
@@ -183,6 +219,86 @@ def create_release_repo(*, root: Path, workspace: Path, name: str, version: str)
|
|||||||
return repo, remote
|
return repo, remote
|
||||||
|
|
||||||
|
|
||||||
|
def add_scoped_workflow_manifest(repo: Path) -> None:
|
||||||
|
package = repo / "src" / "govoplan_access"
|
||||||
|
backend = package / "backend"
|
||||||
|
backend.mkdir(parents=True)
|
||||||
|
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
(backend / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
(backend / "manifest.py").write_text(
|
||||||
|
"""from govoplan_core.core.modules import DocumentationCondition, DocumentationTopic, ModuleManifest, PermissionDefinition
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest():
|
||||||
|
return ModuleManifest(
|
||||||
|
id="access",
|
||||||
|
name="Access",
|
||||||
|
version="0.1.10",
|
||||||
|
permissions=(
|
||||||
|
PermissionDefinition(
|
||||||
|
scope="access:item:read",
|
||||||
|
module_id="access",
|
||||||
|
resource="item",
|
||||||
|
action="read",
|
||||||
|
label="Read items",
|
||||||
|
description="Read example items.",
|
||||||
|
category="Example",
|
||||||
|
level="tenant",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="access.workflow.scoped",
|
||||||
|
title="Scoped task",
|
||||||
|
summary="This is a valid release fixture.",
|
||||||
|
documentation_types=("user",),
|
||||||
|
conditions=(DocumentationCondition(required_scopes=("access:item:read",)),),
|
||||||
|
metadata={"kind": "workflow"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with (repo / "pyproject.toml").open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(
|
||||||
|
'\n[project.entry-points."govoplan.modules"]\n'
|
||||||
|
'access = "govoplan_access.backend.manifest:get_manifest"\n'
|
||||||
|
)
|
||||||
|
git(repo, "add", "pyproject.toml", "src")
|
||||||
|
git(repo, "commit", "-m", "Add scoped workflow manifest")
|
||||||
|
git(repo, "push", "origin", "main")
|
||||||
|
|
||||||
|
|
||||||
|
def replace_with_unscoped_workflow_manifest(repo: Path) -> None:
|
||||||
|
manifest = repo / "src" / "govoplan_access" / "backend" / "manifest.py"
|
||||||
|
manifest.write_text(
|
||||||
|
"""from govoplan_core.core.modules import DocumentationTopic, ModuleManifest
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest():
|
||||||
|
return ModuleManifest(
|
||||||
|
id="access",
|
||||||
|
name="Access",
|
||||||
|
version="0.1.10",
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="access.workflow.unscoped",
|
||||||
|
title="Unscoped task",
|
||||||
|
summary="This must block release.",
|
||||||
|
documentation_types=("user",),
|
||||||
|
metadata={"kind": "workflow"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
git(repo, "add", str(manifest.relative_to(repo)))
|
||||||
|
git(repo, "commit", "-m", "Replace scoped workflow with unscoped workflow")
|
||||||
|
git(repo, "push", "origin", "main")
|
||||||
|
|
||||||
|
|
||||||
def git_text(cwd: Path, *args: str) -> str:
|
def git_text(cwd: Path, *args: str) -> str:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
("git", *args),
|
("git", *args),
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
from .git_state import collect_repository_snapshot, git_text
|
from .git_state import collect_repository_snapshot, git_text
|
||||||
from .model import RepositorySnapshot
|
from .model import RepositorySnapshot
|
||||||
from .repository_push import command_text, compact_output
|
from .repository_push import command_text, compact_output
|
||||||
from .version_alignment import repository_version_issues
|
from .version_alignment import repository_version_issues
|
||||||
from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root
|
from .workspace import META_ROOT, load_repository_specs, resolve_repo_path, resolve_workspace_root
|
||||||
|
|
||||||
|
|
||||||
_RELEASE_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
_RELEASE_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
||||||
@@ -75,6 +77,24 @@ def tag_repositories(
|
|||||||
"detail": "batch preflight failed; no selected repository was mutated",
|
"detail": "batch preflight failed; no selected repository was mutated",
|
||||||
"repositories": blocked_rows,
|
"repositories": blocked_rows,
|
||||||
}
|
}
|
||||||
|
elif selected:
|
||||||
|
manifest_gate_issue = manifest_shape_gate_issue(workspace)
|
||||||
|
if manifest_gate_issue:
|
||||||
|
return {
|
||||||
|
"status": "blocked",
|
||||||
|
"apply": False,
|
||||||
|
"push": push,
|
||||||
|
"remote": remote,
|
||||||
|
"detail": "release manifest validation failed",
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"repo": repo,
|
||||||
|
"status": "blocked",
|
||||||
|
"detail": manifest_gate_issue,
|
||||||
|
}
|
||||||
|
for repo in selected
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
for repo in selected:
|
for repo in selected:
|
||||||
version = normalize_version(repo_versions.get(repo))
|
version = normalize_version(repo_versions.get(repo))
|
||||||
@@ -352,6 +372,20 @@ def basic_blocker(*, snapshot: RepositorySnapshot, version: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_shape_gate_issue(workspace: Path) -> str | None:
|
||||||
|
command = (
|
||||||
|
sys.executable,
|
||||||
|
str(META_ROOT / "tools" / "checks" / "check-manifest-shapes.py"),
|
||||||
|
"--workspace-root",
|
||||||
|
str(workspace),
|
||||||
|
)
|
||||||
|
result = run(command, cwd=META_ROOT, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
|
||||||
|
if result.returncode == 0:
|
||||||
|
return None
|
||||||
|
detail = compact_output(result.stderr) or compact_output(result.stdout)
|
||||||
|
return f"manifest/documentation release gate failed: {detail or 'unknown validation error'}"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class RemoteTagResult:
|
class RemoteTagResult:
|
||||||
commit: str | None = None
|
commit: str | None = None
|
||||||
|
|||||||
@@ -493,6 +493,13 @@ run_version_alignment_gate() {
|
|||||||
run "${command[@]}"
|
run "${command[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
run_manifest_shape_gate() {
|
||||||
|
run env PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
"$PYTHON" \
|
||||||
|
"$META_ROOT/tools/checks/check-manifest-shapes.py" \
|
||||||
|
--workspace-root "$PARENT"
|
||||||
|
}
|
||||||
|
|
||||||
run_migration_release_audit() {
|
run_migration_release_audit() {
|
||||||
local audit_script="$META_ROOT/tools/release/release-migration-audit.py"
|
local audit_script="$META_ROOT/tools/release/release-migration-audit.py"
|
||||||
local command=("$PYTHON" "$audit_script" "--track" "release")
|
local command=("$PYTHON" "$audit_script" "--track" "release")
|
||||||
@@ -841,6 +848,7 @@ for repo in "${REPOS[@]}"; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
run_migration_release_audit
|
run_migration_release_audit
|
||||||
|
run_manifest_shape_gate
|
||||||
|
|
||||||
confirm_release
|
confirm_release
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user