|
55 | 55 | ), |
56 | 56 | ] |
57 | 57 |
|
| 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 | + |
58 | 102 |
|
59 | 103 | def main() -> None: |
60 | 104 | """Run the migration steps.""" |
61 | 105 | # Add a separation line like this one after each migration step. |
62 | 106 | print("=" * 72) |
| 107 | + print("Updating cookiecutter replay file...") |
| 108 | + migrate_cookiecutter_replay_file() |
| 109 | + print("=" * 72) |
63 | 110 | print("Updating generated CI workflows...") |
64 | 111 | migrate_ci_workflows() |
65 | 112 | print("=" * 72) |
@@ -96,6 +143,8 @@ def main() -> None: |
96 | 143 |
|
97 | 144 | def migrate_ci_workflows() -> None: |
98 | 145 | """Update the generated CI workflows to the latest template.""" |
| 146 | + private_repo = infer_private_repo() |
| 147 | + |
99 | 148 | _migrate_workflow_file( |
100 | 149 | Path(".github/workflows/ci-pr.yaml"), |
101 | 150 | [ |
@@ -123,6 +172,11 @@ def migrate_ci_workflows() -> None: |
123 | 172 | ], |
124 | 173 | description="updated CI pull-request workflow", |
125 | 174 | ) |
| 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 | + ) |
126 | 180 |
|
127 | 181 | _migrate_workflow_file( |
128 | 182 | Path(".github/workflows/ci.yaml"), |
@@ -252,6 +306,11 @@ def migrate_ci_workflows() -> None: |
252 | 306 | ], |
253 | 307 | description="updated main CI workflow", |
254 | 308 | ) |
| 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 | + ) |
255 | 314 |
|
256 | 315 |
|
257 | 316 | def migrate_dependabot_workflows() -> None: |
@@ -326,6 +385,8 @@ def migrate_dependabot_workflows() -> None: |
326 | 385 |
|
327 | 386 | def migrate_auxiliary_workflows() -> None: |
328 | 387 | """Update the remaining generated GitHub workflows.""" |
| 388 | + private_repo = infer_private_repo() |
| 389 | + |
329 | 390 | _migrate_workflow_file( |
330 | 391 | Path(".github/workflows/dco-merge-queue.yml"), |
331 | 392 | [ |
@@ -360,6 +421,11 @@ def migrate_auxiliary_workflows() -> None: |
360 | 421 | _RELEASE_NOTES_CHECK_REPLACEMENTS, |
361 | 422 | description="updated release notes check workflow", |
362 | 423 | ) |
| 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 | + ) |
363 | 429 |
|
364 | 430 |
|
365 | 431 | def remove_cross_arch_files() -> None: |
@@ -611,6 +677,107 @@ def _migrate_workflow_file( |
611 | 677 | ) |
612 | 678 |
|
613 | 679 |
|
| 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 | + |
614 | 781 | def apply_patch(patch_content: str) -> None: |
615 | 782 | """Apply a patch using the patch utility.""" |
616 | 783 | subprocess.run(["patch", "-p1"], input=patch_content.encode(), check=True) |
@@ -858,6 +1025,102 @@ def read_cookiecutter_str_var(name: str) -> str | None: |
858 | 1025 | return value |
859 | 1026 |
|
860 | 1027 |
|
| 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 | + |
861 | 1124 | def manual_step(message: str) -> None: |
862 | 1125 | """Print a manual step message in yellow.""" |
863 | 1126 | _manual_steps.append(message) |
|
0 commit comments