diff --git a/Makefile b/Makefile index 7589711..9eeabce 100644 --- a/Makefile +++ b/Makefile @@ -4,11 +4,11 @@ SHELL := bash test-template-render: test-template-render-github test-template-render-gitlab -# Validate GitHub-scaffolded output and task-runner pruning. +# Validate generated template repository structure. test-template-render-github: @set -euo pipefail; \ out_dir="$$(mktemp -d /tmp/scaf-template-github-XXXXXX)"; \ - copier copy . "$$out_dir" --trust --defaults \ + copier copy . "$$out_dir" --vcs-ref=HEAD --trust --defaults \ -d copier__project_name_raw="Sample GitHub Template" \ -d copier__project_slug="sample_github_template" \ -d copier__description="Sample github generated project" \ @@ -20,22 +20,44 @@ test-template-render-github: -d copier__github_semantic_release_auth="github_token" \ -d copier__enable_secret_scanning=true \ -d copier__task_runner="task"; \ + test -f "$$out_dir/copier.yml"; \ test -f "$$out_dir/README.md"; \ - test -f "$$out_dir/.copier-answers.yml"; \ - test -f "$$out_dir/Taskfile.yml"; \ - test ! -f "$$out_dir/Makefile"; \ - test ! -f "$$out_dir/justfile"; \ test -f "$$out_dir/.github/workflows/template-correctness.yaml"; \ - test -f "$$out_dir/.github/workflows/secret-scan.yaml"; \ - test ! -f "$$out_dir/.github/workflows/semantic-release.yaml"; \ - test ! -f "$$out_dir/.gitlab-ci.yml"; \ + test -f "$$out_dir/.copier-answers.yml"; \ + test -f "$$out_dir/template/README.md"; \ + test -f "$$out_dir/template/.scaf/post-copy.py"; \ + test -f "$$out_dir/template/Taskfile.yml"; \ + test ! -f "$$out_dir/template/Makefile"; \ + test ! -f "$$out_dir/template/justfile"; \ + test -f "$$out_dir/template/.github/workflows/template-correctness.yaml"; \ + test -f "$$out_dir/template/.github/workflows/secret-scan.yaml"; \ + test ! -f "$$out_dir/template/.github/workflows/semantic-release.yaml"; \ + test ! -f "$$out_dir/template/.github/workflows/semantic-pull-request.yaml"; \ + test ! -f "$$out_dir/template/.gitlab-ci.yml"; \ + test ! -f "$$out_dir/template/package.json"; \ + test ! -f "$$out_dir/template/dependencies-init.txt"; \ + test ! -f "$$out_dir/template/dependencies-dev-init.txt"; \ + test -f "$$out_dir/template/{{_copier_conf.answers_file}}"; \ + grep -Fq '{{ copier__project_name }}' "$$out_dir/template/README.md"; \ + grep -Fq 'copier copy . /path/to/new-project --trust' "$$out_dir/README.md"; \ + grep -Eq '^copier__project_name_raw:' "$$out_dir/copier.yml"; \ + render_dir="$$(mktemp -d /tmp/scaf-template-rendered-gh-XXXXXX)"; \ + copier copy "$$out_dir" "$$render_dir" --trust --defaults \ + -d copier__configure_repo=false \ + -d copier__enable_semantic_release=false \ + -d copier__enable_secret_scanning=false \ + -d copier__ci_provider="github" \ + -d copier__task_runner="task"; \ + test -f "$$render_dir/.copier-answers.yml"; \ + test ! -f "$$render_dir/{{_copier_conf.answers_file}}"; \ + rm -rf "$$render_dir"; \ rm -rf "$$out_dir" -# Validate GitLab-scaffolded output and task-runner pruning. +# Validate generated template repository structure. test-template-render-gitlab: @set -euo pipefail; \ out_dir="$$(mktemp -d /tmp/scaf-template-gitlab-XXXXXX)"; \ - copier copy . "$$out_dir" --trust --defaults \ + copier copy . "$$out_dir" --vcs-ref=HEAD --trust --defaults \ -d copier__project_name_raw="Sample GitLab Template" \ -d copier__project_slug="sample_gitlab_template" \ -d copier__description="Sample gitlab generated project" \ @@ -46,11 +68,32 @@ test-template-render-gitlab: -d copier__enable_semantic_release=false \ -d copier__enable_secret_scanning=true \ -d copier__task_runner="just"; \ + test -f "$$out_dir/copier.yml"; \ test -f "$$out_dir/README.md"; \ + test -f "$$out_dir/.github/workflows/template-correctness.yaml"; \ test -f "$$out_dir/.copier-answers.yml"; \ - test -f "$$out_dir/justfile"; \ - test ! -f "$$out_dir/Makefile"; \ - test ! -f "$$out_dir/Taskfile.yml"; \ - test -f "$$out_dir/.gitlab-ci.yml"; \ - test ! -d "$$out_dir/.github"; \ + test -f "$$out_dir/template/README.md"; \ + test -f "$$out_dir/template/.scaf/post-copy.py"; \ + test -f "$$out_dir/template/justfile"; \ + test ! -f "$$out_dir/template/Makefile"; \ + test ! -f "$$out_dir/template/Taskfile.yml"; \ + test -f "$$out_dir/template/.gitlab-ci.yml"; \ + test ! -d "$$out_dir/template/.github"; \ + test ! -f "$$out_dir/template/package.json"; \ + test ! -f "$$out_dir/template/dependencies-init.txt"; \ + test ! -f "$$out_dir/template/dependencies-dev-init.txt"; \ + test -f "$$out_dir/template/{{_copier_conf.answers_file}}"; \ + grep -Fq '{{ copier__project_name }}' "$$out_dir/template/README.md"; \ + grep -Fq 'copier copy . /path/to/new-project --trust' "$$out_dir/README.md"; \ + grep -Eq '^copier__project_name_raw:' "$$out_dir/copier.yml"; \ + render_dir="$$(mktemp -d /tmp/scaf-template-rendered-gl-XXXXXX)"; \ + copier copy "$$out_dir" "$$render_dir" --trust --defaults \ + -d copier__configure_repo=false \ + -d copier__enable_semantic_release=false \ + -d copier__enable_secret_scanning=false \ + -d copier__ci_provider="gitlab" \ + -d copier__task_runner="just"; \ + test -f "$$render_dir/.copier-answers.yml"; \ + test ! -f "$$render_dir/{{_copier_conf.answers_file}}"; \ + rm -rf "$$render_dir"; \ rm -rf "$$out_dir" diff --git a/README.md b/README.md index 44c2101..60b31f4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Use this starter to generate new Scaf templates with validated Copier prompts, GitHub/GitLab CI templates, optional semantic-release wiring (GitHub token or GitHub App), optional gitleaks scanning, and generated `make`/`task`/`just` -`init`/`check` commands. +`init`/`check` commands in the emitted `template/` payload. ## Features @@ -18,6 +18,7 @@ GitHub App), optional gitleaks scanning, and generated `make`/`task`/`just` - Optional semantic-release setup and config - Optional secret scanning in CI - Template correctness CI (Copier render checks) +- Generated templates are emitted under `template/` in the produced repository - Choice of local task runner (`make`, `task`, or `just`) with `init`/`check` - Generated project docs for usage and upgrade flow - Apache-2.0 license by default (license selection via Copier option planned) @@ -60,9 +61,9 @@ CI runs the same command in `.github/workflows/template-render-tests.yaml`. Each generated project includes: -- `docs/using-template.md` -- `docs/upgrading.md` -- `docs/semantic-release-github.md` (when GitHub + semantic-release is enabled) +- `template/docs/using-template.md` +- `template/docs/upgrading.md` +- `template/docs/semantic-release-github.md` (when GitHub + semantic-release is enabled) These cover day-to-day usage and copier update workflow for downstream projects. diff --git a/copier.yml b/copier.yml index a19a821..23b0b86 100644 --- a/copier.yml +++ b/copier.yml @@ -1,8 +1,8 @@ -_templates_suffix: "" +_templates_suffix: ".jinja" _subdirectory: template _tasks: - - python tasks.py + - python .scaf/starter-post-copy.py copier__project_name_raw: type: str @@ -138,8 +138,3 @@ copier__task_runner: - make - task - just - -copier__template_sample_name: - type: str - default: "sample" - help: "Sample project name used by template correctness CI." diff --git a/template/.github/workflows/template-correctness.yaml b/template/.github/workflows/template-correctness.yaml index c40151d..f4aa1eb 100644 --- a/template/.github/workflows/template-correctness.yaml +++ b/template/.github/workflows/template-correctness.yaml @@ -23,21 +23,23 @@ jobs: - name: Render sample project run: | - copier copy . /tmp/{{ copier__template_sample_name }} --trust --defaults \ + copier copy . /tmp/sample-project --trust --defaults \ -d copier__project_name_raw="Sample Project" \ -d copier__project_slug="sample_project" \ -d copier__description="Sample generated project" \ -d copier__author_name="Sample Author" \ -d copier__email="sample@example.com" \ -d copier__version="0.1.0" \ - -d copier__ci_provider="{{ copier__ci_provider }}" \ - -d copier__enable_semantic_release={{ 'true' if copier__enable_semantic_release else 'false' }} \ - -d copier__github_semantic_release_auth="{{ copier__github_semantic_release_auth }}" \ - -d copier__enable_secret_scanning={{ 'true' if copier__enable_secret_scanning else 'false' }} \ - -d copier__task_runner="{{ copier__task_runner }}" + -d copier__configure_repo=false \ + -d copier__ci_provider="github" \ + -d copier__enable_semantic_release=false \ + -d copier__enable_secret_scanning=false \ + -d copier__task_runner="task" - name: Validate generated project files run: | - test -f /tmp/{{ copier__template_sample_name }}/README.md - test -f /tmp/{{ copier__template_sample_name }}/copier.yml - test -f /tmp/{{ copier__template_sample_name }}/template/{{'Taskfile.yml' if copier__task_runner == 'task' else ('justfile' if copier__task_runner == 'just' else 'Makefile')}} + test -f /tmp/sample-project/README.md + test -f /tmp/sample-project/.copier-answers.yml + test -f /tmp/sample-project/Taskfile.yml + test ! -f /tmp/sample-project/Makefile + test ! -f /tmp/sample-project/justfile diff --git a/template/.scaf/starter-post-copy.py.jinja b/template/.scaf/starter-post-copy.py.jinja new file mode 100644 index 0000000..e7096c7 --- /dev/null +++ b/template/.scaf/starter-post-copy.py.jinja @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +import os +import pathlib +import shutil +import subprocess +import tempfile + +CI_PROVIDER = "{{ copier__ci_provider }}" +SEMANTIC_RELEASE = {{ "True" if copier__enable_semantic_release else "False" }} +SECRET_SCANNING = {{ "True" if copier__enable_secret_scanning else "False" }} +TASK_RUNNER = "{{ copier__task_runner }}" +CONFIGURE_REPO = {{ "True" if copier__configure_repo else "False" }} +REPO_PROVIDER = "{{ copier__repo_provider }}" +REPO_ORG = "{{ copier__repo_org }}" +REPO_NAME = "{{ copier__repo_name }}" +REPO_URL = "{{ copier__repo_url }}" +CREATE_REPO = {{ "True" if copier__create_repo else "False" }} +REPO_VISIBILITY = "{{ copier__repo_visibility }}" +AUTHOR_NAME = "{{ copier__author_name }}" +AUTHOR_EMAIL = "{{ copier__email }}" + +PROJECT_ROOT = pathlib.Path.cwd() +TEMPLATE_ROOT = PROJECT_ROOT / "template" +TERMINATOR = "\x1b[0m" +WARNING = "\x1b[1;33m [WARNING]: " +INFO = "\x1b[1;33m [INFO]: " +SUCCESS = "\x1b[1;32m [SUCCESS]: " + + +def run( + cmd: list[str], *, cwd: pathlib.Path | None = None, capture_output: bool = False +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + check=True, + cwd=cwd, + capture_output=capture_output, + text=True, + ) + + +def try_run(cmd: list[str], *, cwd: pathlib.Path | None = None) -> bool: + return subprocess.run(cmd, cwd=cwd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0 + + +def remove(path: pathlib.Path) -> None: + if not path.exists(): + return + if path.is_file() or path.is_symlink(): + path.unlink() + else: + shutil.rmtree(path) + + +def read_packages(path: pathlib.Path) -> list[str]: + if not path.exists(): + return [] + return [line.strip() for line in path.read_text().splitlines() if line.strip() and not line.startswith("#")] + + +def has_unrendered_jinja(path: pathlib.Path) -> bool: + if not path.exists(): + return False + content = path.read_text() + return ("{" + "{") in content or ("{" + "%") in content + + +def init_git_repo() -> bool: + if (PROJECT_ROOT / ".git").exists(): + return False + print(INFO + "Initializing git repository..." + TERMINATOR) + print(INFO + f"Current working directory: {PROJECT_ROOT}" + TERMINATOR) + run(["git", "-c", "init.defaultBranch=main", "init", ".", "--quiet"], cwd=PROJECT_ROOT) + print(SUCCESS + "Git repository initialized." + TERMINATOR) + return True + + +def maybe_create_repo() -> None: + if not CREATE_REPO or not CONFIGURE_REPO: + return + if not REPO_ORG.strip() or not REPO_NAME.strip(): + print(WARNING + "Repo org/name not set. Skipping repo creation." + TERMINATOR) + return + repo_name = f"{REPO_ORG.strip()}/{REPO_NAME.strip()}" + + if REPO_PROVIDER == "github": + if not shutil.which("gh"): + print(WARNING + "gh CLI is not installed. Skipping repo creation." + TERMINATOR) + return + if try_run(["gh", "repo", "view", repo_name], cwd=PROJECT_ROOT): + print(INFO + f"GitHub repository {repo_name} already exists." + TERMINATOR) + return + print(INFO + f"Creating GitHub repository {repo_name} ({REPO_VISIBILITY})..." + TERMINATOR) + created = subprocess.run( + ["gh", "repo", "create", repo_name, f"--{REPO_VISIBILITY}"], + capture_output=True, + text=True, + ) + if created.returncode != 0: + error = created.stderr.strip() or created.stdout.strip() or "unknown error" + print(WARNING + f"Failed to create GitHub repository {repo_name}: {error}" + TERMINATOR) + return + print(SUCCESS + f"GitHub repository {repo_name} created." + TERMINATOR) + return + + if REPO_PROVIDER == "gitlab": + if not shutil.which("glab"): + print(WARNING + "glab CLI is not installed. Skipping repo creation." + TERMINATOR) + return + if try_run(["glab", "repo", "view", repo_name], cwd=PROJECT_ROOT): + print(INFO + f"GitLab repository {repo_name} already exists." + TERMINATOR) + return + print(INFO + f"Creating GitLab repository {repo_name} ({REPO_VISIBILITY})..." + TERMINATOR) + created = subprocess.run( + ["glab", "repo", "create", repo_name, f"--{REPO_VISIBILITY}"], + capture_output=True, + text=True, + ) + if created.returncode != 0: + error = created.stderr.strip() or created.stdout.strip() or "unknown error" + print(WARNING + f"Failed to create GitLab repository {repo_name}: {error}" + TERMINATOR) + return + print(SUCCESS + f"GitLab repository {repo_name} created." + TERMINATOR) + return + + print( + WARNING + + f"Repo creation not implemented for provider '{REPO_PROVIDER}'. Skipping." + + TERMINATOR + ) + + +def configure_git_remote() -> None: + if not CONFIGURE_REPO: + return + + repo_url = REPO_URL.strip() + if not repo_url: + print(WARNING + "No repo_url provided. Skipping git remote configuration." + TERMINATOR) + return + + print(INFO + f"repo_url: {repo_url}" + TERMINATOR) + if try_run(["git", "remote", "get-url", "origin"], cwd=PROJECT_ROOT): + current_origin = run(["git", "remote", "get-url", "origin"], cwd=PROJECT_ROOT, capture_output=True) + print( + INFO + + f"Remote origin already configured ({current_origin.stdout.strip()}). Skipping add." + + TERMINATOR + ) + return + + run(["git", "remote", "add", "origin", repo_url], cwd=PROJECT_ROOT) + print(SUCCESS + f"Remote origin={repo_url} added." + TERMINATOR) + + +def install_semantic_release_deps(init_file: pathlib.Path, dev_file: pathlib.Path) -> None: + init_packages = read_packages(init_file) + dev_packages = read_packages(dev_file) + + if shutil.which("npm"): + if init_packages: + run(["npm", "install", *init_packages], cwd=TEMPLATE_ROOT) + if dev_packages: + run(["npm", "install", "--save-dev", *dev_packages], cwd=TEMPLATE_ROOT) + return + + if shutil.which("docker"): + run( + [ + "docker", + "run", + "--rm", + "--user", + f"{os.getuid()}:{os.getgid()}", + "-w", + "/app", + "-v", + f"{TEMPLATE_ROOT}:/app", + "-e", + "npm_config_cache=/tmp/.npm", + "node:lts", + "/bin/bash", + "-lc", + ( + f"if [ -s {init_file.name!r} ]; then xargs npm install < {init_file.name!r}; fi; " + f"if [ -s {dev_file.name!r} ]; then xargs npm install --save-dev < {dev_file.name!r}; fi" + ), + ], + cwd=TEMPLATE_ROOT, + ) + return + + print(WARNING + "docker and npm not found; skipping semantic-release dependency install" + TERMINATOR) + + +def run_template_init() -> None: + if shutil.which("pre-commit") and try_run(["git", "rev-parse", "--is-inside-work-tree"], cwd=TEMPLATE_ROOT): + try: + run(["pre-commit", "install"], cwd=TEMPLATE_ROOT) + except subprocess.CalledProcessError: + print("pre-commit install failed; continuing") + + if SEMANTIC_RELEASE: + init_deps = TEMPLATE_ROOT / "dependencies-init.txt" + dev_deps = TEMPLATE_ROOT / "dependencies-dev-init.txt" + + if has_unrendered_jinja(init_deps) or has_unrendered_jinja(dev_deps): + with tempfile.NamedTemporaryFile( + mode="w", dir=TEMPLATE_ROOT, prefix=".scaf-deps-init.", delete=False + ) as init_tmp: + for line in read_packages(init_deps): + if ("{" + "{") in line or ("{" + "%") in line: + continue + init_tmp.write(f"{line}\n") + init_tmp_path = pathlib.Path(init_tmp.name) + + with tempfile.NamedTemporaryFile( + mode="w", dir=TEMPLATE_ROOT, prefix=".scaf-deps-dev.", delete=False + ) as dev_tmp: + for line in read_packages(dev_deps): + if ("{" + "{") in line or ("{" + "%") in line: + continue + if CI_PROVIDER == "github" and line == "@semantic-release/gitlab": + continue + if CI_PROVIDER == "gitlab" and line == "@semantic-release/github": + continue + dev_tmp.write(f"{line}\n") + dev_tmp_path = pathlib.Path(dev_tmp.name) + + try: + install_semantic_release_deps(init_tmp_path, dev_tmp_path) + finally: + remove(init_tmp_path) + remove(dev_tmp_path) + else: + install_semantic_release_deps(init_deps, dev_deps) + + print("Local development setup complete.") + + +def prune_starter_template() -> None: + if TASK_RUNNER != "make": + remove(TEMPLATE_ROOT / "Makefile") + if TASK_RUNNER != "task": + remove(TEMPLATE_ROOT / "Taskfile.yml") + if TASK_RUNNER != "just": + remove(TEMPLATE_ROOT / "justfile") + + if CI_PROVIDER != "github": + remove(TEMPLATE_ROOT / ".github") + if CI_PROVIDER != "gitlab": + remove(TEMPLATE_ROOT / ".gitlab-ci.yml") + + if not SEMANTIC_RELEASE: + remove(TEMPLATE_ROOT / "package.json") + remove(TEMPLATE_ROOT / "dependencies-init.txt") + remove(TEMPLATE_ROOT / "dependencies-dev-init.txt") + remove(TEMPLATE_ROOT / ".releaserc.json") + remove(TEMPLATE_ROOT / "CHANGELOG.md") + remove(TEMPLATE_ROOT / ".github/workflows/semantic-release.yaml") + remove(TEMPLATE_ROOT / ".github/workflows/semantic-pull-request.yaml") + + if not SECRET_SCANNING: + remove(TEMPLATE_ROOT / ".github/workflows/secret-scan.yaml") + + +def maybe_initial_commit(initialized_repo: bool) -> None: + if not initialized_repo: + return + if try_run(["git", "rev-parse", "--verify", "HEAD"], cwd=PROJECT_ROOT): + return + + run(["git", "add", "-A"], cwd=PROJECT_ROOT) + status = run(["git", "status", "--porcelain"], cwd=PROJECT_ROOT, capture_output=True) + if not status.stdout.strip(): + return + + commit_result = subprocess.run( + [ + "git", + "-c", + f"user.name={AUTHOR_NAME}", + "-c", + f"user.email={AUTHOR_EMAIL}", + "commit", + "--no-verify", + "-m", + "chore: initialize from template", + ], + cwd=PROJECT_ROOT, + ) + if commit_result.returncode != 0: + print( + WARNING + + "Initial commit failed. Configure git user.name and user.email, then commit manually." + + TERMINATOR + ) + return + print(SUCCESS + "Initial commit created." + TERMINATOR) + + +def main() -> None: + initialized_repo = init_git_repo() + maybe_create_repo() + configure_git_remote() + run_template_init() + prune_starter_template() + remove(PROJECT_ROOT / ".scaf/starter-post-copy.py") + try: + (PROJECT_ROOT / ".scaf").rmdir() + except OSError: + pass + maybe_initial_commit(initialized_repo) + + +if __name__ == "__main__": + main() diff --git a/template/README.md.jinja b/template/README.md.jinja new file mode 100644 index 0000000..fe1ab7f --- /dev/null +++ b/template/README.md.jinja @@ -0,0 +1,27 @@ +# {{ copier__project_name }} + +{{ copier__description }} + +This repository is a Copier template. It renders project scaffolding from the `template/` directory. + +## Use This Template + +```bash +# From a local clone +copier copy . /path/to/new-project --trust + +# Or from a remote repository URL +copier copy /path/to/new-project --trust +``` + +## What Gets Rendered + +- Project metadata and docs +- CI scaffolding +- Optional semantic-release wiring +- Optional secret scanning +- Task runner files (`make`, `task`, or `just`, pruned by selected option) + +## Template Validation + +This repository includes `.github/workflows/template-correctness.yaml`, which renders a sample project on every push/PR to verify the template remains valid. diff --git a/template/copier.yml b/template/copier.yml new file mode 100644 index 0000000..99f6476 --- /dev/null +++ b/template/copier.yml @@ -0,0 +1,140 @@ +_templates_suffix: "" +_subdirectory: template + +_tasks: + - python .scaf/post-copy.py + +copier__project_name_raw: + type: str + default: "My Scaf Template" + help: "The generated project name." + validator: >- + {% if not copier__project_name_raw.strip() %} + "Project name cannot be empty." + {% endif %} + +copier__project_name: + type: str + default: "{{ copier__project_name_raw | replace(\"'\", \"\") | replace('\"', '') }}" + when: false + +copier__project_slug: + type: str + default: "{{ is_project_slug if is_project_slug else copier__project_name.lower().replace(' ', '_').replace('-', '_').replace('.', '_').strip() }}" + help: "Python identifier-style slug." + validator: >- + {% if not copier__project_slug.isidentifier() %} + "'{{ copier__project_slug }}' is not a valid Python identifier." + {% elif copier__project_slug != copier__project_slug.lower() %} + "'{{ copier__project_slug }}' must be lowercase." + {% elif not copier__project_slug.strip() %} + "Project slug cannot be empty." + {% endif %} + +copier__project_dash: + type: str + default: "{{ copier__project_slug.lower().replace(' ', '-').replace('_', '-').replace('.', '-').strip() }}" + when: false + +copier__description: + type: str + default: "Template for new scaf templates" + help: "Short project description." + +copier__author_name: + type: str + default: "Template Author" + help: "Author name." + +copier__email: + type: str + default: "{{ copier__author_name.lower().replace(' ', '.') }}@example.com" + help: "Author email." + +copier__configure_repo: + type: bool + default: true + help: "Configure a git remote for the generated project." + +copier__repo_provider: + type: str + default: github + choices: + - github + - gitlab + help: "Repository provider." + when: "{{ copier__configure_repo }}" + +copier__repo_org: + type: str + default: "getscaf" + help: "Repository organization/group." + when: "{{ copier__configure_repo }}" + +copier__repo_name: + type: str + default: "{{ copier__project_dash }}" + help: "Repository name." + when: "{{ copier__configure_repo }}" + +copier__repo_url: + type: str + default: "{{ ('git@github.com:' + copier__repo_org + '/' + copier__repo_name + '.git') if (copier__configure_repo and copier__repo_provider == 'github') else (('git@gitlab.com:' + copier__repo_org + '/' + copier__repo_name + '.git') if copier__configure_repo else '') }}" + when: false + +copier__create_repo: + type: bool + default: true + help: "Create the repository automatically if it does not exist (gh for GitHub, glab for GitLab)." + when: "{{ copier__configure_repo }}" + +copier__repo_visibility: + type: str + default: public + choices: + - private + - public + help: "Visibility to use when creating the repository." + when: "{{ copier__configure_repo and copier__create_repo }}" + +copier__version: + type: str + default: "0.1.0" + help: "Initial project version." + +copier__ci_provider: + type: str + default: "{{ copier__repo_provider if copier__configure_repo else 'github' }}" + help: "CI provider to scaffold." + choices: + - github + - gitlab + when: "{{ not copier__configure_repo }}" + +copier__enable_semantic_release: + type: bool + default: true + help: "Include semantic-release config and CI release job." + +copier__github_semantic_release_auth: + type: str + default: github_token + help: "GitHub semantic-release authentication method." + choices: + - github_token + - github_app + when: "{{ copier__ci_provider == 'github' and copier__enable_semantic_release }}" + +copier__enable_secret_scanning: + type: bool + default: true + help: "Include secret scanning in CI." + +copier__task_runner: + type: str + default: task + help: "Primary local task runner." + choices: + - make + - task + - just diff --git a/template/tasks.py b/template/tasks.py deleted file mode 100644 index 137926d..0000000 --- a/template/tasks.py +++ /dev/null @@ -1,218 +0,0 @@ -import os -import pathlib -import shlex -import shutil -import subprocess - -CI_PROVIDER = "{{ copier__ci_provider }}" -SEMANTIC_RELEASE = {{ "True" if copier__enable_semantic_release else "False" }} -SECRET_SCANNING = {{ "True" if copier__enable_secret_scanning else "False" }} -TASK_RUNNER = "{{ copier__task_runner }}" -CONFIGURE_REPO = {{ "True" if copier__configure_repo else "False" }} -REPO_PROVIDER = "{{ copier__repo_provider }}" -REPO_ORG = "{{ copier__repo_org }}" -REPO_NAME = "{{ copier__repo_name }}" -REPO_URL = "{{ copier__repo_url }}" -CREATE_REPO = {{ "True" if copier__create_repo else "False" }} -REPO_VISIBILITY = "{{ copier__repo_visibility }}" - -ROOT = pathlib.Path(".") -TERMINATOR = "\x1b[0m" -WARNING = "\x1b[1;33m [WARNING]: " -INFO = "\x1b[1;33m [INFO]: " -SUCCESS = "\x1b[1;32m [SUCCESS]: " - - -def remove(path: str) -> None: - p = ROOT / path - if p.exists(): - if p.is_file() or p.is_symlink(): - p.unlink() - else: - for child in sorted(p.rglob("*"), reverse=True): - if child.is_file() or child.is_symlink(): - child.unlink() - elif child.is_dir(): - child.rmdir() - p.rmdir() - - -def run(cmd: list[str]) -> None: - subprocess.run(cmd, check=True) - - -def run_init_script() -> None: - run(["bash", "./scripts/init-dev.sh"]) - - -def init_git_repo() -> None: - if (ROOT / ".git").exists(): - return - print(INFO + "Initializing git repository..." + TERMINATOR) - print(INFO + f"Current working directory: {os.getcwd()}" + TERMINATOR) - subprocess.run( - shlex.split("git -c init.defaultBranch=main init . --quiet"), check=True - ) - print(SUCCESS + "Git repository initialized." + TERMINATOR) - - -def configure_git_remote() -> None: - if not CONFIGURE_REPO: - return - repo_url = REPO_URL.strip() - if repo_url: - print(INFO + f"repo_url: {repo_url}" + TERMINATOR) - existing_origin = subprocess.run( - shlex.split("git remote get-url origin"), - capture_output=True, - text=True, - ) - if existing_origin.returncode == 0: - current_origin = existing_origin.stdout.strip() - print( - INFO - + f"Remote origin already configured ({current_origin}). Skipping add." - + TERMINATOR - ) - return - command = f"git remote add origin {repo_url}" - subprocess.run(shlex.split(command), check=True) - print(SUCCESS + f"Remote origin={repo_url} added." + TERMINATOR) - else: - print( - WARNING - + "No repo_url provided. Skipping git remote configuration." - + TERMINATOR - ) - - -def maybe_create_repo() -> None: - if not CREATE_REPO: - return - - if not CONFIGURE_REPO: - return - if not REPO_ORG.strip() or not REPO_NAME.strip(): - print(WARNING + "Repo org/name not set. Skipping repo creation." + TERMINATOR) - return - repo_name = f"{REPO_ORG.strip()}/{REPO_NAME.strip()}" - - if REPO_PROVIDER == "github": - if not shutil.which("gh"): - print(WARNING + "gh CLI is not installed. Skipping repo creation." + TERMINATOR) - return - - gh_env = os.environ.copy() - gh_env["GH_HOST"] = "github.com" - - repo_exists = subprocess.run( - ["gh", "repo", "view", repo_name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - env=gh_env, - ) - if repo_exists.returncode == 0: - print(INFO + f"GitHub repository {repo_name} already exists." + TERMINATOR) - return - - print( - INFO - + f"Creating GitHub repository {repo_name} ({REPO_VISIBILITY})..." - + TERMINATOR - ) - create_repo = subprocess.run( - ["gh", "repo", "create", repo_name, f"--{REPO_VISIBILITY}"], - capture_output=True, - text=True, - env=gh_env, - ) - if create_repo.returncode != 0: - error = create_repo.stderr.strip() or create_repo.stdout.strip() or "unknown error" - print( - WARNING - + f"Failed to create GitHub repository {repo_name}: {error}" - + TERMINATOR - ) - return - print(SUCCESS + f"GitHub repository {repo_name} created." + TERMINATOR) - return - - if REPO_PROVIDER == "gitlab": - if not shutil.which("glab"): - print(WARNING + "glab CLI is not installed. Skipping repo creation." + TERMINATOR) - return - - glab_env = os.environ.copy() - glab_env["GITLAB_HOST"] = "gitlab.com" - repo_exists = subprocess.run( - ["glab", "repo", "view", repo_name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - env=glab_env, - ) - if repo_exists.returncode == 0: - print(INFO + f"GitLab repository {repo_name} already exists." + TERMINATOR) - return - - print( - INFO - + f"Creating GitLab repository {repo_name} ({REPO_VISIBILITY})..." - + TERMINATOR - ) - create_repo = subprocess.run( - ["glab", "repo", "create", repo_name, f"--{REPO_VISIBILITY}"], - capture_output=True, - text=True, - env=glab_env, - ) - if create_repo.returncode != 0: - error = create_repo.stderr.strip() or create_repo.stdout.strip() or "unknown error" - print( - WARNING - + f"Failed to create GitLab repository {repo_name}: {error}" - + TERMINATOR - ) - return - print(SUCCESS + f"GitLab repository {repo_name} created." + TERMINATOR) - return - - print( - WARNING - + f"Repo creation not implemented for provider '{REPO_PROVIDER}'. Skipping." - + TERMINATOR - ) - - -def main() -> None: - init_git_repo() - maybe_create_repo() - configure_git_remote() - run_init_script() - - if TASK_RUNNER != "make": - remove("Makefile") - if TASK_RUNNER != "task": - remove("Taskfile.yml") - if TASK_RUNNER != "just": - remove("justfile") - - if CI_PROVIDER != "github": - remove(".github") - if CI_PROVIDER != "gitlab": - remove(".gitlab-ci.yml") - - if not SEMANTIC_RELEASE: - remove("package.json") - remove("dependencies-init.txt") - remove("dependencies-dev-init.txt") - remove(".releaserc.json") - remove("CHANGELOG.md") - remove(".github/workflows/semantic-release.yaml") - remove(".github/workflows/semantic-pull-request.yaml") - - if not SECRET_SCANNING: - remove(".github/workflows/secret-scan.yaml") - - -if __name__ == "__main__": - main() diff --git a/template/.github/workflows/secret-scan.yaml b/template/template/.github/workflows/secret-scan.yaml similarity index 100% rename from template/.github/workflows/secret-scan.yaml rename to template/template/.github/workflows/secret-scan.yaml diff --git a/template/.github/workflows/semantic-pull-request.yaml b/template/template/.github/workflows/semantic-pull-request.yaml similarity index 100% rename from template/.github/workflows/semantic-pull-request.yaml rename to template/template/.github/workflows/semantic-pull-request.yaml diff --git a/template/.github/workflows/semantic-release.yaml b/template/template/.github/workflows/semantic-release.yaml similarity index 100% rename from template/.github/workflows/semantic-release.yaml rename to template/template/.github/workflows/semantic-release.yaml diff --git a/template/template/.github/workflows/template-correctness.yaml b/template/template/.github/workflows/template-correctness.yaml new file mode 100644 index 0000000..c40151d --- /dev/null +++ b/template/template/.github/workflows/template-correctness.yaml @@ -0,0 +1,43 @@ +name: Template Correctness + +on: + push: + branches: [main] + pull_request: + +jobs: + render-template: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install copier + run: pip install copier + + - name: Render sample project + run: | + copier copy . /tmp/{{ copier__template_sample_name }} --trust --defaults \ + -d copier__project_name_raw="Sample Project" \ + -d copier__project_slug="sample_project" \ + -d copier__description="Sample generated project" \ + -d copier__author_name="Sample Author" \ + -d copier__email="sample@example.com" \ + -d copier__version="0.1.0" \ + -d copier__ci_provider="{{ copier__ci_provider }}" \ + -d copier__enable_semantic_release={{ 'true' if copier__enable_semantic_release else 'false' }} \ + -d copier__github_semantic_release_auth="{{ copier__github_semantic_release_auth }}" \ + -d copier__enable_secret_scanning={{ 'true' if copier__enable_secret_scanning else 'false' }} \ + -d copier__task_runner="{{ copier__task_runner }}" + + - name: Validate generated project files + run: | + test -f /tmp/{{ copier__template_sample_name }}/README.md + test -f /tmp/{{ copier__template_sample_name }}/copier.yml + test -f /tmp/{{ copier__template_sample_name }}/template/{{'Taskfile.yml' if copier__task_runner == 'task' else ('justfile' if copier__task_runner == 'just' else 'Makefile')}} diff --git a/template/.gitignore b/template/template/.gitignore similarity index 100% rename from template/.gitignore rename to template/template/.gitignore diff --git a/template/.gitlab-ci.yml b/template/template/.gitlab-ci.yml similarity index 100% rename from template/.gitlab-ci.yml rename to template/template/.gitlab-ci.yml diff --git a/template/.pre-commit-config.yaml b/template/template/.pre-commit-config.yaml similarity index 100% rename from template/.pre-commit-config.yaml rename to template/template/.pre-commit-config.yaml diff --git a/template/.releaserc.json b/template/template/.releaserc.json similarity index 100% rename from template/.releaserc.json rename to template/template/.releaserc.json diff --git a/template/template/.scaf/post-copy.py.jinja b/template/template/.scaf/post-copy.py.jinja new file mode 100644 index 0000000..be3aa56 --- /dev/null +++ b/template/template/.scaf/post-copy.py.jinja @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +import pathlib +import shutil +import subprocess + +CI_PROVIDER = "{{ copier__ci_provider }}" +SEMANTIC_RELEASE = {{ "True" if copier__enable_semantic_release else "False" }} +SECRET_SCANNING = {{ "True" if copier__enable_secret_scanning else "False" }} +TASK_RUNNER = "{{ copier__task_runner }}" +CONFIGURE_REPO = {{ "True" if copier__configure_repo else "False" }} +REPO_PROVIDER = "{{ copier__repo_provider }}" +REPO_ORG = "{{ copier__repo_org }}" +REPO_NAME = "{{ copier__repo_name }}" +REPO_URL = "{{ copier__repo_url }}" +CREATE_REPO = {{ "True" if copier__create_repo else "False" }} +REPO_VISIBILITY = "{{ copier__repo_visibility }}" +AUTHOR_NAME = "{{ copier__author_name }}" +AUTHOR_EMAIL = "{{ copier__email }}" + +PROJECT_ROOT = pathlib.Path.cwd() +TERMINATOR = "\x1b[0m" +WARNING = "\x1b[1;33m [WARNING]: " +INFO = "\x1b[1;33m [INFO]: " +SUCCESS = "\x1b[1;32m [SUCCESS]: " + + +def run( + cmd: list[str], *, cwd: pathlib.Path | None = None, capture_output: bool = False +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + check=True, + cwd=cwd, + capture_output=capture_output, + text=True, + ) + + +def try_run(cmd: list[str], *, cwd: pathlib.Path | None = None) -> bool: + return subprocess.run(cmd, cwd=cwd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0 + + +def remove(path: pathlib.Path) -> None: + if not path.exists(): + return + if path.is_file() or path.is_symlink(): + path.unlink() + else: + shutil.rmtree(path) + + +def init_git_repo() -> bool: + if (PROJECT_ROOT / ".git").exists(): + return False + print(INFO + "Initializing git repository..." + TERMINATOR) + print(INFO + f"Current working directory: {PROJECT_ROOT}" + TERMINATOR) + run(["git", "-c", "init.defaultBranch=main", "init", ".", "--quiet"], cwd=PROJECT_ROOT) + print(SUCCESS + "Git repository initialized." + TERMINATOR) + return True + + +def maybe_create_repo() -> None: + if not CREATE_REPO or not CONFIGURE_REPO: + return + if not REPO_ORG.strip() or not REPO_NAME.strip(): + print(WARNING + "Repo org/name not set. Skipping repo creation." + TERMINATOR) + return + repo_name = f"{REPO_ORG.strip()}/{REPO_NAME.strip()}" + + if REPO_PROVIDER == "github": + if not shutil.which("gh"): + print(WARNING + "gh CLI is not installed. Skipping repo creation." + TERMINATOR) + return + if try_run(["gh", "repo", "view", repo_name], cwd=PROJECT_ROOT): + print(INFO + f"GitHub repository {repo_name} already exists." + TERMINATOR) + return + print(INFO + f"Creating GitHub repository {repo_name} ({REPO_VISIBILITY})..." + TERMINATOR) + created = subprocess.run( + ["gh", "repo", "create", repo_name, f"--{REPO_VISIBILITY}"], + capture_output=True, + text=True, + ) + if created.returncode != 0: + error = created.stderr.strip() or created.stdout.strip() or "unknown error" + print(WARNING + f"Failed to create GitHub repository {repo_name}: {error}" + TERMINATOR) + return + print(SUCCESS + f"GitHub repository {repo_name} created." + TERMINATOR) + return + + if REPO_PROVIDER == "gitlab": + if not shutil.which("glab"): + print(WARNING + "glab CLI is not installed. Skipping repo creation." + TERMINATOR) + return + if try_run(["glab", "repo", "view", repo_name], cwd=PROJECT_ROOT): + print(INFO + f"GitLab repository {repo_name} already exists." + TERMINATOR) + return + print(INFO + f"Creating GitLab repository {repo_name} ({REPO_VISIBILITY})..." + TERMINATOR) + created = subprocess.run( + ["glab", "repo", "create", repo_name, f"--{REPO_VISIBILITY}"], + capture_output=True, + text=True, + ) + if created.returncode != 0: + error = created.stderr.strip() or created.stdout.strip() or "unknown error" + print(WARNING + f"Failed to create GitLab repository {repo_name}: {error}" + TERMINATOR) + return + print(SUCCESS + f"GitLab repository {repo_name} created." + TERMINATOR) + return + + print( + WARNING + + f"Repo creation not implemented for provider '{REPO_PROVIDER}'. Skipping." + + TERMINATOR + ) + + +def configure_git_remote() -> None: + if not CONFIGURE_REPO: + return + + repo_url = REPO_URL.strip() + if not repo_url: + print(WARNING + "No repo_url provided. Skipping git remote configuration." + TERMINATOR) + return + + print(INFO + f"repo_url: {repo_url}" + TERMINATOR) + if try_run(["git", "remote", "get-url", "origin"], cwd=PROJECT_ROOT): + current_origin = run(["git", "remote", "get-url", "origin"], cwd=PROJECT_ROOT, capture_output=True) + print( + INFO + + f"Remote origin already configured ({current_origin.stdout.strip()}). Skipping add." + + TERMINATOR + ) + return + + run(["git", "remote", "add", "origin", repo_url], cwd=PROJECT_ROOT) + print(SUCCESS + f"Remote origin={repo_url} added." + TERMINATOR) + + +def run_init_script() -> None: + run(["bash", "./scripts/init-dev.sh"], cwd=PROJECT_ROOT) + + +def prune_files() -> None: + if TASK_RUNNER != "make": + remove(PROJECT_ROOT / "Makefile") + if TASK_RUNNER != "task": + remove(PROJECT_ROOT / "Taskfile.yml") + if TASK_RUNNER != "just": + remove(PROJECT_ROOT / "justfile") + + if CI_PROVIDER != "github": + remove(PROJECT_ROOT / ".github") + if CI_PROVIDER != "gitlab": + remove(PROJECT_ROOT / ".gitlab-ci.yml") + + if not SEMANTIC_RELEASE: + remove(PROJECT_ROOT / "package.json") + remove(PROJECT_ROOT / "dependencies-init.txt") + remove(PROJECT_ROOT / "dependencies-dev-init.txt") + remove(PROJECT_ROOT / ".releaserc.json") + remove(PROJECT_ROOT / "CHANGELOG.md") + remove(PROJECT_ROOT / ".github/workflows/semantic-release.yaml") + remove(PROJECT_ROOT / ".github/workflows/semantic-pull-request.yaml") + + if not SECRET_SCANNING: + remove(PROJECT_ROOT / ".github/workflows/secret-scan.yaml") + + +def maybe_initial_commit(initialized_repo: bool) -> None: + if not initialized_repo: + return + if try_run(["git", "rev-parse", "--verify", "HEAD"], cwd=PROJECT_ROOT): + return + + run(["git", "add", "-A"], cwd=PROJECT_ROOT) + status = run(["git", "status", "--porcelain"], cwd=PROJECT_ROOT, capture_output=True) + if not status.stdout.strip(): + return + + commit_result = subprocess.run( + [ + "git", + "-c", + f"user.name={AUTHOR_NAME}", + "-c", + f"user.email={AUTHOR_EMAIL}", + "commit", + "--no-verify", + "-m", + "chore: initialize from template", + ], + cwd=PROJECT_ROOT, + ) + if commit_result.returncode != 0: + print( + WARNING + + "Initial commit failed. Configure git user.name and user.email, then commit manually." + + TERMINATOR + ) + return + print(SUCCESS + "Initial commit created." + TERMINATOR) + + +def main() -> None: + initialized_repo = init_git_repo() + maybe_create_repo() + configure_git_remote() + run_init_script() + prune_files() + remove(PROJECT_ROOT / ".scaf/post-copy.py") + try: + (PROJECT_ROOT / ".scaf").rmdir() + except OSError: + pass + maybe_initial_commit(initialized_repo) + + +if __name__ == "__main__": + main() diff --git a/template/CHANGELOG.md b/template/template/CHANGELOG.md similarity index 100% rename from template/CHANGELOG.md rename to template/template/CHANGELOG.md diff --git a/template/Makefile b/template/template/Makefile similarity index 100% rename from template/Makefile rename to template/template/Makefile diff --git a/template/README.md b/template/template/README.md.jinja similarity index 94% rename from template/README.md rename to template/template/README.md.jinja index 18996c8..c4bf84d 100644 --- a/template/README.md +++ b/template/template/README.md.jinja @@ -1,4 +1,4 @@ -# {{ copier__project_name }} +{% raw %}# {{ copier__project_name }} {{ copier__description }} @@ -43,3 +43,4 @@ task check - [Upgrading Projects](docs/upgrading.md) {% if copier__ci_provider == "github" and copier__enable_semantic_release %}- [GitHub Semantic Release Setup](docs/semantic-release-github.md) {% endif %} +{% endraw %} diff --git a/template/Taskfile.yml b/template/template/Taskfile.yml similarity index 100% rename from template/Taskfile.yml rename to template/template/Taskfile.yml diff --git a/template/dependencies-dev-init.txt b/template/template/dependencies-dev-init.txt similarity index 100% rename from template/dependencies-dev-init.txt rename to template/template/dependencies-dev-init.txt diff --git a/template/dependencies-init.txt b/template/template/dependencies-init.txt similarity index 100% rename from template/dependencies-init.txt rename to template/template/dependencies-init.txt diff --git a/template/docs/semantic-release-github.md b/template/template/docs/semantic-release-github.md similarity index 100% rename from template/docs/semantic-release-github.md rename to template/template/docs/semantic-release-github.md diff --git a/template/docs/upgrading.md b/template/template/docs/upgrading.md similarity index 100% rename from template/docs/upgrading.md rename to template/template/docs/upgrading.md diff --git a/template/docs/using-template.md b/template/template/docs/using-template.md similarity index 100% rename from template/docs/using-template.md rename to template/template/docs/using-template.md diff --git a/template/justfile b/template/template/justfile similarity index 100% rename from template/justfile rename to template/template/justfile diff --git a/template/package.json b/template/template/package.json similarity index 100% rename from template/package.json rename to template/template/package.json diff --git a/template/scripts/init-dev.sh b/template/template/scripts/init-dev.sh.jinja similarity index 91% rename from template/scripts/init-dev.sh rename to template/template/scripts/init-dev.sh.jinja index 7d3d47c..2a8aaa0 100755 --- a/template/scripts/init-dev.sh +++ b/template/template/scripts/init-dev.sh.jinja @@ -6,14 +6,14 @@ if command -v pre-commit >/dev/null 2>&1 && git rev-parse --is-inside-work-tree fi {% if copier__enable_semantic_release %} -if command -v docker >/dev/null 2>&1; then +if command -v npm >/dev/null 2>&1; then + xargs npm install < dependencies-init.txt + xargs npm install --save-dev < dependencies-dev-init.txt +elif command -v docker >/dev/null 2>&1; then docker run --rm --user "$(id -u):$(id -g)" -w "/app" -v "$(pwd):/app" -e npm_config_cache=/tmp/.npm node:lts /bin/bash -c \ "xargs npm install < dependencies-init.txt" docker run --rm --user "$(id -u):$(id -g)" -w "/app" -v "$(pwd):/app" -e npm_config_cache=/tmp/.npm node:lts /bin/bash -c \ "xargs npm install --save-dev < dependencies-dev-init.txt" -elif command -v npm >/dev/null 2>&1; then - xargs npm install < dependencies-init.txt - xargs npm install --save-dev < dependencies-dev-init.txt else echo "docker and npm not found; skipping semantic-release dependency install" fi diff --git a/template/template/{% raw %}{{_copier_conf.answers_file}}{% endraw %}.jinja b/template/template/{% raw %}{{_copier_conf.answers_file}}{% endraw %}.jinja new file mode 100644 index 0000000..69d12b7 --- /dev/null +++ b/template/template/{% raw %}{{_copier_conf.answers_file}}{% endraw %}.jinja @@ -0,0 +1,2 @@ +# Changes here will be overwritten by Copier +{% raw %}{{ _copier_answers|to_nice_yaml -}}{% endraw %} diff --git a/template/{{_copier_conf.answers_file}} b/template/{{_copier_conf.answers_file}}.jinja similarity index 100% rename from template/{{_copier_conf.answers_file}} rename to template/{{_copier_conf.answers_file}}.jinja