github-build-workflow
Interactive GitHub Actions workflow generator with action versions auto-pinned.
Metadata
- Author: ropean, Claude Sonnet (Anthropic)
- Version: 1.0.0
Code
python
#!/usr/bin/env python3
"""
@title github-build-workflow
@description Interactive GitHub Actions workflow generator with action versions auto-pinned.
@author ropean, Claude Sonnet (Anthropic)
@version 1.0.0
Generates .github/workflows/*.yml from templates, with action versions
automatically pinned from github-actions-versions.json.
@example
python scripts/github-build-workflow.py [-o DIR] [-v FILE]
Options:
-o / --output Output directory (default: .github/workflows/)
-v / --versions Versions JSON (default: scripts/github-actions-versions.json)
"""
import json
import re
import sys
import argparse
from dataclasses import dataclass
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parent
DEFAULT_VERSIONS = SCRIPTS_DIR / "github-actions-versions.json"
# ── GitHub expression shorthands (avoids {{ }} escaping in f-strings) ─────────
G = {
"ref": "${{ github.ref }}",
"workflow": "${{ github.workflow }}",
"sha": "${{ github.sha }}",
"repo": "${{ github.repository }}",
"actor": "${{ github.actor }}",
"token": "${{ secrets.GITHUB_TOKEN }}",
"tag": "${{ github.ref_name }}",
"ref_name": "${{ github.ref_name }}",
"event_name": "${{ github.event_name }}",
"pr_ref": "${{ github.event.pull_request.number || github.ref }}",
"matrix_node": "${{ matrix.node }}",
"meta_tags": "${{ steps.meta.outputs.tags }}",
"meta_labels": "${{ steps.meta.outputs.labels }}",
"inputs_env": "${{ inputs.environment }}",
# Reusable expression: push → derive from branch, dispatch → use input
"deploy_env": "${{ github.event_name == 'push' && (startsWith(github.ref_name, 'dev') && 'dev' || 'release') || inputs.environment }}",
# Job-level env var (avoids repeating the expression in steps)
"gh_env": "${{ env.GH_ENV }}",
"step_cf_project": "${{ steps.cf.outputs.project }}",
"step_cf_domain": "${{ steps.cf.outputs.domain }}",
}
# ── Version fallbacks ──────────────────────────────────────────────────────────
FALLBACKS: dict[str, str] = {
"actions/checkout": "v4",
"actions/setup-node": "v4",
"actions/upload-artifact": "v4",
"actions/download-artifact": "v4",
"pnpm/action-setup": "v4",
"cloudflare/wrangler-action": "v3",
"docker/login-action": "v3",
"docker/metadata-action": "v5",
"docker/build-push-action": "v5",
"softprops/action-gh-release": "v2",
}
# ── Package manager config ─────────────────────────────────────────────────────
PKG: dict[str, dict] = {
"pnpm": {
"install": "pnpm install",
"run": "pnpm",
"cache": "pnpm",
"extra_step": "pnpm/action-setup",
},
"npm": {
"install": "npm ci",
"run": "npm run",
"cache": "npm",
"extra_step": None,
},
"yarn": {
"install": "yarn install --frozen-lockfile",
"run": "yarn",
"cache": "yarn",
"extra_step": None,
},
}
# ── Workflow name + filename per template / publish target ─────────────────────
WORKFLOW_META = {
"1": {
"name": "CI",
"filename": "ci.yml",
},
"2": {
"cf-pages": ("Deploy to Cloudflare Pages", "deploy-frontend-to-cloudflare.yml"),
"cf-worker": ("Deploy to Cloudflare Worker", "deploy-backend-to-cloudflare.yml"),
"github": ("Release", "release.yml"),
},
"3": {
"name": "Docker",
"filename": "docker.yml",
},
"4": {
"name": "PR",
"filename": "pr.yml",
},
}
# ── Param + interactive helpers ────────────────────────────────────────────────
class UserAbort(Exception):
pass
_QUIT = {"q", "quit", "exit"}
@dataclass
class Param:
key: str
prompt: str
default: str
choices: list[str] | None = None
def ask(param: Param) -> str:
if param.choices:
parts = []
for i, c in enumerate(param.choices, 1):
label = f"{i}.{c}"
parts.append(f"[{label}]" if c == param.default else label)
opts = "/".join(parts)
line = f" {param.prompt} ({opts}): "
else:
hint = " (blank to skip)" if param.default == "" else f" [{param.default}]"
line = f" {param.prompt}{hint}: "
try:
val = input(line).strip()
except (EOFError, KeyboardInterrupt):
raise UserAbort
if val.lower() in _QUIT:
raise UserAbort
if not val:
return param.default
# numeric shortcut: "1" → first choice, etc.
if param.choices and val.isdigit():
idx = int(val) - 1
if 0 <= idx < len(param.choices):
return param.choices[idx]
return val
def ask_yn(prompt: str, default: bool = False) -> bool:
opts = "Y/n" if default else "y/N"
try:
val = input(f" {prompt} [{opts}]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
raise UserAbort
if val in _QUIT:
raise UserAbort
return (val in ("y", "yes")) if val else default
# ── Version injection ──────────────────────────────────────────────────────────
def inject_versions(yaml_str: str, versions: dict) -> tuple[str, list[str]]:
"""Pin every `uses: owner/repo` (bare or already versioned) from versions.json."""
warnings: list[str] = []
def _pin(m: re.Match) -> str:
action = m.group(1)
key = "/".join(action.split("/")[:2])
ver = versions.get(key) or versions.get(action)
if not ver:
ver = FALLBACKS.get(key) or FALLBACKS.get(action)
if ver:
warnings.append(f" [fallback] {action}@{ver}")
else:
warnings.append(f" [missing] {action} — left unpinned")
return m.group(0)
# v-prefixed: keep major version only (e.g. v6.0.2 → v6)
if ver.startswith("v") and re.match(r"v\d+\.\d+", ver):
ver = "v" + ver[1:].split(".")[0]
return f"uses: {action}@{ver}"
result = re.sub(r"uses:\s+([\w./-]+)(?:@[\w.]+)?", _pin, yaml_str)
return result, warnings
# ── Shared YAML blocks ─────────────────────────────────────────────────────────
def _node_steps_ci(pkg_name: str, node_ver: str, pnpm_ver: str = "10") -> str:
"""CI pattern: pnpm/action-setup → setup-node (with cache) → install."""
cfg = PKG[pkg_name]
lines = [" - uses: actions/checkout"]
if cfg["extra_step"]:
lines += [
"",
f" - uses: {cfg['extra_step']}",
" with:",
f" version: {pnpm_ver}",
]
lines += [
"",
" - uses: actions/setup-node",
" with:",
f" node-version: {node_ver}",
f" cache: '{cfg['cache']}'",
"",
f" - run: {cfg['install']}",
]
return "\n".join(lines)
def _node_steps_deploy(pkg_name: str, node_ver: str, pnpm_ver: str = "10") -> str:
"""Deploy pattern: setup-node → pnpm/action-setup → install (no cache)."""
cfg = PKG[pkg_name]
lines = [
" - uses: actions/checkout",
"",
" - uses: actions/setup-node",
" with:",
f" node-version: {node_ver}",
]
if cfg["extra_step"]:
lines += [
"",
f" - uses: {cfg['extra_step']}",
" with:",
f" version: {pnpm_ver}",
]
lines += [
"",
f" - run: {cfg['install']}",
]
return "\n".join(lines)
def _concurrency(group_expr: str | None = None) -> str:
suffix = group_expr or G["ref"]
return (
"concurrency:\n"
f" group: {G['workflow']}-{suffix}\n"
" cancel-in-progress: true"
)
# ══════════════════════════════════════════════════════════════════════════════
# Templates
# ══════════════════════════════════════════════════════════════════════════════
# ── 1. CI ──────────────────────────────────────────────────────────────────────
CI_PARAMS = [
Param("node_version", "Node.js version", "24"),
Param("pkg_manager", "Package manager", "pnpm", ["pnpm", "npm", "yarn"]),
Param("branches", "Trigger branches", "main"),
Param("test_cmd", "Test script", "test"),
Param("lint_cmd", "Lint script", "lint"),
Param("build_cmd", "Build script", ""),
]
def ci_template(p: dict) -> str:
cfg = PKG[p["pkg_manager"]]
run = cfg["run"]
lint = f"\n - run: {run} {p['lint_cmd']}" if p["lint_cmd"] else ""
build = f"\n - run: {run} {p['build_cmd']}" if p["build_cmd"] else ""
if p.get("matrix"):
node_ver = G["matrix_node"]
strategy = (
" strategy:\n"
" matrix:\n"
f" node: [{p['matrix_versions']}]\n"
)
else:
node_ver = p["node_version"]
strategy = ""
return f"""name: {p['workflow_name']}
on:
push:
branches: [{p['branches']}]
pull_request:
permissions:
contents: read
{_concurrency()}
jobs:
test:
runs-on: ubuntu-latest
{strategy} steps:
{_node_steps_ci(p["pkg_manager"], node_ver, p.get("pnpm_version", "10"))}{lint}
- run: {run} {p['test_cmd']}{build}
"""
# ── 2. Release / Deploy ────────────────────────────────────────────────────────
RELEASE_PARAMS = [
Param("node_version", "Node.js version", "24"),
Param("pkg_manager", "Package manager", "pnpm", ["pnpm", "npm", "yarn"]),
Param("build_cmd", "Build script", "build"),
Param("publish_target", "Publish target", "cf-pages",
["cf-pages", "cf-worker", "github"]),
]
def _cf_config_step(p: dict) -> str:
return f"""
- name: Determine Cloudflare config
id: cf
run: |
if [[ "{G['gh_env']}" == "dev" ]]; then
echo "domain={p['dev_domain']}" >> $GITHUB_OUTPUT
else
echo "domain={p['release_domain']}" >> $GITHUB_OUTPUT
fi"""
def _cf_deploy_step(p: dict, target: str) -> str:
run_cmd = PKG[p["pkg_manager"]]["run"]
step_name = "Deploy to Cloudflare Pages" if target == "cf-pages" else "Deploy to Cloudflare Worker"
return f"""
- name: {step_name}
env:
CLOUDFLARE_API_TOKEN: ${{{{ secrets.CLOUDFLARE_API_TOKEN }}}}
CLOUDFLARE_ACCOUNT_ID: ${{{{ secrets.CLOUDFLARE_ACCOUNT_ID }}}}
run: |
if [[ "{G['gh_env']}" == "dev" ]]; then
{run_cmd} {p['dev_deploy_cmd']}
else
{run_cmd} {p['prod_deploy_cmd']}
fi"""
def _deploy_summary() -> str:
return f"""
- name: Deployment summary
run: |
cat << EOF >> $GITHUB_STEP_SUMMARY
## ✅ Deployed
- 🚀 Deployment completed!
- 🔧 Environment: {G['gh_env']}
- 🌿 Branch: {G['ref_name']}
- 🌐 Domain: https://{G['step_cf_domain']}
EOF"""
def _deploy_summary_no_domain() -> str:
return f"""
- name: Deployment summary
run: |
cat << EOF >> $GITHUB_STEP_SUMMARY
## ✅ Deployed
- 🚀 Deployment completed!
- 🔧 Environment: {G['gh_env']}
- 🌿 Branch: {G['ref_name']}
EOF"""
def _cf_trigger_header(workflow_name: str) -> str:
return f"""name: {workflow_name}
on:
push:
branches:
- dev
- dev*
- release
- release*
workflow_dispatch:
inputs:
environment:
description: Environment
required: true
default: dev
type: choice
options:
- dev
- release
permissions:
contents: read
deployments: write
jobs:
deploy:
runs-on: ubuntu-latest
environment: {G['deploy_env']}
env:
GH_ENV: {G['deploy_env']}
steps:"""
def cf_pages_template(p: dict) -> str:
return f"""{_cf_trigger_header(p['workflow_name'])}
{_node_steps_deploy(p["pkg_manager"], p["node_version"], p.get("pnpm_version", "10"))}
- run: pnpm install -g wrangler
- run: {PKG[p["pkg_manager"]]["run"]} {p["build_cmd"]}
{_cf_config_step(p)}
{_cf_deploy_step(p, "cf-pages")}
{_deploy_summary()}
"""
def cf_worker_template(p: dict) -> str:
return f"""{_cf_trigger_header(p['workflow_name'])}
{_node_steps_deploy(p["pkg_manager"], p["node_version"], p.get("pnpm_version", "10"))}
- run: pnpm install -g wrangler
{_cf_deploy_step(p, "cf-worker")}
{_deploy_summary_no_domain()}
"""
def github_release_template(p: dict) -> str:
cfg = PKG[p["pkg_manager"]]
return f"""name: {p['workflow_name']}
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
{_node_steps_ci(p["pkg_manager"], p["node_version"], p.get("pnpm_version", "10"))}
- run: {cfg["run"]} {p["build_cmd"]}
- name: Create GitHub Release
uses: softprops/action-gh-release
with:
generate_release_notes: true
"""
def release_template(p: dict) -> str:
target = p["publish_target"]
if target == "cf-pages":
return cf_pages_template(p)
elif target == "cf-worker":
return cf_worker_template(p)
else:
return github_release_template(p)
# ── 3. Docker ──────────────────────────────────────────────────────────────────
DOCKER_PARAMS = [
Param("registry", "Registry", "ghcr.io", ["ghcr.io", "docker.io"]),
Param("image_name", "Image name", G["repo"]),
Param("dockerfile", "Dockerfile path", "Dockerfile"),
Param("context", "Build context", "."),
Param("platforms", "Platforms", "linux/amd64"),
Param("branches", "Trigger branches", "main"),
]
def docker_template(p: dict) -> str:
if p["registry"] == "ghcr.io":
login_with = (
f" username: {G['actor']}\n"
f" password: {G['token']}"
)
else:
login_with = (
" username: ${{ secrets.DOCKER_USERNAME }}\n"
" password: ${{ secrets.DOCKER_PASSWORD }}"
)
return f"""name: {p['workflow_name']}
on:
push:
branches: [{p['branches']}]
tags: ['v*']
permissions:
contents: read
packages: write
env:
REGISTRY: {p['registry']}
IMAGE_NAME: {p['image_name']}
{_concurrency()}
jobs:
build-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout
- name: Log in to {p['registry']}
uses: docker/login-action
with:
{login_with}
- name: Extract metadata
id: meta
uses: docker/metadata-action
with:
images: {p['registry']}/{p['image_name']}
- name: Build and push
uses: docker/build-push-action
with:
context: {p['context']}
file: {p['dockerfile']}
push: true
tags: {G['meta_tags']}
labels: {G['meta_labels']}
platforms: {p['platforms']}
"""
# ── 4. PR Checks ───────────────────────────────────────────────────────────────
PR_PARAMS = [
Param("node_version", "Node.js version", "24"),
Param("pkg_manager", "Package manager", "pnpm", ["pnpm", "npm", "yarn"]),
Param("lint_cmd", "Lint script", "lint"),
Param("typecheck_cmd", "Type-check script", "typecheck"),
]
def pr_template(p: dict) -> str:
cfg = PKG[p["pkg_manager"]]
run = cfg["run"]
typecheck = f"\n - run: {run} {p['typecheck_cmd']}" if p["typecheck_cmd"] else ""
return f"""name: {p['workflow_name']}
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
{_concurrency(G['pr_ref'])}
jobs:
pr-check:
runs-on: ubuntu-latest
steps:
{_node_steps_ci(p["pkg_manager"], p["node_version"], p.get("pnpm_version", "10"))}
- run: {run} {p['lint_cmd']}{typecheck}
"""
# ══════════════════════════════════════════════════════════════════════════════
# Template registry
# ══════════════════════════════════════════════════════════════════════════════
TEMPLATES = {
"1": ("CI / Test", CI_PARAMS, ci_template),
"2": ("Release / Deploy", RELEASE_PARAMS, release_template),
"3": ("Docker Build & Push", DOCKER_PARAMS, docker_template),
"4": ("PR Checks", PR_PARAMS, pr_template),
}
# ── Interactive flow ───────────────────────────────────────────────────────────
def collect_params(params: list[Param]) -> dict:
return {p.key: ask(p) for p in params}
def run_interactive(versions: dict, out_dir: Path) -> None:
print(f"\n GitHub Actions Workflow Generator")
print(f" {'─' * 42}")
for key, (label, _, _) in TEMPLATES.items():
print(f" {key} {label}")
print(f" {'─' * 42}")
print(f" (type q to quit at any prompt)")
try:
choice = input(" Select template [1-4]: ").strip()
except (EOFError, KeyboardInterrupt):
print("\nAborted.")
return
if choice.lower() in _QUIT:
print(" Aborted.")
return
if choice not in TEMPLATES:
print(f" Invalid choice: {choice!r}")
return
label, params, render = TEMPLATES[choice]
print(f"\n ── {label} ──\n")
try:
p = collect_params(params)
# ── pnpm version (asked only when pnpm is selected) ───────────────────────
if p.get("pkg_manager") == "pnpm":
p["pnpm_version"] = ask(Param("pnpm_version", "pnpm version", "10"))
# ── Template-specific extras ───────────────────────────────────────────────
if choice == "1": # CI
if ask_yn("Enable matrix strategy (multiple Node versions)?"):
p["matrix"] = True
p["matrix_versions"] = ask(Param("mv", "Node versions (comma-separated)", "20, 22, 24"))
else:
p["matrix"] = False
if choice == "2": # Release / Deploy
target = p["publish_target"]
if target == "cf-pages":
print()
# CI domain: auto-expand prefix → prefix.aceapp.dev
raw_dev = ask(Param("dev_domain", "Dev domain", "project.aceapp.dev"))
dev_domain = raw_dev if "." in raw_dev else f"{raw_dev}.aceapp.dev"
p["dev_domain"] = dev_domain
# Release domain: smart-derive from dev domain, user can override
if ".aceapp.dev" in dev_domain:
release_default = dev_domain.replace(".aceapp.dev", ".ropean.org")
else:
release_default = "project.ropean.org"
p["release_domain"] = ask(Param("release_domain", "Release domain", release_default))
p["dev_deploy_cmd"] = ask(Param("dev_deploy_cmd", "Dev deploy script", "ship:dev:frontend"))
p["prod_deploy_cmd"] = ask(Param("prod_deploy_cmd", "Release deploy script", "ship:prod:frontend"))
elif target == "cf-worker":
print()
p["dev_deploy_cmd"] = ask(Param("dev_deploy_cmd", "Dev deploy script", "ship:dev:backend"))
p["prod_deploy_cmd"] = ask(Param("prod_deploy_cmd", "Release deploy script", "ship:prod:backend"))
# ── Auto-generate workflow name + filename ─────────────────────────────────
meta = WORKFLOW_META[choice]
if choice == "2":
wf_name, default_filename = meta[p["publish_target"]]
else:
wf_name, default_filename = meta["name"], meta["filename"]
p["workflow_name"] = wf_name
# ── Output filename ────────────────────────────────────────────────────────
print()
filename = ask(Param("filename", "Output filename", default_filename))
if not filename.endswith((".yml", ".yaml")):
filename += ".yml"
# ── Render + inject versions ───────────────────────────────────────────────
yaml_str = render(p)
yaml_str, warns = inject_versions(yaml_str, versions)
if warns:
print("\n Version notes:")
for w in warns:
print(w)
# ── Write ──────────────────────────────────────────────────────────────────
out_path = out_dir / filename
if out_path.exists():
if not ask_yn(f" {out_path} already exists. Overwrite?", default=True):
print(" Not written.")
return
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(yaml_str, encoding="utf-8", newline="\n")
print(f" OK {out_path}")
except UserAbort:
print("\n Aborted.")
# ── Entry point ────────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Interactive GitHub Actions workflow generator."
)
p.add_argument(
"-o", "--output",
default=".github/workflows",
metavar="DIR",
help="Output directory (default: .github/workflows/)",
)
p.add_argument(
"-v", "--versions",
default=str(DEFAULT_VERSIONS),
metavar="FILE",
help=f"Versions JSON file (default: {DEFAULT_VERSIONS.name})",
)
return p.parse_args()
def main() -> None:
args = parse_args()
versions_path = Path(args.versions)
if versions_path.exists():
versions: dict = json.loads(versions_path.read_text(encoding="utf-8"))
print(f"[versions] {len(versions)} entries from {versions_path.name}",
file=sys.stderr)
else:
versions = {}
print(f"[warn] {versions_path.name} not found — using built-in fallbacks only",
file=sys.stderr)
run_interactive(versions, Path(args.output))
if __name__ == "__main__":
main()File Information
- Filename:
github-build-workflow.py - Category: python
- Language: PYTHON