diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59f5032..f2dcce3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: echo "use_pixi=false" >> $GITHUB_OUTPUT fi - # Job 3: Test Suite - UV Workflow + # Job 3.1: Test Suite - UV Workflow # Traditional Python testing with uv for projects without pixi test-uv: needs: detect-test-strategy @@ -99,7 +99,7 @@ jobs: run: | pytest -v || if [ $? -eq 5 ]; then exit 0; else exit $?; fi - # Job 4: Test Suite - Pixi Workflow + # Job 3.2: Test Suite - Pixi Workflow # Pixi-based testing for projects using pixi.toml test-pixi: needs: detect-test-strategy @@ -136,7 +136,28 @@ jobs: run: | pixi run -e ${{ matrix.environment }} test || if [ $? -eq 5 ]; then exit 0; else exit $?; fi - # Job 5: Build Verification + # Job 3.3: Test Suite - Complete + # Unified test status for branch protection + test: + if: always() + needs: [detect-test-strategy, test-uv, test-pixi] + runs-on: ubuntu-latest + steps: + - name: Check test results + run: | + uv_result="${{ needs.test-uv.result }}" + pixi_result="${{ needs.test-pixi.result }}" + # One should succeed, the other should be skipped + if [ "$uv_result" == "success" ] || [ "$uv_result" == "skipped" ]; then + if [ "$pixi_result" == "success" ] || [ "$pixi_result" == "skipped" ]; then + echo "All tests passed!" + exit 0 + fi + fi + echo "Tests failed: uv=$uv_result, pixi=$pixi_result" + exit 1 + + # Job 4: Build Verification # Ensures the package can be built successfully build: runs-on: ubuntu-latest @@ -172,5 +193,5 @@ jobs: # This ensures the package is usable after installation run: ap --help - # Job 6: Type Check + # Job 5: Type Check # typecheck: diff --git a/README.md b/README.md index 3738151..0888f04 100644 --- a/README.md +++ b/README.md @@ -85,5 +85,5 @@ ap init --- -## *Mission* +## *Our Mission* > Only by making package maintenance and documentation easy can we encourage more Python developers to publish their own packages and grow the ecosystem even further. diff --git a/afterpython/cli/commands/build.py b/afterpython/cli/commands/build.py index dc592ce..3e1c43d 100644 --- a/afterpython/cli/commands/build.py +++ b/afterpython/cli/commands/build.py @@ -77,7 +77,7 @@ def _clean_build_directory(): _check_initialized() _clean_build_directory() - if os.getenv("AP_MOLAB_BADGE", "0") == "1": + if os.getenv("AP_MOLAB_BADGE", "1") == "1": for content_type in CONTENT_TYPES: add_molab_badge_to_jupyter_notebooks(content_type) diff --git a/afterpython/cli/commands/init_branch_rules.py b/afterpython/cli/commands/init_branch_rules.py new file mode 100644 index 0000000..cf8879b --- /dev/null +++ b/afterpython/cli/commands/init_branch_rules.py @@ -0,0 +1,9 @@ +import click + + +@click.command() +def init_branch_rules(): + """Create default branch protection rules for the current repository""" + from afterpython.tools.branch_rules import create_default_branch_rules + + create_default_branch_rules() diff --git a/afterpython/cli/main.py b/afterpython/cli/main.py index 3f97011..3f09209 100644 --- a/afterpython/cli/main.py +++ b/afterpython/cli/main.py @@ -23,6 +23,7 @@ from afterpython.cli.commands.commit import commit from afterpython.cli.commands.bump import bump from afterpython.cli.commands.release import release +from afterpython.cli.commands.init_branch_rules import init_branch_rules @tui(command="tui", help="Open terminal UI") @@ -70,3 +71,4 @@ def afterpython_group(ctx): afterpython_group.add_command(commit) afterpython_group.add_command(bump) afterpython_group.add_command(release) +afterpython_group.add_command(init_branch_rules) diff --git a/afterpython/doc/package_maintenance/ci_cd.md b/afterpython/doc/package_maintenance/ci_cd.md index 55abffe..3e0ff39 100644 --- a/afterpython/doc/package_maintenance/ci_cd.md +++ b/afterpython/doc/package_maintenance/ci_cd.md @@ -22,6 +22,26 @@ Deploys your project website to GitHub Pages. ### `dependabot.yml` (optional) Automatically updates GitHub Actions versions. + +--- +## Branch Protection Rules +To create default branch protection rules: +1. install GitHub CLI + - on macOS: `brew install gh` + - on Linux: https://github.com/cli/cli/releases + - on Windows: https://cli.github.com/ +2. authenticate with GitHub CLI by running `gh auth login` +3. run `ap init-branch-rules`, this will create **3 branch protection rules** (a ruleset named `afterpython-default`) for your `main` branch. + - No Force Pushes + - Prevents overwriting history on the main branch. + - No Branch Deletion + - Protects the main branch from being deleted. + - CI Status Checks (before the branch can be updated) + - Requires all configured CI checks to pass before any update (push or PR merge) is allowed. + +You can view them in **GitHub → Settings → Rules → Rulesets** + + --- ## Security Scanning 🚧 diff --git a/afterpython/doc/quickstart.md b/afterpython/doc/quickstart.md index 665b02e..d06bb49 100644 --- a/afterpython/doc/quickstart.md +++ b/afterpython/doc/quickstart.md @@ -24,6 +24,11 @@ The structure of `afterpython/` is as follows: - `afterpython/example/` - `afterpython/guide/` +:::{note} Default Branch Protection Rules +Default branch protection rules can be created by running `ap init-branch-rules`. See [](package_maintenance/ci_cd.md#branch-protection-rules) for more details. +::: + + --- ## Project Website A project website is basically a website that serves as the **homepage for your project**. diff --git a/afterpython/doc/references/environment_variables.md b/afterpython/doc/references/environment_variables.md index 09dc517..8e4a088 100644 --- a/afterpython/doc/references/environment_variables.md +++ b/afterpython/doc/references/environment_variables.md @@ -5,4 +5,4 @@ --- ## `AP_MOLAB_BADGE` -> Default: `0`. If set to `1`, automatically adds Molab badges to Jupyter notebooks. +> Default: `1`. If set to `1`, automatically adds Molab badges to Jupyter notebooks. diff --git a/afterpython/templates/ci-workflow-template.yml b/afterpython/templates/ci-workflow-template.yml index 59f5032..f2dcce3 100644 --- a/afterpython/templates/ci-workflow-template.yml +++ b/afterpython/templates/ci-workflow-template.yml @@ -61,7 +61,7 @@ jobs: echo "use_pixi=false" >> $GITHUB_OUTPUT fi - # Job 3: Test Suite - UV Workflow + # Job 3.1: Test Suite - UV Workflow # Traditional Python testing with uv for projects without pixi test-uv: needs: detect-test-strategy @@ -99,7 +99,7 @@ jobs: run: | pytest -v || if [ $? -eq 5 ]; then exit 0; else exit $?; fi - # Job 4: Test Suite - Pixi Workflow + # Job 3.2: Test Suite - Pixi Workflow # Pixi-based testing for projects using pixi.toml test-pixi: needs: detect-test-strategy @@ -136,7 +136,28 @@ jobs: run: | pixi run -e ${{ matrix.environment }} test || if [ $? -eq 5 ]; then exit 0; else exit $?; fi - # Job 5: Build Verification + # Job 3.3: Test Suite - Complete + # Unified test status for branch protection + test: + if: always() + needs: [detect-test-strategy, test-uv, test-pixi] + runs-on: ubuntu-latest + steps: + - name: Check test results + run: | + uv_result="${{ needs.test-uv.result }}" + pixi_result="${{ needs.test-pixi.result }}" + # One should succeed, the other should be skipped + if [ "$uv_result" == "success" ] || [ "$uv_result" == "skipped" ]; then + if [ "$pixi_result" == "success" ] || [ "$pixi_result" == "skipped" ]; then + echo "All tests passed!" + exit 0 + fi + fi + echo "Tests failed: uv=$uv_result, pixi=$pixi_result" + exit 1 + + # Job 4: Build Verification # Ensures the package can be built successfully build: runs-on: ubuntu-latest @@ -172,5 +193,5 @@ jobs: # This ensures the package is usable after installation run: ap --help - # Job 6: Type Check + # Job 5: Type Check # typecheck: diff --git a/afterpython/tools/_git.py b/afterpython/tools/_git.py index 5a70b93..0f54d6e 100644 --- a/afterpython/tools/_git.py +++ b/afterpython/tools/_git.py @@ -58,7 +58,7 @@ def get_github_url() -> str | None: return None -def setup_github_auth(): +def is_gh_authenticated(): """Guide user through GitHub authentication.""" if not has_gh(): print(""" @@ -89,7 +89,6 @@ def setup_github_auth(): """) return False - print("✓ GitHub authentication verified") return True diff --git a/afterpython/tools/branch_rules.py b/afterpython/tools/branch_rules.py new file mode 100644 index 0000000..a79b478 --- /dev/null +++ b/afterpython/tools/branch_rules.py @@ -0,0 +1,100 @@ +""" +Branch protection rules for AfterPython projects. +Uses gh CLI to create GitHub rulesets. +""" + +import json +import subprocess +from copy import deepcopy + +from afterpython.tools._git import is_gh_authenticated + + +DEFAULT_RULESET = { + "name": "afterpython-default", # The ruleset identifier in GitHub + "target": "branch", # This ruleset applies to branches (not tags) + "enforcement": "active", # Rules are enforced (vs "disabled" or "evaluate") + "conditions": { # Which branches this ruleset applies to + "ref_name": { + "include": ["refs/heads/main"], # Only protect the 'main' branch + "exclude": [], # No exclusions + } + }, + "rules": [ # Array of protection rules + # Rule 1: No Force Pushes + {"type": "non_fast_forward"}, # Prevents git push --force + # Rule 2: No Branch Deletion + {"type": "deletion"}, # Prevents branch from being deleted + # Rule 3: CI Status Checks + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + {"context": "lint"}, + {"context": "test"}, + {"context": "build"}, + ], + # The PR branch must be up to date with the base branch (main) before merging + "strict_required_status_checks_policy": True, + }, + }, + ], +} + + +def list_rulesets() -> list[dict] | None: + """ + List existing rulesets for the current repository. + + Returns: + List of rulesets or None if error + """ + if not is_gh_authenticated(): + return None + + result = subprocess.run( + ["gh", "api", "repos/{owner}/{repo}/rulesets"], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + print(f"Error listing rulesets: {result.stderr}") + return None + + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + print("Error: Failed to parse rulesets JSON") + return None + + +def create_default_branch_rules(branch: str = "main") -> bool: + if not is_gh_authenticated(): + return False + + # Check if ruleset already exists + existing_rulesets = list_rulesets() + if existing_rulesets: + print(f"Ruleset '{DEFAULT_RULESET['name']}' already exists.") + return False + + # Build payload + payload = deepcopy(DEFAULT_RULESET) + if branch != "main": + payload["conditions"]["ref_name"]["include"] = [f"refs/heads/{branch}"] + + # Create ruleset + result = subprocess.run( + ["gh", "api", "-X", "POST", "repos/{owner}/{repo}/rulesets", "--input", "-"], + input=json.dumps(payload), + capture_output=True, + text=True, + ) + + if result.returncode != 0: + print(f"Error creating ruleset: {result.stderr}") + return False + + print(f"✓ Created branch ruleset '{DEFAULT_RULESET['name']}' for {branch=}") + return True diff --git a/afterpython/tools/github_actions.py b/afterpython/tools/github_actions.py index 793d807..cd14358 100644 --- a/afterpython/tools/github_actions.py +++ b/afterpython/tools/github_actions.py @@ -24,7 +24,7 @@ def _copy_github_template(template_name: str, target_path: Path): ) shutil.copy(template_path, target_path) - click.echo(f"Created {target_path}") + print(f"Created {target_path}") def create_workflow(workflow_name: str):