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
109 changes: 108 additions & 1 deletion scripts/template_repo/push_with_gh.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
# file: scripts/template_repo/push_with_gh.py
# version: 1.0.0
# version: 1.1.0
# guid: a1b2c3d4-e5f6-7890-abcd-ef0123456789

"""Optionally initialize a local git repository and publish it to GitHub using the
Expand All @@ -13,6 +13,13 @@
Safety:
- Does not create submodules or nested repos. Operates only in the target dir.
- No modification of parent repos; uses 'git init' in the target directory only.

Repo settings applied after creation (toggleable with --no-settings):
- Disable merge commits + squash merges (rebase-only)
- Disable Projects + Wiki
- Enable auto-merge, allow_update_branch, delete-branch-on-merge
- Workflow permissions: write + can-approve PRs (lets GH Actions merge)
- Actions permissions: sha_pinning_required = true
"""

from __future__ import annotations
Expand All @@ -29,6 +36,84 @@ def run(cmd: list[str], cwd: Path) -> None:
subprocess.run(cmd, cwd=str(cwd), check=True)


def apply_repo_settings(owner: str, repo: str) -> None:
"""Apply the standard repo-settings preset via the gh CLI.

Each call is independent — if one fails (e.g. an org-level policy
overrides the request), the rest still run. We log failures but don't
abort: settings can always be re-applied with `--settings-only`.
"""
full = f"{owner}/{repo}"

# Repo-level toggles (gh repo edit covers most of these directly).
edit_cmd = [
"gh",
"repo",
"edit",
full,
"--enable-merge-commit=false",
"--enable-squash-merge=false",
"--enable-rebase-merge=true",
"--enable-auto-merge=true",
"--delete-branch-on-merge=true",
"--enable-projects=false",
"--enable-wiki=false",
]
if subprocess.run(edit_cmd, check=False).returncode != 0:
print(f"warning: gh repo edit failed for {full}", file=sys.stderr)

# allow_update_branch is not exposed on `gh repo edit`; PATCH the repo.
patch_cmd = [
"gh",
"api",
"-X",
"PATCH",
f"/repos/{full}",
"-F",
"allow_update_branch=true",
"--silent",
]
if subprocess.run(patch_cmd, check=False).returncode != 0:
print(f"warning: PATCH allow_update_branch failed for {full}", file=sys.stderr)

# Workflow permissions: read+write, can approve PRs (so Actions can merge).
wf_cmd = [
"gh",
"api",
"-X",
"PUT",
f"/repos/{full}/actions/permissions/workflow",
"-F",
"default_workflow_permissions=write",
"-F",
"can_approve_pull_request_reviews=true",
"--silent",
]
if subprocess.run(wf_cmd, check=False).returncode != 0:
print(f"warning: PUT actions/permissions/workflow failed for {full}", file=sys.stderr)

# Require pinned-SHA action refs. This rejects @vN tag refs at run time;
# the scaffold's generated workflows already use SHAs to satisfy it.
perms_cmd = [
"gh",
"api",
"-X",
"PUT",
f"/repos/{full}/actions/permissions",
"-F",
"enabled=true",
"-f",
"allowed_actions=all",
"-F",
"sha_pinning_required=true",
"--silent",
]
if subprocess.run(perms_cmd, check=False).returncode != 0:
print(f"warning: PUT actions/permissions failed for {full}", file=sys.stderr)

print(f"Settings applied to https://github.com/{full}")


def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
description="Initialize git and push to GitHub using gh CLI (no secrets)."
Expand All @@ -49,8 +134,26 @@ def main(argv: list[str]) -> int:
action="store_true",
help="Create the repository as private",
)
parser.add_argument(
"--no-settings",
action="store_true",
help="Skip applying the standard repo-settings preset after create",
)
parser.add_argument(
"--settings-only",
action="store_true",
help=(
"Skip git init / repo create / push; only apply the settings preset "
"to an existing repo. Useful for back-filling settings on repos "
"that were created before this tool was extended."
),
)
args = parser.parse_args(argv)

if args.settings_only:
apply_repo_settings(args.owner, args.repo)
return 0

target = Path(os.path.expanduser(args.target)).resolve()
if not target.exists() or not target.is_dir():
print(
Expand Down Expand Up @@ -139,6 +242,10 @@ def main(argv: list[str]) -> int:
)

print(f"Repository created and pushed: https://github.com/{args.owner}/{args.repo}")

if not args.no_settings:
apply_repo_settings(args.owner, args.repo)

return 0


Expand Down
43 changes: 24 additions & 19 deletions scripts/template_repo/scaffold_template_repo.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
# file: scripts/template_repo/scaffold_template_repo.py
# version: 1.5.0
# version: 1.7.0
# guid: 7f2d3a2e-4b5c-8d9e-0f1a-2b3c4d5e6f70

"""Scaffold a minimal, public-safe template repository to a target directory.
Expand Down Expand Up @@ -1086,14 +1086,17 @@ def plan_docker_overlay(opts: Options) -> list[tuple[Path, str]]:
"""

# Optional pip layer.
# Note: --break-system-packages is Python 3.12+ (PEP 668). Older bases
# (Ubuntu 22.04 ships 3.10) error on the flag. Omit it; on a CI/build
# image, system-pip without the flag is fine because there's no other
# package manager fighting it.
pip_layer = ""
if opts.docker_pip_packages:
# Render as one pip install line; quote each spec to allow git+ URLs.
spec = " ".join(f'"{p}"' for p in opts.docker_pip_packages)
pip_layer = f"""
# --- Extra Python packages (system pip; image is a build/CI runner, not a multi-tenant host) ---
RUN python3 -m pip install --no-cache-dir --break-system-packages --upgrade pip \\
&& python3 -m pip install --no-cache-dir --break-system-packages {spec}
RUN python3 -m pip install --no-cache-dir --upgrade pip \\
&& python3 -m pip install --no-cache-dir {spec}
"""

dockerfile = f"""# file: Dockerfile
Expand Down Expand Up @@ -1211,30 +1214,30 @@ def render_docker_build_workflow(opts: Options) -> str:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
# NOTE: action refs are major-version tags here. The repo's
# pin-actions-to-hashes step (or dependabot) will rewrite each `@vN`
# to a digest-pinned `@<sha> # vN.M.K` on first run. Don't hand-pin
# SHAs at scaffold time — there's no source of truth for "the latest
# known-good SHA" without a network round-trip.
# SHAs below were the latest tagged release at scaffold-version-bump
# time. push_with_gh.py applies sha_pinning_required=true on the new
# repo's Actions permissions, so tag refs would be rejected at run
# time. Dependabot (--with-dependabot) ships PRs to bump these as
# upstream tags ship.
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0

- name: Set up Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0

- name: Log in to GHCR
uses: docker/login-action@v3
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
Expand All @@ -1247,7 +1250,7 @@ def render_docker_build_workflow(opts: Options) -> str:

- name: Build and push
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64
Expand All @@ -1260,7 +1263,7 @@ def render_docker_build_workflow(opts: Options) -> str:
sbom: true

- name: Attest provenance
uses: actions/attest-build-provenance@v1
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.build.outputs.digest }}
Expand Down Expand Up @@ -1343,11 +1346,13 @@ def parse_args(argv: list[str]) -> Options:
)
parser.add_argument(
"--docker-base-image",
default="ghcr.io/catthehacker/ubuntu:full-22.04",
default="ubuntu:22.04",
help=(
"Base image for the Dockerfile when --with-docker is set. "
"Default mirrors a GitHub Actions Ubuntu runner; catthehacker's "
"'full-22.04' is the known-good tag — verify before bumping to 24.04."
"Default is vanilla ubuntu:22.04 (works as a Docker base, runs "
"as root, plain apt-get). Pass ghcr.io/catthehacker/ubuntu:full-22.04 "
"ONLY if you also handle non-root + apt permission quirks — that "
"image is tuned for `act`, not buildx multi-arch builds."
),
)
parser.add_argument(
Expand Down
Loading