Skip to content

Commit ace7c0f

Browse files
committed
Handle private repos in workflow migration
Treat the docs and PyPI publish patterns in ci.yaml as optional when the repository appears to be private, so migrations do not fail just because those jobs were removed intentionally. When those publish jobs are still present in a private repository, warn with a manual step so maintainers can remove them after applying the workflow updates. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
1 parent d9fa53d commit ace7c0f

2 files changed

Lines changed: 267 additions & 1 deletion

File tree

cookiecutter/migrate.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@
5555
]
5656

5757

58+
_PRIVATE_REPO_CI_OPTIONAL_PATTERNS = {
59+
" permissions:\n contents: write\n",
60+
" python -m frequenz.repo.config.cli.version.mike.info\n",
61+
" run: |\n"
62+
' mike deploy --update-aliases --title "$TITLE" "$VERSION" '
63+
"$ALIASES\n",
64+
" python -m frequenz.repo.config.cli.version.mike.sort versions.json\n",
65+
}
66+
67+
5868
def main() -> None:
5969
"""Run the migration steps."""
6070
# Add a separation line like this one after each migration step.
@@ -92,6 +102,8 @@ def main() -> None:
92102

93103
def migrate_ci_workflows() -> None:
94104
"""Update the generated CI workflows to the latest template."""
105+
is_private_repo = has_private_repo_indicators()
106+
95107
_migrate_workflow_file(
96108
Path(".github/workflows/ci-pr.yaml"),
97109
[
@@ -120,8 +132,15 @@ def migrate_ci_workflows() -> None:
120132
description="updated CI pull-request workflow",
121133
)
122134

135+
ci_workflow = Path(".github/workflows/ci.yaml")
136+
private_ci_publish_jobs: list[str] = []
137+
if is_private_repo and ci_workflow.exists():
138+
private_ci_publish_jobs = find_ci_publish_jobs(
139+
_normalize_content(ci_workflow.read_text(encoding="utf-8"))
140+
)
141+
123142
_migrate_workflow_file(
124-
Path(".github/workflows/ci.yaml"),
143+
ci_workflow,
125144
[
126145
(
127146
" workflow_dispatch:\n\nenv:\n",
@@ -247,8 +266,19 @@ def migrate_ci_workflows() -> None:
247266
),
248267
],
249268
description="updated main CI workflow",
269+
ignore_missing_patterns=(
270+
_PRIVATE_REPO_CI_OPTIONAL_PATTERNS if is_private_repo else None
271+
),
250272
)
251273

274+
if is_private_repo and private_ci_publish_jobs:
275+
jobs = ", ".join(f"`{job}`" for job in private_ci_publish_jobs)
276+
manual_step(
277+
f"{ci_workflow} still contains {jobs}. This repository appears to be "
278+
"private, so those publish jobs usually should be removed manually "
279+
"after the migration, even though the workflow was updated."
280+
)
281+
252282

253283
def migrate_dependabot_workflows() -> None:
254284
"""Update the generated Dependabot automation workflows."""
@@ -444,6 +474,7 @@ def _migrate_workflow_file(
444474
replacements: list[tuple[str, str]],
445475
*,
446476
description: str,
477+
ignore_missing_patterns: set[str] | None = None,
447478
) -> None:
448479
"""Apply text replacements to a generated workflow file.
449480
@@ -463,6 +494,12 @@ def _migrate_workflow_file(
463494

464495
updated = _pin_workflow_action_references(content)
465496
updated, missing_patterns = _apply_idempotent_replacements(updated, replacements)
497+
if ignore_missing_patterns:
498+
missing_patterns = [
499+
pattern
500+
for pattern in missing_patterns
501+
if pattern not in ignore_missing_patterns
502+
]
466503

467504
if updated == content:
468505
if not missing_patterns:
@@ -734,6 +771,49 @@ def read_cookiecutter_str_var(name: str) -> str | None:
734771
return value
735772

736773

774+
def has_private_repo_indicators() -> bool:
775+
"""Return whether the repository appears to be private."""
776+
github_org = read_cookiecutter_str_var("github_org")
777+
if github_org is not None and github_org != "frequenz-floss":
778+
return True
779+
780+
license_name = read_cookiecutter_str_var("license")
781+
if license_name == "Proprietary":
782+
return True
783+
784+
pyproject_path = Path("pyproject.toml")
785+
if pyproject_path.exists():
786+
pyproject = _normalize_content(pyproject_path.read_text(encoding="utf-8"))
787+
if 'license = "LicenseRef-Proprietary"' in pyproject:
788+
return True
789+
790+
try:
791+
stdout = subprocess.check_output(
792+
["gh", "repo", "view", "--json", "owner"],
793+
text=True,
794+
stderr=subprocess.PIPE,
795+
)
796+
except (FileNotFoundError, subprocess.CalledProcessError):
797+
return False
798+
799+
try:
800+
info: dict[str, Any] = json.loads(stdout)
801+
owner = info["owner"]["login"]
802+
except (KeyError, TypeError, json.JSONDecodeError):
803+
return False
804+
805+
return owner != "frequenz-floss"
806+
807+
808+
def find_ci_publish_jobs(content: str) -> list[str]:
809+
"""Return CI publish job names found in the workflow content."""
810+
jobs: list[str] = []
811+
for job_name in ("publish-docs", "publish-to-pypi"):
812+
if f"\n {job_name}:\n" in f"\n{content}":
813+
jobs.append(job_name)
814+
return jobs
815+
816+
737817
def manual_step(message: str) -> None:
738818
"""Print a manual step message in yellow."""
739819
_manual_steps.append(message)

tests/test_migrate.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# License: MIT
2+
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for the cookiecutter migration script."""
5+
6+
import importlib.util
7+
import json
8+
from pathlib import Path
9+
from types import ModuleType
10+
11+
12+
def _load_migrate_module() -> ModuleType:
13+
"""Load the migration script as a module."""
14+
module_path = Path(__file__).resolve().parents[1] / "cookiecutter" / "migrate.py"
15+
spec = importlib.util.spec_from_file_location("cookiecutter_migrate", module_path)
16+
assert spec is not None
17+
assert spec.loader is not None
18+
19+
module = importlib.util.module_from_spec(spec)
20+
spec.loader.exec_module(module)
21+
return module
22+
23+
24+
def test_migrate_workflow_file_adds_missing_release_notes_permissions(
25+
tmp_path: Path,
26+
) -> None:
27+
"""Add the release notes permissions block when it is missing."""
28+
migrate = _load_migrate_module()
29+
workflow = tmp_path / "release-notes-check.yml"
30+
workflow.write_text(
31+
"""name: Release Notes Check
32+
33+
on:
34+
merge_group:
35+
pull_request:
36+
types:
37+
- \"opened\"
38+
39+
jobs:
40+
check-release-notes:
41+
name: Check release notes are updated
42+
runs-on: ubuntu-slim
43+
steps:
44+
- name: Check for a release notes update
45+
if: github.event_name == 'pull_request'
46+
uses: brettcannon/check-for-changed-files@871d7b8b5917a4f6f06662e2262e8ffc51dff6d1 # v1.2.1
47+
with:
48+
file-pattern: \"RELEASE_NOTES.md\"
49+
prereq-pattern: \"{proto,py}/**\"
50+
skip-label: \"cmd:skip-release-notes\"
51+
""",
52+
encoding="utf-8",
53+
)
54+
55+
migrate._migrate_workflow_file( # pylint: disable=protected-access
56+
workflow,
57+
migrate._RELEASE_NOTES_CHECK_REPLACEMENTS, # pylint: disable=protected-access
58+
description="updated release notes check workflow",
59+
)
60+
61+
assert workflow.read_text(encoding="utf-8") == (
62+
"name: Release Notes Check\n\n"
63+
"on:\n"
64+
" merge_group:\n"
65+
" pull_request:\n"
66+
" types:\n"
67+
' - "opened"\n\n'
68+
"jobs:\n"
69+
" check-release-notes:\n"
70+
" name: Check release notes are updated\n"
71+
" runs-on: ubuntu-slim\n"
72+
" permissions:\n"
73+
" # Read pull request metadata to evaluate labels and changed files.\n"
74+
" pull-requests: read\n"
75+
" steps:\n"
76+
" - name: Check for a release notes update\n"
77+
" if: github.event_name == 'pull_request'\n"
78+
" uses: brettcannon/check-for-changed-files@871d7b8b5917a4f6f06662e2262e8ffc51dff6d1 # v1.2.1\n"
79+
" with:\n"
80+
' file-pattern: "RELEASE_NOTES.md"\n'
81+
' prereq-pattern: "{proto,py}/**"\n'
82+
' skip-label: "cmd:skip-release-notes"\n'
83+
)
84+
assert migrate._manual_steps == [] # pylint: disable=protected-access
85+
86+
87+
def test_migrate_workflow_file_reports_missing_patterns(tmp_path: Path) -> None:
88+
"""Report the full missing patterns when a workflow cannot be migrated."""
89+
migrate = _load_migrate_module()
90+
workflow = tmp_path / "release-notes-check.yml"
91+
workflow.write_text(
92+
"""name: Release Notes Check
93+
94+
jobs:
95+
check-release-notes:
96+
name: Check release notes are updated
97+
""",
98+
encoding="utf-8",
99+
)
100+
101+
migrate._migrate_workflow_file( # pylint: disable=protected-access
102+
workflow,
103+
migrate._RELEASE_NOTES_CHECK_REPLACEMENTS, # pylint: disable=protected-access
104+
description="updated release notes check workflow",
105+
)
106+
107+
assert len(migrate._manual_steps) == 1 # pylint: disable=protected-access
108+
assert (
109+
f"Could not find the expected pattern(s) in {workflow}."
110+
in migrate._manual_steps[0] # pylint: disable=protected-access
111+
)
112+
assert "Pattern 1:" in migrate._manual_steps[0] # pylint: disable=protected-access
113+
assert "Pattern 2:" in migrate._manual_steps[0] # pylint: disable=protected-access
114+
assert "permissions:" in migrate._manual_steps[0] # pylint: disable=protected-access
115+
assert "runs-on: ubuntu-slim" in migrate._manual_steps[0] # pylint: disable=protected-access
116+
117+
118+
def test_migrate_workflow_file_ignores_private_ci_publish_patterns(
119+
tmp_path: Path,
120+
) -> None:
121+
"""Ignore private-repo CI publish patterns when they are absent."""
122+
migrate = _load_migrate_module()
123+
workflow = tmp_path / "ci.yaml"
124+
workflow.write_text("name: CI\n", encoding="utf-8")
125+
126+
replacements = [
127+
(pattern, f"replacement {index}\n")
128+
for index, pattern in enumerate(
129+
migrate._PRIVATE_REPO_CI_OPTIONAL_PATTERNS, # pylint: disable=protected-access
130+
start=1,
131+
)
132+
]
133+
134+
migrate._migrate_workflow_file( # pylint: disable=protected-access
135+
workflow,
136+
replacements,
137+
description="updated main CI workflow",
138+
ignore_missing_patterns=migrate._PRIVATE_REPO_CI_OPTIONAL_PATTERNS, # pylint: disable=protected-access
139+
)
140+
141+
assert workflow.read_text(encoding="utf-8") == "name: CI\n"
142+
assert migrate._manual_steps == [] # pylint: disable=protected-access
143+
144+
145+
def test_migrate_ci_workflows_warns_about_private_publish_jobs(
146+
tmp_path: Path,
147+
monkeypatch,
148+
) -> None:
149+
"""Warn when a private repository still has CI publish jobs."""
150+
migrate = _load_migrate_module()
151+
template_root = Path(__file__).resolve().parents[1] / "cookiecutter"
152+
workflow_dir = tmp_path / ".github" / "workflows"
153+
workflow_dir.mkdir(parents=True)
154+
workflow_dir.joinpath("ci-pr.yaml").write_text(
155+
(
156+
template_root
157+
/ "{{cookiecutter.github_repo_name}}"
158+
/ ".github"
159+
/ "workflows"
160+
/ "ci-pr.yaml"
161+
).read_text(encoding="utf-8"),
162+
encoding="utf-8",
163+
)
164+
workflow_dir.joinpath("ci.yaml").write_text(
165+
(
166+
template_root
167+
/ "{{cookiecutter.github_repo_name}}"
168+
/ ".github"
169+
/ "workflows"
170+
/ "ci.yaml"
171+
).read_text(encoding="utf-8"),
172+
encoding="utf-8",
173+
)
174+
(tmp_path / ".cookiecutter-replay.json").write_text(
175+
json.dumps({"cookiecutter": {"github_org": "private-org", "license": "MIT"}}),
176+
encoding="utf-8",
177+
)
178+
179+
monkeypatch.chdir(tmp_path)
180+
migrate.migrate_ci_workflows()
181+
182+
assert len(migrate._manual_steps) == 1 # pylint: disable=protected-access
183+
assert (
184+
"still contains `publish-docs`, `publish-to-pypi`" in migrate._manual_steps[0]
185+
) # pylint: disable=protected-access
186+
assert "appears to be private" in migrate._manual_steps[0] # pylint: disable=protected-access

0 commit comments

Comments
 (0)