Skip to content

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

View on GitHub