Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion afterpython/cli/commands/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 9 additions & 0 deletions afterpython/cli/commands/init_branch_rules.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions afterpython/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
20 changes: 20 additions & 0 deletions afterpython/doc/package_maintenance/ci_cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 🚧

Expand Down
5 changes: 5 additions & 0 deletions afterpython/doc/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
2 changes: 1 addition & 1 deletion afterpython/doc/references/environment_variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
29 changes: 25 additions & 4 deletions afterpython/templates/ci-workflow-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
3 changes: 1 addition & 2 deletions afterpython/tools/_git.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
Expand Down Expand Up @@ -89,7 +89,6 @@ def setup_github_auth():
""")
return False

print("✓ GitHub authentication verified")
return True


Expand Down
100 changes: 100 additions & 0 deletions afterpython/tools/branch_rules.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion afterpython/tools/github_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down