feat(release): gate tags on scoped workflows
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 14s
Security Audit / security-audit (push) Failing after 12s

This commit is contained in:
2026-07-22 01:56:18 +02:00
parent 30f9ed152a
commit 8906704dc0
5 changed files with 171 additions and 7 deletions

View File

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

View File

@@ -24,7 +24,9 @@ class ReleaseEntrypointGateTests(unittest.TestCase):
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()
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")
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(lock_generation, full_gate)
self.assertLess(full_gate, first_push)
self.assertLess(manifest_gate, confirm)
def test_source_catalog_generator_enforces_explicit_repo_versions(self) -> None:
script = (META_ROOT / "tools" / "release" / "generate-release-catalog.py").read_text()

View File

@@ -30,6 +30,13 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
name="govoplan-core",
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:
self.temporary.cleanup()
@@ -95,13 +102,42 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
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(
def test_user_workflow_scope_contract_blocks_source_tagging(self) -> None:
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,
workspace=self.workspace,
name="govoplan-access",
workspace=workspace,
name="govoplan-core",
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")
result = tag_repositories(
@@ -183,6 +219,86 @@ def create_release_repo(*, root: Path, workspace: Path, name: str, version: str)
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:
result = subprocess.run(
("git", *args),

View File

@@ -3,15 +3,17 @@
from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
import re
import subprocess
import sys
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
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.-]+)?$")
@@ -75,6 +77,24 @@ def tag_repositories(
"detail": "batch preflight failed; no selected repository was mutated",
"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:
version = normalize_version(repo_versions.get(repo))
@@ -352,6 +372,20 @@ def basic_blocker(*, snapshot: RepositorySnapshot, version: str) -> str | 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)
class RemoteTagResult:
commit: str | None = None

View File

@@ -493,6 +493,13 @@ run_version_alignment_gate() {
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() {
local audit_script="$META_ROOT/tools/release/release-migration-audit.py"
local command=("$PYTHON" "$audit_script" "--track" "release")
@@ -841,6 +848,7 @@ for repo in "${REPOS[@]}"; do
done
run_migration_release_audit
run_manifest_shape_gate
confirm_release