Skip to content

Commit 70a70c1

Browse files
committed
Add migration script step
Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
1 parent 6104b0f commit 70a70c1

1 file changed

Lines changed: 263 additions & 0 deletions

File tree

cookiecutter/migrate.py

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,58 @@
5555
),
5656
]
5757

58+
_SETUP_GIT_DISABLED_BLOCK = (
59+
" # TODO(cookiecutter): Uncomment this for projects with private "
60+
"dependencies\n"
61+
" # with:\n"
62+
" # username: ${{ secrets.GIT_USER }}\n"
63+
" # password: ${{ secrets.GIT_PASS }}\n"
64+
)
65+
66+
_SETUP_GIT_ENABLED_BLOCK = (
67+
" with:\n"
68+
" username: ${{ secrets.GIT_USER }}\n"
69+
" password: ${{ secrets.GIT_PASS }}\n"
70+
)
71+
72+
_NOX_PRIVATE_DEPS_DISABLED_BLOCK = (
73+
" # TODO(cookiecutter): Uncomment this for projects with private "
74+
"dependencies\n"
75+
" # git-username: ${{ secrets.GIT_USER }}\n"
76+
" # git-password: ${{ secrets.GIT_PASS }}\n"
77+
)
78+
79+
_NOX_PRIVATE_DEPS_ENABLED_BLOCK = (
80+
" git-username: ${{ secrets.GIT_USER }}\n"
81+
" git-password: ${{ secrets.GIT_PASS }}\n"
82+
)
83+
84+
_RELEASE_NOTES_TOKEN_DISABLED_BLOCK = (
85+
" # TODO(cookiecutter): Uncomment the following line for private "
86+
"repositories, otherwise remove it\n"
87+
" # token: ${{ secrets.github_token }}\n"
88+
)
89+
90+
_RELEASE_NOTES_TOKEN_ENABLED_BLOCK = " token: ${{ secrets.github_token }}\n"
91+
92+
_CREATE_GITHUB_RELEASE_PUBLIC_NEEDS = ' needs: ["publish-docs"]\n'
93+
_CREATE_GITHUB_RELEASE_PRIVATE_NEEDS = (
94+
' needs: ["nox-all", "test-installation-all"]\n'
95+
)
96+
97+
_PRIVATE_REPO_COOKIECUTTER_OPTIONS = [
98+
"{{ 'no' if cookiecutter.license == 'MIT' else 'yes' }}",
99+
"{{ 'yes' if cookiecutter.license == 'MIT' else 'no' }}",
100+
]
101+
58102

59103
def main() -> None:
60104
"""Run the migration steps."""
61105
# Add a separation line like this one after each migration step.
62106
print("=" * 72)
107+
print("Updating cookiecutter replay file...")
108+
migrate_cookiecutter_replay_file()
109+
print("=" * 72)
63110
print("Updating generated CI workflows...")
64111
migrate_ci_workflows()
65112
print("=" * 72)
@@ -96,6 +143,8 @@ def main() -> None:
96143

97144
def migrate_ci_workflows() -> None:
98145
"""Update the generated CI workflows to the latest template."""
146+
private_repo = infer_private_repo()
147+
99148
_migrate_workflow_file(
100149
Path(".github/workflows/ci-pr.yaml"),
101150
[
@@ -123,6 +172,11 @@ def migrate_ci_workflows() -> None:
123172
],
124173
description="updated CI pull-request workflow",
125174
)
175+
update_workflow_private_repo_settings(
176+
Path(".github/workflows/ci-pr.yaml"),
177+
private_repo=private_repo,
178+
description="adjusted CI pull-request privacy settings",
179+
)
126180

127181
_migrate_workflow_file(
128182
Path(".github/workflows/ci.yaml"),
@@ -252,6 +306,11 @@ def migrate_ci_workflows() -> None:
252306
],
253307
description="updated main CI workflow",
254308
)
309+
update_workflow_private_repo_settings(
310+
Path(".github/workflows/ci.yaml"),
311+
private_repo=private_repo,
312+
description="adjusted main CI privacy settings",
313+
)
255314

256315

257316
def migrate_dependabot_workflows() -> None:
@@ -326,6 +385,8 @@ def migrate_dependabot_workflows() -> None:
326385

327386
def migrate_auxiliary_workflows() -> None:
328387
"""Update the remaining generated GitHub workflows."""
388+
private_repo = infer_private_repo()
389+
329390
_migrate_workflow_file(
330391
Path(".github/workflows/dco-merge-queue.yml"),
331392
[
@@ -360,6 +421,11 @@ def migrate_auxiliary_workflows() -> None:
360421
_RELEASE_NOTES_CHECK_REPLACEMENTS,
361422
description="updated release notes check workflow",
362423
)
424+
update_workflow_private_repo_settings(
425+
Path(".github/workflows/release-notes-check.yml"),
426+
private_repo=private_repo,
427+
description="adjusted release notes privacy settings",
428+
)
363429

364430

365431
def remove_cross_arch_files() -> None:
@@ -611,6 +677,107 @@ def _migrate_workflow_file(
611677
)
612678

613679

680+
def _set_optional_block(
681+
content: str, *, disabled: str, enabled: str, include_enabled: bool
682+
) -> str:
683+
"""Set a generated optional workflow block to the desired state."""
684+
if include_enabled:
685+
return content.replace(disabled, enabled)
686+
return content.replace(disabled, "").replace(enabled, "")
687+
688+
689+
def _remove_job(content: str, job_name: str) -> str:
690+
"""Remove a top-level workflow job by name."""
691+
pattern = re.compile(
692+
rf"^ {re.escape(job_name)}:\n.*?(?=^ [a-z0-9-]+:|\Z)",
693+
re.MULTILINE | re.DOTALL,
694+
)
695+
return pattern.sub("", content)
696+
697+
698+
def _adjust_ci_workflow_private_repo_settings(
699+
content: str, *, private_repo: bool
700+
) -> str:
701+
"""Adjust CI workflow sections that depend on repository privacy."""
702+
content = _set_optional_block(
703+
content,
704+
disabled=_SETUP_GIT_DISABLED_BLOCK,
705+
enabled=_SETUP_GIT_ENABLED_BLOCK,
706+
include_enabled=private_repo,
707+
)
708+
content = _set_optional_block(
709+
content,
710+
disabled=_NOX_PRIVATE_DEPS_DISABLED_BLOCK,
711+
enabled=_NOX_PRIVATE_DEPS_ENABLED_BLOCK,
712+
include_enabled=private_repo,
713+
)
714+
content = content.replace(
715+
_CREATE_GITHUB_RELEASE_PUBLIC_NEEDS,
716+
(
717+
_CREATE_GITHUB_RELEASE_PRIVATE_NEEDS
718+
if private_repo
719+
else _CREATE_GITHUB_RELEASE_PUBLIC_NEEDS
720+
),
721+
)
722+
content = content.replace(
723+
_CREATE_GITHUB_RELEASE_PRIVATE_NEEDS,
724+
(
725+
_CREATE_GITHUB_RELEASE_PRIVATE_NEEDS
726+
if private_repo
727+
else _CREATE_GITHUB_RELEASE_PUBLIC_NEEDS
728+
),
729+
)
730+
731+
if private_repo:
732+
content = _remove_job(content, "publish-docs")
733+
content = _remove_job(content, "publish-to-pypi")
734+
735+
content = re.sub(r"[ \t]+\n", "\n", content)
736+
content = re.sub(r"\n{3,}", "\n\n", content)
737+
738+
return content
739+
740+
741+
def _adjust_release_notes_check_private_repo_settings(
742+
content: str, *, private_repo: bool
743+
) -> str:
744+
"""Adjust release-notes workflow sections that depend on privacy."""
745+
return _set_optional_block(
746+
content,
747+
disabled=_RELEASE_NOTES_TOKEN_DISABLED_BLOCK,
748+
enabled=_RELEASE_NOTES_TOKEN_ENABLED_BLOCK,
749+
include_enabled=private_repo,
750+
)
751+
752+
753+
def update_workflow_private_repo_settings(
754+
filepath: Path, *, private_repo: bool, description: str
755+
) -> None:
756+
"""Update workflow sections that depend on repository privacy."""
757+
if not filepath.exists():
758+
return
759+
760+
content = _normalize_content(filepath.read_text(encoding="utf-8"))
761+
762+
match filepath.name:
763+
case "ci.yaml" | "ci-pr.yaml":
764+
updated = _adjust_ci_workflow_private_repo_settings(
765+
content, private_repo=private_repo
766+
)
767+
case "release-notes-check.yml":
768+
updated = _adjust_release_notes_check_private_repo_settings(
769+
content, private_repo=private_repo
770+
)
771+
case _:
772+
return
773+
774+
if updated == content:
775+
return
776+
777+
replace_file_atomically(filepath, updated)
778+
print(f" Updated {filepath}: {description}")
779+
780+
614781
def apply_patch(patch_content: str) -> None:
615782
"""Apply a patch using the patch utility."""
616783
subprocess.run(["patch", "-p1"], input=patch_content.encode(), check=True)
@@ -858,6 +1025,102 @@ def read_cookiecutter_str_var(name: str) -> str | None:
8581025
return value
8591026

8601027

1028+
def insert_key_after(
1029+
data: dict[str, Any], *, after_key: str, key: str, value: Any
1030+
) -> dict[str, Any]:
1031+
"""Insert or update a key immediately after another key in a dict."""
1032+
if key in data:
1033+
data[key] = value
1034+
return data
1035+
1036+
updated: dict[str, Any] = {}
1037+
inserted = False
1038+
for existing_key, existing_value in data.items():
1039+
updated[existing_key] = existing_value
1040+
if existing_key == after_key:
1041+
updated[key] = value
1042+
inserted = True
1043+
1044+
if not inserted:
1045+
updated[key] = value
1046+
1047+
return updated
1048+
1049+
1050+
def migrate_cookiecutter_replay_file() -> None:
1051+
"""Add new template inputs to the stored cookiecutter replay file."""
1052+
replay_path = Path(".cookiecutter-replay.json")
1053+
if not replay_path.exists():
1054+
print(f" Skipped {replay_path}: file not found")
1055+
return
1056+
1057+
try:
1058+
data = json.loads(replay_path.read_text(encoding="utf-8"))
1059+
except (json.JSONDecodeError, OSError) as error:
1060+
manual_step(
1061+
f"Could not update {replay_path}. Please add the new `private_repo` "
1062+
f"variable manually ({error})."
1063+
)
1064+
return
1065+
1066+
cookiecutter_data = data.get("cookiecutter")
1067+
if not isinstance(cookiecutter_data, dict):
1068+
manual_step(
1069+
f"Could not update {replay_path}: missing `cookiecutter` object. "
1070+
"Please add `private_repo` manually."
1071+
)
1072+
return
1073+
1074+
private_repo_value = "yes" if infer_private_repo() else "no"
1075+
updated_cookiecutter = insert_key_after(
1076+
cookiecutter_data,
1077+
after_key="license",
1078+
key="private_repo",
1079+
value=private_repo_value,
1080+
)
1081+
data["cookiecutter"] = updated_cookiecutter
1082+
1083+
replay_template_data = data.get("_cookiecutter")
1084+
if isinstance(replay_template_data, dict):
1085+
data["_cookiecutter"] = insert_key_after(
1086+
replay_template_data,
1087+
after_key="license",
1088+
key="private_repo",
1089+
value=_PRIVATE_REPO_COOKIECUTTER_OPTIONS,
1090+
)
1091+
1092+
new_content = json.dumps(data, indent=2)
1093+
if not new_content.endswith("\n"):
1094+
new_content += "\n"
1095+
1096+
normalized_old = _normalize_content(replay_path.read_text(encoding="utf-8"))
1097+
if new_content == normalized_old:
1098+
print(f" Skipped {replay_path}: already has the expected updates")
1099+
return
1100+
1101+
replace_file_atomically(replay_path, new_content)
1102+
print(f" Updated {replay_path}: added `private_repo` replay data")
1103+
1104+
1105+
def infer_private_repo() -> bool:
1106+
"""Infer whether the generated repository should be treated as private."""
1107+
if private_repo := read_cookiecutter_str_var("private_repo"):
1108+
return private_repo == "yes"
1109+
1110+
if license_name := read_cookiecutter_str_var("license"):
1111+
return license_name != "MIT"
1112+
1113+
pyproject_path = Path("pyproject.toml")
1114+
if pyproject_path.exists():
1115+
pyproject_content = pyproject_path.read_text(encoding="utf-8")
1116+
if 'license = "LicenseRef-Proprietary"' in pyproject_content:
1117+
return True
1118+
if 'license = "MIT"' in pyproject_content:
1119+
return False
1120+
1121+
return False
1122+
1123+
8611124
def manual_step(message: str) -> None:
8621125
"""Print a manual step message in yellow."""
8631126
_manual_steps.append(message)

0 commit comments

Comments
 (0)