Skip to content

Commit f0d1492

Browse files
committed
fix(release): gate Registry publish on version changes
1 parent 90c9a6f commit f0d1492

8 files changed

Lines changed: 453 additions & 11 deletions

‎.github/workflows/ci.yml‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,55 @@ jobs:
4949
# SECURITY: reject tracked internal paths and symlinks before the dependent publish job can run.
5050
run: .venv/bin/python scripts/check_public_release_boundary.py
5151

52+
release-version:
53+
name: Detect project version transition
54+
if: >-
55+
${{
56+
github.event_name == 'push' &&
57+
github.ref == 'refs/heads/main' &&
58+
github.repository == 'rookiestar28/ComfyUI-RookieUI'
59+
}}
60+
permissions:
61+
contents: read
62+
runs-on: ubuntu-latest
63+
outputs:
64+
should_publish: ${{ steps.version_gate.outputs.should_publish }}
65+
steps:
66+
- name: Check out release history
67+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
68+
with:
69+
fetch-depth: 0
70+
71+
- name: Set up release classifier Python
72+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
73+
with:
74+
python-version: "3.11"
75+
76+
- name: Detect project version transition
77+
id: version_gate
78+
# SECURITY: keep event identities in environment data; the classifier validates full OIDs
79+
# and invokes Git without a shell before this output can authorize token-bearing work.
80+
env:
81+
BASE_SHA: ${{ github.event.before }}
82+
HEAD_SHA: ${{ github.sha }}
83+
shell: bash
84+
run: >-
85+
python scripts/check_release_version_change.py
86+
--repo .
87+
--base "$BASE_SHA"
88+
--head "$HEAD_SHA"
89+
--github-output "$GITHUB_OUTPUT"
90+
5291
publish-node:
5392
name: Publish Custom Node to registry
5493
# SECURITY: keep publishing in this workflow and gated by successful needs; see https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idneeds
55-
needs: full-test-gate
94+
needs: [full-test-gate, release-version]
5695
if: >-
5796
${{
5897
github.event_name == 'push' &&
5998
github.ref == 'refs/heads/main' &&
60-
github.repository == 'rookiestar28/ComfyUI-RookieUI'
99+
github.repository == 'rookiestar28/ComfyUI-RookieUI' &&
100+
needs.release-version.outputs.should_publish == 'true'
61101
}}
62102
permissions:
63103
contents: read

‎LICENSE‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
1-
 GNU AFFERO GENERAL PUBLIC LICENSE
1+
 GNU AFFERO GENERAL PUBLIC LICENSE
22
Version 3, 19 November 2007
33

4-
Project source copyright notice:
5-
Copyright (c) 2024-2026 rookiestar (GitHub: rooiestar28)
6-
7-
8-
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
4+
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
95
Everyone is permitted to copy and distribute verbatim copies
106
of this license document, but changing it is not allowed.
117

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import json
6+
import re
7+
import subprocess
8+
import sys
9+
import tomllib
10+
from dataclasses import asdict, dataclass
11+
from pathlib import Path
12+
from typing import Sequence
13+
14+
15+
_FULL_OID = re.compile(r"[0-9a-fA-F]{40}")
16+
_SAFE_VERSION = re.compile(r"[0-9A-Za-z][0-9A-Za-z._+!-]{0,127}")
17+
_ZERO_OID = "0" * 40
18+
_PROJECT_FILE = "pyproject.toml"
19+
20+
21+
class ReleaseVersionGateError(ValueError):
22+
def __init__(self, code: str, message: str) -> None:
23+
super().__init__(message)
24+
self.code = code
25+
26+
27+
@dataclass(frozen=True)
28+
class ReleaseVersionDecision:
29+
previous_version: str
30+
current_version: str
31+
should_publish: bool
32+
33+
34+
def _run_git(root: Path, *args: str) -> subprocess.CompletedProcess[bytes]:
35+
# SECURITY: event revisions must stay validated argv values; shell interpolation would turn
36+
# externally supplied workflow context into executable syntax.
37+
return subprocess.run(
38+
["git", *args],
39+
cwd=root,
40+
check=False,
41+
capture_output=True,
42+
)
43+
44+
45+
def _validate_repository(root: Path) -> Path:
46+
resolved = root.resolve()
47+
if not resolved.is_dir():
48+
raise ReleaseVersionGateError("invalid-repository", "Repository root is not a directory.")
49+
50+
completed = _run_git(resolved, "rev-parse", "--show-toplevel")
51+
if completed.returncode != 0:
52+
raise ReleaseVersionGateError("invalid-repository", "Repository root is not a Git worktree.")
53+
try:
54+
discovered = Path(completed.stdout.decode("utf-8", errors="strict").strip()).resolve()
55+
except UnicodeDecodeError as exc:
56+
raise ReleaseVersionGateError("invalid-repository", "Git returned a non-UTF-8 worktree path.") from exc
57+
if discovered != resolved:
58+
raise ReleaseVersionGateError("invalid-repository", "Repository argument must be the Git worktree root.")
59+
return resolved
60+
61+
62+
def _validate_commit(root: Path, revision: str, label: str) -> str:
63+
if revision == _ZERO_OID or _FULL_OID.fullmatch(revision) is None:
64+
raise ReleaseVersionGateError(
65+
"invalid-revision",
66+
f"{label} revision must be a non-zero full Git object id.",
67+
)
68+
normalized = revision.lower()
69+
completed = _run_git(root, "cat-file", "-e", f"{normalized}^{{commit}}")
70+
if completed.returncode != 0:
71+
raise ReleaseVersionGateError("missing-revision", f"{label} revision is not an available commit.")
72+
return normalized
73+
74+
75+
def _read_project_version(root: Path, revision: str, label: str) -> str:
76+
completed = _run_git(root, "cat-file", "blob", f"{revision}:{_PROJECT_FILE}")
77+
if completed.returncode != 0:
78+
raise ReleaseVersionGateError(
79+
"missing-project-metadata",
80+
f"{label} revision does not contain {_PROJECT_FILE}.",
81+
)
82+
try:
83+
document = completed.stdout.decode("utf-8-sig", errors="strict")
84+
except UnicodeDecodeError as exc:
85+
raise ReleaseVersionGateError(
86+
"invalid-project-metadata",
87+
f"{label} {_PROJECT_FILE} is not valid UTF-8.",
88+
) from exc
89+
try:
90+
metadata = tomllib.loads(document)
91+
except tomllib.TOMLDecodeError as exc:
92+
raise ReleaseVersionGateError(
93+
"invalid-project-metadata",
94+
f"{label} {_PROJECT_FILE} is not valid TOML.",
95+
) from exc
96+
97+
project = metadata.get("project")
98+
version = project.get("version") if isinstance(project, dict) else None
99+
if not isinstance(version, str) or _SAFE_VERSION.fullmatch(version) is None:
100+
raise ReleaseVersionGateError(
101+
"invalid-project-version",
102+
f"{label} [project].version must be a non-empty, single-line package version.",
103+
)
104+
return version
105+
106+
107+
def classify_release_version_change(root: Path, base: str, head: str) -> ReleaseVersionDecision:
108+
repository = _validate_repository(root)
109+
base_oid = _validate_commit(repository, base, "base")
110+
head_oid = _validate_commit(repository, head, "head")
111+
previous_version = _read_project_version(repository, base_oid, "base")
112+
current_version = _read_project_version(repository, head_oid, "head")
113+
return ReleaseVersionDecision(
114+
previous_version=previous_version,
115+
current_version=current_version,
116+
should_publish=previous_version != current_version,
117+
)
118+
119+
120+
def _write_github_output(path: Path, decision: ReleaseVersionDecision) -> None:
121+
try:
122+
with path.open("a", encoding="utf-8", newline="\n") as stream:
123+
stream.write(f"should_publish={'true' if decision.should_publish else 'false'}\n")
124+
stream.write(f"previous_version={decision.previous_version}\n")
125+
stream.write(f"current_version={decision.current_version}\n")
126+
except OSError as exc:
127+
raise ReleaseVersionGateError("github-output-write-failed", "Could not write GitHub job output.") from exc
128+
129+
130+
def build_parser() -> argparse.ArgumentParser:
131+
parser = argparse.ArgumentParser(
132+
description="Compare immutable push revisions and authorize Registry publication only for a version change."
133+
)
134+
parser.add_argument("--repo", type=Path, default=Path.cwd(), help="Git worktree root.")
135+
parser.add_argument("--base", required=True, help="Push event base commit (full object id).")
136+
parser.add_argument("--head", required=True, help="Push event head commit (full object id).")
137+
parser.add_argument("--github-output", type=Path, help="Optional GitHub Actions output file.")
138+
return parser
139+
140+
141+
def main(argv: Sequence[str] | None = None) -> int:
142+
args = build_parser().parse_args(argv)
143+
try:
144+
decision = classify_release_version_change(args.repo, args.base, args.head)
145+
if args.github_output is not None:
146+
_write_github_output(args.github_output, decision)
147+
except ReleaseVersionGateError as exc:
148+
print(json.dumps({"status": "error", "error": exc.code}, sort_keys=True))
149+
print(f"[release-version] ERROR: {exc}", file=sys.stderr)
150+
return 2
151+
152+
print(json.dumps({"status": "ok", **asdict(decision)}, sort_keys=True))
153+
return 0
154+
155+
156+
if __name__ == "__main__":
157+
raise SystemExit(main())

‎tests/security/test_supply_chain_workflow_policy.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def test_registry_publish_is_downstream_of_the_exact_ci_gate(self):
4040
text = read_workflow("ci.yml")
4141
publish_job = read_job_block(text, "publish-node")
4242

43-
self.assertIn("needs: full-test-gate", publish_job)
43+
self.assertIn("needs: [full-test-gate, release-version]", publish_job)
4444
self.assertIn("github.event_name == 'push'", publish_job)
4545
self.assertIn("github.ref == 'refs/heads/main'", publish_job)
4646
self.assertIn(
@@ -51,6 +51,29 @@ def test_registry_publish_is_downstream_of_the_exact_ci_gate(self):
5151
self.assertNotIn("actions/download-artifact", publish_job)
5252
self.assertNotRegex(publish_job, re.compile(r"^\s+ref:\s", re.MULTILINE))
5353

54+
def test_registry_publish_requires_an_exact_project_version_transition(self):
55+
text = read_workflow("ci.yml")
56+
release_job = read_job_block(text, "release-version")
57+
publish_job = read_job_block(text, "publish-node")
58+
59+
self.assertIn("outputs:", release_job)
60+
self.assertIn(
61+
"should_publish: ${{ steps.version_gate.outputs.should_publish }}",
62+
release_job,
63+
)
64+
self.assertIn("fetch-depth: 0", release_job)
65+
self.assertIn("python-version: \"3.11\"", release_job)
66+
self.assertIn("BASE_SHA: ${{ github.event.before }}", release_job)
67+
self.assertIn("HEAD_SHA: ${{ github.sha }}", release_job)
68+
self.assertIn("--base \"$BASE_SHA\"", release_job)
69+
self.assertIn("--head \"$HEAD_SHA\"", release_job)
70+
self.assertIn("--github-output \"$GITHUB_OUTPUT\"", release_job)
71+
self.assertIn(
72+
"needs.release-version.outputs.should_publish == 'true'",
73+
publish_job,
74+
)
75+
self.assertNotIn("REGISTRY_ACCESS_TOKEN", release_job)
76+
5477
def test_ci_publish_job_pins_token_bearing_action_and_limits_permissions(self):
5578
publish_job = read_job_block(read_workflow("ci.yml"), "publish-node")
5679

‎tests/test_current_host_contract_lane.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,10 @@ def test_ci_requires_lane_before_full_gate_and_publish(self) -> None:
336336
self.assertIn(lane_marker, workflow)
337337
self.assertLess(workflow.index(full_marker), workflow.index(lane_marker))
338338
self.assertIn(".venv/bin/python scripts/run_current_host_contract_lane.py", workflow)
339-
self.assertRegex(workflow, re.compile(r"publish-node:[\s\S]+needs: full-test-gate"))
339+
self.assertRegex(
340+
workflow,
341+
re.compile(r"publish-node:[\s\S]+needs: \[full-test-gate, release-version\]"),
342+
)
340343

341344

342345
if __name__ == "__main__":

‎tests/test_license_policy.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
import subprocess
5+
import unittest
6+
from pathlib import Path
7+
8+
9+
ROOT = Path(__file__).resolve().parents[1]
10+
OFFICIAL_AGPL_NORMALIZED_SHA256 = (
11+
"0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0" # pragma: allowlist secret
12+
)
13+
14+
15+
class LicensePolicyTests(unittest.TestCase):
16+
def test_license_is_the_unmodified_official_agpl_v3_text(self) -> None:
17+
text = (ROOT / "LICENSE").read_text(encoding="utf-8-sig").replace("\r\n", "\n")
18+
19+
self.assertEqual(
20+
hashlib.sha256(text.encode("utf-8")).hexdigest(),
21+
OFFICIAL_AGPL_NORMALIZED_SHA256,
22+
)
23+
self.assertTrue(text.startswith(" GNU AFFERO GENERAL PUBLIC LICENSE\n"))
24+
self.assertIn(" Version 3, 19 November 2007\n", text)
25+
self.assertIn(" END OF TERMS AND CONDITIONS\n", text)
26+
self.assertNotIn("Project source copyright notice", text)
27+
28+
def test_public_metadata_remains_agpl_and_has_no_misspelled_owner(self) -> None:
29+
readme = (ROOT / "README.md").read_text(encoding="utf-8")
30+
self.assertIn("GNU Affero General Public License v3.0 (AGPL-3.0)", readme)
31+
32+
misspelled_owner = "rooi" + "estar28"
33+
completed = subprocess.run(
34+
["git", "grep", "-n", misspelled_owner, "--", ":(exclude).planning/**", ":(exclude)reference/**"],
35+
cwd=ROOT,
36+
check=False,
37+
capture_output=True,
38+
text=True,
39+
encoding="utf-8",
40+
)
41+
self.assertEqual(completed.returncode, 1, completed.stdout)
42+
43+
44+
if __name__ == "__main__":
45+
unittest.main()

‎tests/test_public_release_boundary.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def test_ci_runs_boundary_after_host_lane_before_publish_job_can_succeed(self) -
9898
)
9999
self.assertLess(workflow.index("Run required current-host contract lane"), workflow.index("Verify public release boundary"))
100100
self.assertIn(".venv/bin/python scripts/check_public_release_boundary.py", workflow)
101-
self.assertIn("needs: full-test-gate", workflow)
101+
self.assertIn("needs: [full-test-gate, release-version]", workflow)
102102

103103
def test_historical_tree_detects_forbidden_content_without_checkout(self) -> None:
104104
root = self._new_repo()

0 commit comments

Comments
 (0)