Infrastructure·CI/CD & Automation·Beginner

Github Actions Deploy

Automate the complete trading bot deployment pipeline using GitHub Actions CI and CD workflows that run comprehensive test suites on push, build deterministic Docker container images, and deploy to production servers with zero-downtime rolling updates on merge to the main branch.

automationinfrastructure

GitHub Actions — Auto Deploy Bot


What You Will Learn

By the end of this notebook you will be able to:

SkillLevel
Understand what GitHub Actions is and why it existsBeginner
Generate valid YAML workflow files programmaticallyBeginner
Build a deploy-bot that creates/updates workflows via the GitHub APIIntermediate
Implement multi-environment deployments (dev / staging / prod)Intermediate
Add secret validation, rollback triggers, and Slack notificationsAdvanced
Package the bot as a reusable CLI toolAdvanced

Big Picture — What Is GitHub Actions?

Imagine you have a factory. Every time a new product design arrives, workers must:

  1. Test the design for defects
  2. Build a prototype
  3. Ship it to the right warehouse

GitHub Actions is the automation system for your code factory. It listens for events (a push, a pull request, a schedule) and then runs a series of steps — tests, builds, deployments — automatically, without a human clicking anything.

A Deploy Bot takes this further: it is a program that writes and manages those automation recipes (called workflow files) on your behalf, so you can deploy dozens of projects consistently without copy-pasting YAML by hand.

Developer pushes code
        │
        ▼
  GitHub detects event
        │
        ▼
  Reads .github/workflows/*.yml   ◄── Our bot WRITES these files
        │
        ▼
  Spins up a virtual machine (Runner)
        │
        ▼
  Executes steps: test → build → deploy
        │
        ▼
  Reports success / failure

Prerequisites

  • A GitHub Personal Access Token with repo and workflow scopes
    (Settings → Developer settings → Personal access tokens → Tokens (classic))
  • Python 3.8+ (provided by Colab)
  • Basic familiarity with Python functions and dictionaries

Never hardcode tokens. This notebook uses getpass to securely collect credentials at runtime.


Section 1 — Environment Setup

Before writing any automation logic, we need a small set of libraries:

  • requests — the standard Python HTTP client; used to talk to the GitHub REST API
  • PyYAML — converts Python dictionaries into YAML text (the format GitHub Actions uses)
  • rich — beautiful terminal output; makes logs readable at a glance

All three are available on PyPI and pre-installed or quickly installable in Colab.

[ ]
# Install required packages (Colab may already have some of these)
!pip install requests PyYAML rich --quiet

1.1 Imports and Global Constants

We collect all imports and constants in one place so they are easy to audit and modify.
Think of this cell as the "settings panel" for the entire notebook.

[ ]
"""
Imports and project-wide constants for the GitHub Actions Deploy Bot.
"""

import os
import json
import base64
import getpass
from datetime import datetime
from typing import Dict, List, Optional, Any

import requests          # HTTP calls to the GitHub API
import yaml              # Serialize Python dicts → YAML workflow files
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.syntax import Syntax
from rich import print as rprint

# ── Global constants ──────────────────────────────────────────────────────────
GITHUB_API_BASE = "https://api.github.com"   # Root URL for all API calls
WORKFLOW_DIR    = ".github/workflows"         # Convention GitHub requires
DEFAULT_RUNNER  = "ubuntu-latest"             # Virtual machine OS for jobs
DEFAULT_BRANCH  = "main"                      # Branch that triggers deployment

# Rich console for pretty-printed output throughout the notebook
console = Console()

print("Imports and constants loaded successfully.")
✅ Imports and constants loaded successfully.

Section 2 — Credential Management

Why Secure Credential Handling Matters

A GitHub Personal Access Token (PAT) is like a master key to your repositories.
If it leaks into a public notebook cell, anyone can read, modify, or delete your code.

The function below uses three layers of protection:

  1. getpass — hides input from the screen (like a password prompt)
  2. Environment variable storage — keeps the token in memory only, never on disk
  3. Validation — confirms the token works before any other code runs

Rule of thumb: If your token ever appears in a code cell output, regenerate it immediately.

[ ]
def setup_github_credentials() -> Dict[str, str]:
    """
    Securely collect and validate GitHub credentials from the user.

    Prompts for a Personal Access Token (PAT) and the target repository
    in 'owner/repo' format.  The token is stored only in memory via an
    environment variable — it is never written to disk or printed.

    Returns
    -------
    dict
        {
            'token'  : str  — the validated GitHub PAT,
            'owner'  : str  — repository owner (user or organisation),
            'repo'   : str  — repository name,
            'headers': dict — pre-built HTTP headers for API requests
        }

    Raises
    ------
    ValueError
        If the token is invalid or the repository cannot be found.

    Example
    -------
    >>> creds = setup_github_credentials()
    >>> print(creds['owner'])   # 'my-github-username'
    """

    # ── Step 1: Collect token without echoing it to the screen ────────────────
    token = getpass.getpass("🔑 Enter your GitHub Personal Access Token: ")

    # Store in environment variable so sub-processes can also use it
    os.environ["GITHUB_TOKEN"] = token

    # ── Step 2: Collect repository target ─────────────────────────────────────
    repo_full = input("📁 Enter repository (format: owner/repo-name): ").strip()

    # Split 'owner/repo' into separate components
    if "/" not in repo_full:
        raise ValueError(
            f"Repository must be in 'owner/repo' format. Got: '{repo_full}'"
        )

    owner, repo = repo_full.split("/", maxsplit=1)  # maxsplit=1 handles org/team/repo edge case

    # ── Step 3: Build standard headers used by every API request ──────────────
    headers = {
        "Authorization" : f"Bearer {token}",
        "Accept"        : "application/vnd.github+json",  # GitHub v3 JSON API
        "X-GitHub-Api-Version": "2022-11-28",             # Pin API version for stability
    }

    # ── Step 4: Validate by hitting the /repos endpoint ───────────────────────
    console.print(f"\n[cyan]🔍 Validating access to [bold]{repo_full}[/bold]...[/cyan]")

    response = requests.get(
        f"{GITHUB_API_BASE}/repos/{owner}/{repo}",
        headers=headers,
        timeout=10
    )

    if response.status_code == 200:
        repo_data = response.json()
        console.print(
            f"[green]✅ Connected to:[/green] [bold]{repo_data['full_name']}[/bold] "
            f"({'private' if repo_data['private'] else 'public'})"
        )
    elif response.status_code == 401:
        raise ValueError("❌ Token is invalid or expired. Please generate a new PAT.")
    elif response.status_code == 404:
        raise ValueError(
            f"❌ Repository '{repo_full}' not found. "
            "Check the name and ensure your token has 'repo' scope."
        )
    else:
        raise ValueError(f"❌ Unexpected API response: {response.status_code}")

    return {
        "token"  : token,
        "owner"  : owner,
        "repo"   : repo,
        "headers": headers,
    }


# ── Run credential setup ──────────────────────────────────────────────────────
# Uncomment the line below when you are ready to connect to a real repository.
# creds = setup_github_credentials()

print("ℹ️  setup_github_credentials() defined. Uncomment the last line to run it.")
ℹ️  setup_github_credentials() defined. Uncomment the last line to run it.

Section 3 — Workflow YAML Builder

Understanding GitHub Actions YAML Structure

Every GitHub Actions workflow is a YAML file stored in .github/workflows/.
Think of YAML as a nested dictionary — Python is excellent at generating it.

Workflow File Anatomy
─────────────────────
name: My Workflow           ← Human-readable label shown in the Actions tab

on:                         ← TRIGGER — what event starts this workflow?
  push:
    branches: [main]

jobs:                       ← JOBS — independent groups of steps
  deploy:                   ← job ID (arbitrary name)
    runs-on: ubuntu-latest  ← which virtual machine to use
    steps:                  ← STEPS — sequential commands inside the job
      - uses: actions/checkout@v4   ← a pre-built action from the marketplace
      - run: echo "Hello World"     ← a raw shell command

The function below takes simple Python parameters and produces this structure automatically.

[ ]
def build_deploy_workflow(
    workflow_name   : str,
    environment     : str,
    deploy_commands : List[str],
    trigger_branch  : str = DEFAULT_BRANCH,
    runner          : str = DEFAULT_RUNNER,
    python_version  : str = "3.11",
    notify_slack    : bool = False,
    extra_env_vars  : Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
    """
    Build a GitHub Actions deploy workflow as a Python dictionary.

    This function generates the complete structure of a deployment workflow
    that:
      1. Checks out the repository code
      2. Sets up Python
      3. Installs dependencies (from requirements.txt if present)
      4. Runs any user-supplied deploy commands
      5. Optionally sends a Slack notification on success or failure

    Parameters
    ----------
    workflow_name : str
        Human-readable label shown in the GitHub Actions UI tab.
        Example: 'Deploy to Production'

    environment : str
        Target environment name. Used in job names and for GitHub Environment
        protection rules. Typical values: 'dev', 'staging', 'production'.

    deploy_commands : list of str
        Shell commands that perform the actual deployment, executed in order.
        Example: ['python manage.py migrate', 'python manage.py collectstatic']

    trigger_branch : str, optional
        The git branch whose push events trigger this workflow.
        Default: 'main'

    runner : str, optional
        GitHub-hosted runner image.
        Default: 'ubuntu-latest'

    python_version : str, optional
        Python version string understood by actions/setup-python.
        Default: '3.11'

    notify_slack : bool, optional
        When True, adds a final step that posts a Slack message using the
        SLACK_WEBHOOK_URL secret.  Default: False.

    extra_env_vars : dict, optional
        Additional environment variables injected into every job step.
        Values that begin with '${{' are treated as GitHub expressions;
        others are treated as literal strings.
        Example: {'DATABASE_URL': '${{ secrets.DATABASE_URL }}'}

    Returns
    -------
    dict
        A nested Python dictionary that can be serialised to YAML with
        `yaml.dump(result, sort_keys=False)` and written to
        `.github/workflows/<name>.yml`.

    Example
    -------
    >>> wf = build_deploy_workflow(
    ...     workflow_name   = 'Deploy to Staging',
    ...     environment     = 'staging',
    ...     deploy_commands = ['./scripts/deploy.sh'],
    ...     trigger_branch  = 'develop',
    ... )
    >>> print(yaml.dump(wf, sort_keys=False))
    """

    # ── Base environment variables available to all steps ─────────────────────
    # ${{ secrets.X }} is GitHub's way to reference encrypted repository secrets
    base_env = {
        "DEPLOY_ENV"  : environment,
        "GITHUB_TOKEN": "${{ secrets.GITHUB_TOKEN }}",  # Auto-provided by GitHub
    }

    # Merge in any caller-supplied extra variables
    if extra_env_vars:
        base_env.update(extra_env_vars)

    # ── Build the list of job steps ───────────────────────────────────────────
    steps = [
        # Step 1: Check out the repository at the commit that triggered the run
        {
            "name": "📥 Checkout repository",
            "uses": "actions/checkout@v4",
        },

        # Step 2: Install the requested Python version on the runner VM
        {
            "name": "🐍 Set up Python",
            "uses": "actions/setup-python@v5",
            "with": {"python-version": python_version},
        },

        # Step 3: Cache pip packages — speeds up subsequent runs significantly
        {
            "name": "📦 Cache pip dependencies",
            "uses": "actions/cache@v4",
            "with": {
                "path": "~/.cache/pip",
                # Cache key changes whenever requirements.txt changes
                "key" : "${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}",
                "restore-keys": "${{ runner.os }}-pip-",
            },
        },

        # Step 4: Install Python dependencies (gracefully skips if file absent)
        {
            "name": "⚙️ Install dependencies",
            "run" : (
                "if [ -f requirements.txt ]; then "
                "pip install -r requirements.txt; "
                "fi"
            ),
        },
    ]

    # ── Step 5: Add each deploy command as its own named step ─────────────────
    # Splitting commands into separate steps gives clearer logs in the Actions UI
    for idx, cmd in enumerate(deploy_commands, start=1):
        steps.append({
            "name": f"🚀 Deploy step {idx}: {cmd[:50]}",  # Truncate long commands in name
            "run" : cmd,
        })

    # ── Step 6 (optional): Slack notification ─────────────────────────────────
    if notify_slack:
        steps.append({
            "name": "💬 Notify Slack",
            # 'if: always()' means this step runs even when previous steps fail
            "if"  : "always()",
            "uses": "slackapi/slack-github-action@v1.27.0",
            "with": {
                "payload": json.dumps({
                    "text": (
                        f"Deploy to *{environment}* — "
                        "${{ job.status == 'success' && '✅ Succeeded' || '❌ Failed' }}\n"
                        "Repo: ${{ github.repository }} | "
                        "Branch: ${{ github.ref_name }} | "
                        "By: ${{ github.actor }}"
                    )
                })
            },
            "env": {"SLACK_WEBHOOK_URL": "${{ secrets.SLACK_WEBHOOK_URL }}"},
        })

    # ── Assemble the complete workflow dictionary ──────────────────────────────
    workflow = {
        "name": workflow_name,

        # Trigger: run this workflow when code is pushed to trigger_branch
        "on": {
            "push": {"branches": [trigger_branch]},
            # Also allow manual runs from the GitHub Actions UI
            "workflow_dispatch": None,
        },

        # Define environment variables available to every job in this workflow
        "env": base_env,

        "jobs": {
            f"deploy-{environment}": {  # Job ID uses environment name for clarity
                "name"       : f"Deploy to {environment.capitalize()}",
                "runs-on"    : runner,
                # Link to a GitHub Environment for protection rules & approvals
                "environment": environment,
                "steps"      : steps,
            }
        },
    }

    return workflow


# ── Demo: build a staging workflow and display it ─────────────────────────────
sample_workflow = build_deploy_workflow(
    workflow_name   = "Deploy to Staging",
    environment     = "staging",
    deploy_commands = [
        "echo 'Running database migrations...'",
        "python manage.py migrate --no-input",
        "echo 'Deployment complete!'",
    ],
    trigger_branch  = "develop",
    notify_slack    = True,
    extra_env_vars  = {"DATABASE_URL": "${{ secrets.DATABASE_URL }}"},
)

# Convert to YAML and display with syntax highlighting
yaml_text = yaml.dump(sample_workflow, sort_keys=False, default_flow_style=False)
console.print(Panel(
    Syntax(yaml_text, "yaml", theme="monokai", line_numbers=True),
    title="[bold green]Generated Workflow YAML[/bold green]",
    border_style="green",
))
╭──────────────────────────────────────────── Generated Workflow YAML ────────────────────────────────────────────╮
    1 name: Deploy to Staging                                                                                    
    2 'on':                                                                                                      
    3   push:                                                                                                    
    4     branches:                                                                                              
    5     - develop                                                                                              
    6   workflow_dispatch: null                                                                                  
    7 env:                                                                                                       
    8   DEPLOY_ENV: staging                                                                                      
    9   GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}                                                                
   10   DATABASE_URL: ${{ secrets.DATABASE_URL }}                                                                
   11 jobs:                                                                                                      
   12   deploy-staging:                                                                                          
   13     name: Deploy to Staging                                                                                
   14     runs-on: ubuntu-latest                                                                                 
   15     environment: staging                                                                                   
   16     steps:                                                                                                 
   17     - name: "\U0001F4E5 Checkout repository"                                                               
   18       uses: actions/checkout@v4                                                                            
   19     - name: "\U0001F40D Set up Python"                                                                     
   20       uses: actions/setup-python@v5                                                                        
   21       with:                                                                                                
   22         python-version: '3.11'                                                                             
   23     - name: "\U0001F4E6 Cache pip dependencies"                                                            
   24       uses: actions/cache@v4                                                                               
   25       with:                                                                                                
   26         path: ~/.cache/pip                                                                                 
   27         key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}                                  
   28         restore-keys: ${{ runner.os }}-pip-                                                                
   29     - name: "\u2699\uFE0F Install dependencies"                                                            
   30       run: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi                            
   31     - name: "\U0001F680 Deploy step 1: echo 'Running database migrations...'"                              
   32       run: echo 'Running database migrations...'                                                           
   33     - name: "\U0001F680 Deploy step 2: python manage.py migrate --no-input"                                
   34       run: python manage.py migrate --no-input                                                             
   35     - name: "\U0001F680 Deploy step 3: echo 'Deployment complete!'"                                        
   36       run: echo 'Deployment complete!'                                                                     
   37     - name: "\U0001F4AC Notify Slack"                                                                      
   38       if: always()                                                                                         
   39       uses: slackapi/slack-github-action@v1.27.0                                                           
   40       with:                                                                                                
   41         payload: '{"text": "Deploy to *staging* \u2014 ${{ job.status == ''success''                       
   42           && ''\u2705 Succeeded'' || ''\u274c Failed'' }}\nRepo: ${{ github.repository                     
   43           }} | Branch: ${{ github.ref_name }} | By: ${{ github.actor }}"}'                                 
   44       env:                                                                                                 
   45         SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}                                                
   46                                                                                                            
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

Section 4 — Multi-Environment Workflow Generator

The Dev → Staging → Production Pipeline

Real-world deployments never go straight to production. The standard pattern is:

feature branch  →  develop branch  →  release branch  →  main branch
     │                   │                  │                  │
  (testing)           deploy to          deploy to         deploy to
                         dev             staging           production

Each environment can have different:

  • Trigger branches (develop → dev, release → staging, main → production)
  • Deploy commands (staging might run extra smoke tests)
  • Approval requirements (production requires a human to approve in GitHub)
  • Notification channels (production failures wake someone up at 2 AM)

The function below generates all three workflow files in one call.

[ ]
def generate_multi_environment_workflows(
    project_name       : str,
    base_deploy_command: str,
    environments       : Optional[List[Dict[str, Any]]] = None,
    python_version     : str = "3.11",
) -> Dict[str, Dict[str, Any]]:
    """
    Generate a complete set of deployment workflows for multiple environments.

    Creates three workflow definitions — dev, staging, and production — each
    with appropriate triggers, commands, and safety settings.
    Production includes an approval gate (GitHub Environment protection rule).

    Parameters
    ----------
    project_name : str
        Name of the project; used in workflow names and job labels.
        Example: 'my-api-service'

    base_deploy_command : str
        The core deployment shell command shared across all environments.
        Example: './scripts/deploy.sh'  or  'ansible-playbook deploy.yml'

    environments : list of dict, optional
        Override the default environment configuration. Each dict supports:
          - name (str)            : environment identifier
          - branch (str)          : git branch that triggers deploy
          - extra_commands (list) : additional steps after base_deploy_command
          - notify_slack (bool)   : whether to post Slack messages
          - extra_env_vars (dict) : additional environment variables
        If None, sensible defaults for dev/staging/production are used.

    python_version : str, optional
        Python version to install on the runner. Default: '3.11'

    Returns
    -------
    dict
        Keys are filename strings (e.g., 'deploy-production.yml'),
        values are the corresponding workflow dictionaries ready for YAML
        serialisation and upload to GitHub.

    Example
    -------
    >>> workflows = generate_multi_environment_workflows(
    ...     project_name        = 'ecommerce-backend',
    ...     base_deploy_command = 'docker compose up -d --build',
    ... )
    >>> for fname in workflows:
    ...     print(fname)
    deploy-dev.yml
    deploy-staging.yml
    deploy-production.yml
    """

    # ── Default environment configuration ─────────────────────────────────────
    # Each dict is a recipe for one environment
    default_environments = [
        {
            "name"          : "dev",
            "branch"        : "develop",
            "extra_commands": ["echo '✅ Dev deploy complete'"],
            "notify_slack"  : False,
            "extra_env_vars": {"LOG_LEVEL": "DEBUG"},
        },
        {
            "name"          : "staging",
            "branch"        : "release",
            # Staging always runs smoke tests after deploy to catch regressions
            "extra_commands": [
                "pytest tests/smoke/ -v --tb=short",
                "echo '✅ Staging smoke tests passed'",
            ],
            "notify_slack"  : True,
            "extra_env_vars": {
                "LOG_LEVEL"   : "INFO",
                "DATABASE_URL": "${{ secrets.STAGING_DATABASE_URL }}",
            },
        },
        {
            "name"          : "production",
            "branch"        : "main",
            # Production: full test suite + health check after deploy
            "extra_commands": [
                "pytest tests/ -v --tb=short -x",  # -x stops on first failure
                "curl --fail ${{ secrets.PROD_HEALTH_CHECK_URL }}",
                "echo '✅ Production deployment verified'",
            ],
            "notify_slack"  : True,   # Always notify on production events
            "extra_env_vars": {
                "LOG_LEVEL"   : "WARNING",
                "DATABASE_URL": "${{ secrets.PROD_DATABASE_URL }}",
            },
        },
    ]

    # Use caller-provided config if given, otherwise fall back to defaults
    env_configs = environments if environments is not None else default_environments

    # ── Generate one workflow per environment ──────────────────────────────────
    workflows = {}

    for env_cfg in env_configs:
        env_name = env_cfg["name"]

        # Combine the shared base command with environment-specific extras
        all_commands = [base_deploy_command] + env_cfg.get("extra_commands", [])

        # Build the workflow dictionary using our previously defined function
        workflow = build_deploy_workflow(
            workflow_name   = f"[{project_name}] Deploy to {env_name.capitalize()}",
            environment     = env_name,
            deploy_commands = all_commands,
            trigger_branch  = env_cfg.get("branch", "main"),
            python_version  = python_version,
            notify_slack    = env_cfg.get("notify_slack", False),
            extra_env_vars  = env_cfg.get("extra_env_vars"),
        )

        # Filename convention: deploy-<environment>.yml
        filename = f"deploy-{env_name}.yml"
        workflows[filename] = workflow

    return workflows


# ── Demo ──────────────────────────────────────────────────────────────────────
all_workflows = generate_multi_environment_workflows(
    project_name        = "my-web-app",
    base_deploy_command = "./scripts/deploy.sh",
)

# Summary table
table = Table(title="Generated Workflows", show_lines=True)
table.add_column("Filename",    style="cyan",  no_wrap=True)
table.add_column("Trigger",     style="green")
table.add_column("Steps",       style="yellow")
table.add_column("Slack Notify",style="magenta")

for fname, wf in all_workflows.items():
    job_key   = list(wf["jobs"].keys())[0]          # First (only) job
    job       = wf["jobs"][job_key]
    trigger   = list(wf["on"]["push"]["branches"])[0]
    num_steps = len(job["steps"])
    has_slack = any("Slack" in s.get("name", "") for s in job["steps"])

    table.add_row(fname, trigger, str(num_steps), "✅" if has_slack else "—")

console.print(table)
                   Generated Workflows                    
┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━┓
┃ Filename               Trigger  Steps  Slack Notify ┃
┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━┩
│ deploy-dev.yml         develop  6     │
├───────────────────────┼─────────┼───────┼──────────────┤
│ deploy-staging.yml     release  8     │
├───────────────────────┼─────────┼───────┼──────────────┤
│ deploy-production.yml  main     9     │
└───────────────────────┴─────────┴───────┴──────────────┘

Section 5 — GitHub API: Upload Workflow Files

How the GitHub Contents API Works

GitHub provides a REST endpoint that lets you create or update any file in a repository — including the workflow files in .github/workflows/.
This is exactly what our deploy bot uses to push workflows without needing git installed locally.

PUT /repos/{owner}/{repo}/contents/{path}
{
    "message": "commit message",
    "content": "<base64-encoded file content>",
    "sha"    : "<existing file SHA — required for updates, omit for new files>"
}

Key detail: file content must be base64-encoded. Python's base64 module handles this in one line.

Key detail 2: updating an existing file requires passing the current file's SHA hash — GitHub uses this as an optimistic concurrency check to prevent overwriting concurrent changes.

[ ]
def get_file_sha(
    owner   : str,
    repo    : str,
    path    : str,
    headers : Dict[str, str],
) -> Optional[str]:
    """
    Retrieve the current SHA hash of a file in a GitHub repository.

    When updating an existing file via the GitHub Contents API, you must
    supply the file's current SHA.  This function fetches that value.
    Returns None if the file does not yet exist — callers use this to
    distinguish between a create (no SHA needed) and an update.

    Parameters
    ----------
    owner   : str  — repository owner username or organisation
    repo    : str  — repository name
    path    : str  — file path within the repo, e.g. '.github/workflows/deploy.yml'
    headers : dict — pre-built GitHub API authentication headers

    Returns
    -------
    str or None
        The 40-character SHA string if the file exists, otherwise None.

    Example
    -------
    >>> sha = get_file_sha(owner, repo, '.github/workflows/deploy.yml', headers)
    >>> print(sha)   # 'a1b2c3...'  or  None
    """

    url      = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{path}"
    response = requests.get(url, headers=headers, timeout=10)

    if response.status_code == 200:
        # File exists — extract its SHA from the JSON response
        return response.json().get("sha")
    elif response.status_code == 404:
        # File does not exist yet — that's fine, caller will create it
        return None
    else:
        # Any other status is unexpected; surface it with context
        raise RuntimeError(
            f"Failed to check file SHA for '{path}': "
            f"HTTP {response.status_code}{response.text[:200]}"
        )


print("✅ get_file_sha() defined.")
✅ get_file_sha() defined.
[ ]
def upload_workflow_file(
    owner          : str,
    repo           : str,
    filename       : str,
    workflow_dict  : Dict[str, Any],
    headers        : Dict[str, str],
    commit_message : Optional[str] = None,
    dry_run        : bool = False,
) -> Dict[str, Any]:
    """
    Upload (create or update) a GitHub Actions workflow file via the GitHub API.

    Converts the workflow dictionary to YAML, base64-encodes it, then calls
    the GitHub Contents API to commit it directly to the repository's
    `.github/workflows/` directory.  Handles both new file creation and
    updates to existing files automatically.

    Parameters
    ----------
    owner          : str  — repository owner
    repo           : str  — repository name
    filename       : str  — name of the workflow file, e.g. 'deploy-prod.yml'
    workflow_dict  : dict — workflow structure from build_deploy_workflow()
    headers        : dict — GitHub API authentication headers
    commit_message : str, optional
        Git commit message for the upload.  Defaults to an auto-generated
        timestamped message.
    dry_run        : bool, optional
        When True, generate and display the YAML but do NOT call the API.
        Useful for previewing changes before committing.  Default: False.

    Returns
    -------
    dict
        {
          'status'   : 'created' | 'updated' | 'dry_run',
          'filename' : str,
          'path'     : str,
          'commit_sha': str or None,
          'url'      : str or None
        }

    Raises
    ------
    requests.HTTPError
        If the API returns a non-2xx status code.

    Example
    -------
    >>> result = upload_workflow_file(
    ...     owner         = 'my-org',
    ...     repo          = 'my-app',
    ...     filename      = 'deploy-staging.yml',
    ...     workflow_dict = staging_workflow,
    ...     headers       = creds['headers'],
    ...     dry_run       = True,
    ... )
    """

    # ── Convert workflow dict → YAML text ─────────────────────────────────────
    yaml_content = yaml.dump(
        workflow_dict,
        sort_keys=False,        # Preserve our intentional key ordering
        default_flow_style=False,  # Use block style (readable multi-line)
        allow_unicode=True,
    )

    # ── Dry-run mode: show YAML but skip the API call ─────────────────────────
    if dry_run:
        console.print(Panel(
            Syntax(yaml_content, "yaml", theme="monokai", line_numbers=True),
            title=f"[bold yellow]🔍 DRY RUN — {filename}[/bold yellow]",
            border_style="yellow",
        ))
        return {
            "status"    : "dry_run",
            "filename"  : filename,
            "path"      : f"{WORKFLOW_DIR}/{filename}",
            "commit_sha": None,
            "url"       : None,
        }

    # ── Encode YAML content to base64 (required by GitHub Contents API) ───────
    content_b64 = base64.b64encode(yaml_content.encode("utf-8")).decode("utf-8")

    # ── Build the target file path inside the repository ──────────────────────
    file_path = f"{WORKFLOW_DIR}/{filename}"

    # ── Check whether the file already exists (needed for update vs create) ───
    existing_sha = get_file_sha(owner, repo, file_path, headers)
    operation    = "updated" if existing_sha else "created"

    # ── Build the request payload ─────────────────────────────────────────────
    timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
    payload   = {
        "message": commit_message or f"bot: {operation} {filename} [{timestamp}]",
        "content": content_b64,
    }

    # Include SHA only when updating — omitting it for new files
    if existing_sha:
        payload["sha"] = existing_sha

    # ── Make the API request ───────────────────────────────────────────────────
    url      = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{file_path}"
    response = requests.put(url, headers=headers, json=payload, timeout=15)

    if response.status_code not in (200, 201):
        response.raise_for_status()  # Surface error with HTTP details

    response_data = response.json()

    # ── Log success ───────────────────────────────────────────────────────────
    console.print(
        f"[green]{'✅ Created' if operation == 'created' else '🔄 Updated'}:[/green] "
        f"[bold]{file_path}[/bold]"
    )

    return {
        "status"    : operation,
        "filename"  : filename,
        "path"      : file_path,
        "commit_sha": response_data["commit"]["sha"],
        "url"       : response_data["content"]["html_url"],
    }


print("✅ upload_workflow_file() defined.")
✅ upload_workflow_file() defined.

Section 6 — The Deploy Bot Orchestrator

Putting It All Together

The functions we've built so far are like individual Lego bricks.
This section creates the orchestrator — the master function that snaps all the bricks together into a complete, working deploy bot.

deploy_bot()
    │
    ├── validate_deployment_config()   ← Check inputs before touching GitHub
    │
    ├── generate_multi_environment_workflows()  ← Build all workflow YAMLs
    │
    ├── for each workflow:
    │       upload_workflow_file()     ← Commit to .github/workflows/
    │
    └── print_deployment_report()     ← Summary of what was deployed

The orchestrator also supports rollback — if any upload fails mid-way, it can delete the files that were successfully uploaded, leaving the repository in its original state.

[ ]
def validate_deployment_config(
    project_name       : str,
    base_deploy_command: str,
    environments       : List[str],
) -> bool:
    """
    Validate a deployment configuration before any API calls are made.

    Performs lightweight checks that catch common mistakes early,
    before any changes are pushed to GitHub.  Think of this as a
    pre-flight checklist a pilot runs before takeoff.

    Checks performed
    ----------------
    1. project_name: non-empty, no special characters that break filenames
    2. base_deploy_command: non-empty string
    3. environments: non-empty list, all values from the allowed set

    Parameters
    ----------
    project_name        : str  — project identifier used in workflow names
    base_deploy_command : str  — shell command to deploy the project
    environments        : list — target environment names to validate

    Returns
    -------
    bool
        True if all checks pass.

    Raises
    ------
    ValueError
        With a descriptive message for the first failed check.

    Example
    -------
    >>> validate_deployment_config('my-app', './deploy.sh', ['dev', 'staging'])
    True
    """

    import re

    # ── Check 1: Project name ─────────────────────────────────────────────────
    if not project_name or not project_name.strip():
        raise ValueError("project_name cannot be empty.")

    # Only allow letters, numbers, hyphens, and underscores
    if not re.match(r'^[\w\-]+$', project_name):
        raise ValueError(
            f"project_name '{project_name}' contains invalid characters. "
            "Use only letters, numbers, hyphens, and underscores."
        )

    # ── Check 2: Deploy command ────────────────────────────────────────────────
    if not base_deploy_command or not base_deploy_command.strip():
        raise ValueError("base_deploy_command cannot be empty.")

    # Warn (but don't block) on suspicious command patterns
    dangerous_patterns = ["rm -rf /", "format c:", "dd if=" ]
    for pattern in dangerous_patterns:
        if pattern in base_deploy_command.lower():
            raise ValueError(
                f"base_deploy_command contains a potentially destructive pattern: '{pattern}'"
            )

    # ── Check 3: Environments ─────────────────────────────────────────────────
    allowed_envs = {"dev", "staging", "production", "test", "qa", "sandbox"}

    if not environments:
        raise ValueError("environments list cannot be empty.")

    for env in environments:
        if env not in allowed_envs:
            raise ValueError(
                f"Unknown environment: '{env}'. "
                f"Allowed values: {sorted(allowed_envs)}"
            )

    console.print("[green]✅ Configuration validation passed.[/green]")
    return True


# ── Test validate_deployment_config ───────────────────────────────────────────
# Valid config
validate_deployment_config(
    project_name        = "my-web-app",
    base_deploy_command = "./scripts/deploy.sh",
    environments        = ["dev", "staging", "production"],
)

# Invalid config — uncomment to test error handling
# validate_deployment_config(
#     project_name        = "my app!",   # spaces and ! are invalid
#     base_deploy_command = "./deploy.sh",
#     environments        = ["dev"],
# )
✅ Configuration validation passed.
True
[ ]
def deploy_bot(
    creds              : Dict[str, str],
    project_name       : str,
    base_deploy_command: str,
    target_environments: Optional[List[str]] = None,
    dry_run            : bool = True,
    python_version     : str = "3.11",
    rollback_on_error  : bool = True,
) -> Dict[str, Any]:
    """
    Main orchestrator: generate and deploy GitHub Actions workflows for a project.

    This is the top-level entry point for the deploy bot.  It validates the
    configuration, generates workflow YAML for each requested environment,
    uploads them to GitHub, and prints a deployment report.  If any upload
    fails and rollback_on_error is True, previously uploaded files are deleted
    to restore the repository to its pre-bot state.

    Parameters
    ----------
    creds : dict
        Credentials dict from setup_github_credentials().  Must contain
        'owner', 'repo', and 'headers' keys.

    project_name : str
        Project identifier used in workflow names and file labels.
        Example: 'payment-service'

    base_deploy_command : str
        Core shell command that performs the deployment.
        Example: 'kubectl apply -f k8s/'

    target_environments : list of str, optional
        Which environments to create workflows for.
        Defaults to ['dev', 'staging', 'production'].

    dry_run : bool, optional
        When True (default), display workflow YAMLs without calling the API.
        Set to False to actually commit files to GitHub.

    python_version : str, optional
        Python version for the runner.  Default: '3.11'.

    rollback_on_error : bool, optional
        If True and an upload fails partway through, previously created
        workflow files are deleted from GitHub.  Default: True.

    Returns
    -------
    dict
        {
          'project'       : str,
          'dry_run'       : bool,
          'total_workflows': int,
          'results'       : list of upload result dicts,
          'errors'        : list of error message strings
        }

    Example
    -------
    >>> report = deploy_bot(
    ...     creds               = creds,
    ...     project_name        = 'my-app',
    ...     base_deploy_command = './deploy.sh',
    ...     dry_run             = True,
    ... )
    """

    # ── Defaults ──────────────────────────────────────────────────────────────
    if target_environments is None:
        target_environments = ["dev", "staging", "production"]

    owner   = creds["owner"]
    repo    = creds["repo"]
    headers = creds["headers"]

    results = []   # Accumulate successful upload results for the final report
    errors  = []   # Collect any error messages

    console.print(Panel(
        f"[bold]Project:[/bold] {project_name}\n"
        f"[bold]Repository:[/bold] {owner}/{repo}\n"
        f"[bold]Environments:[/bold] {', '.join(target_environments)}\n"
        f"[bold]Deploy Command:[/bold] {base_deploy_command}\n"
        f"[bold]Mode:[/bold] {'🔍 DRY RUN' if dry_run else '🚀 LIVE DEPLOY'}",
        title="[bold blue]🤖 Deploy Bot Starting[/bold blue]",
        border_style="blue",
    ))

    # ── Step 1: Validate configuration before touching GitHub ──────────────────
    validate_deployment_config(project_name, base_deploy_command, target_environments)

    # ── Step 2: Generate workflow files for the requested environments ─────────
    # Filter default_environments to only those in target_environments
    all_workflows = generate_multi_environment_workflows(
        project_name        = project_name,
        base_deploy_command = base_deploy_command,
        python_version      = python_version,
    )

    # Keep only the environments the caller requested
    filtered_workflows = {
        fname: wf
        for fname, wf in all_workflows.items()
        if any(env in fname for env in target_environments)
    }

    console.print(f"\n[cyan]📄 Generated {len(filtered_workflows)} workflow file(s)[/cyan]")

    # ── Step 3: Upload each workflow file ─────────────────────────────────────
    for filename, workflow_dict in filtered_workflows.items():
        try:
            result = upload_workflow_file(
                owner         = owner,
                repo          = repo,
                filename      = filename,
                workflow_dict = workflow_dict,
                headers       = headers,
                dry_run       = dry_run,
            )
            results.append(result)

        except Exception as exc:
            error_msg = f"Failed to upload {filename}: {str(exc)}"
            errors.append(error_msg)
            console.print(f"[red]❌ {error_msg}[/red]")

            # ── Rollback: delete successfully uploaded files ───────────────────
            if rollback_on_error and not dry_run and results:
                console.print("[yellow]⏪ Rolling back previously uploaded files...[/yellow]")
                _rollback_uploads(owner, repo, results, headers)

            break  # Stop processing further files after an error

    # ── Step 4: Print final report ────────────────────────────────────────────
    _print_deployment_report(
        project_name = project_name,
        results      = results,
        errors       = errors,
        dry_run      = dry_run,
    )

    return {
        "project"         : project_name,
        "dry_run"         : dry_run,
        "total_workflows" : len(filtered_workflows),
        "results"         : results,
        "errors"          : errors,
    }


print("✅ deploy_bot() defined.")
✅ deploy_bot() defined.

Section 7 — Helper Functions (Rollback & Reporting)

Good automation tools always have a safety net.
The rollback function and reporting function are that safety net — they are called by deploy_bot() internally but are also useful independently for debugging and auditing.

[ ]
def _rollback_uploads(
    owner   : str,
    repo    : str,
    results : List[Dict[str, Any]],
    headers : Dict[str, str],
) -> None:
    """
    Delete files that were successfully uploaded during a failed deploy run.

    Called internally by deploy_bot() when rollback_on_error=True and a
    mid-run upload fails.  Removes only the files in `results` (i.e., the
    ones the current bot run created), leaving pre-existing files untouched.

    Parameters
    ----------
    owner   : str               — repository owner
    repo    : str               — repository name
    results : list of dict      — upload results from successful upload_workflow_file() calls
    headers : dict              — GitHub API authentication headers

    Returns
    -------
    None  (side-effect: deletes files from GitHub)

    Notes
    -----
    Only deletes files whose status was 'created' (i.e., new files).  Files
    that were 'updated' are not rolled back because we do not store the prior
    content in this version of the bot.
    """

    for result in results:
        # Only delete files we newly created; skip pre-existing files we updated
        if result.get("status") != "created":
            continue

        file_path = result["path"]

        # Need the current SHA to delete a file via the GitHub API
        sha = get_file_sha(owner, repo, file_path, headers)

        if sha is None:
            # File already gone — nothing to do
            continue

        url     = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{file_path}"
        payload = {
            "message": f"bot: rollback — remove {result['filename']}",
            "sha"    : sha,
        }

        response = requests.delete(url, headers=headers, json=payload, timeout=10)

        if response.status_code == 200:
            console.print(f"[yellow]  ⏪ Rolled back: {file_path}[/yellow]")
        else:
            console.print(
                f"[red]  ⚠️ Could not roll back {file_path}: "
                f"HTTP {response.status_code}[/red]"
            )


def _print_deployment_report(
    project_name : str,
    results      : List[Dict[str, Any]],
    errors       : List[str],
    dry_run      : bool,
) -> None:
    """
    Print a formatted summary table of the deployment bot run.

    Displays which workflow files were created/updated, their GitHub URLs,
    and any errors that occurred.  Uses the Rich library for readable output.

    Parameters
    ----------
    project_name : str            — project name for the report header
    results      : list of dict   — upload results from upload_workflow_file()
    errors       : list of str    — error messages from failed uploads
    dry_run      : bool           — True if this was a dry run

    Returns
    -------
    None  (side-effect: prints to console)
    """

    mode_label = "🔍 DRY RUN" if dry_run else "🚀 DEPLOYED"

    table = Table(
        title=f"Deploy Bot Report — {project_name} [{mode_label}]",
        show_lines=True,
    )
    table.add_column("Workflow File",   style="cyan",    no_wrap=True)
    table.add_column("Status",          style="green")
    table.add_column("Commit / Note",   style="yellow")

    for r in results:
        status = r["status"].upper()
        note   = (
            r["commit_sha"][:10] + "..." if r.get("commit_sha")
            else "(dry run — no commit)"
        )
        table.add_row(r["filename"], status, note)

    for err in errors:
        table.add_row("—", "[red]ERROR[/red]", err[:80])

    console.print(table)

    # Summary line
    total   = len(results) + len(errors)
    success = len(results)
    emoji   = "✅" if not errors else "⚠️"
    console.print(
        f"\n{emoji} {success}/{total} workflow(s) {'previewed' if dry_run else 'deployed'} successfully."
    )


print("✅ _rollback_uploads() and _print_deployment_report() defined.")
✅ _rollback_uploads() and _print_deployment_report() defined.

Section 8 — Running the Deploy Bot (Dry Run Demo)

Now let's put the whole bot together with a dry run — it will generate all the YAML and print the report, but will not make any changes to GitHub.

To run against a real repository:

  1. Uncomment creds = setup_github_credentials() in Section 2 and run it
  2. Change dry_run=True to dry_run=False in the cell below
[ ]
# ── Simulate credentials for the dry-run demo ─────────────────────────────────
# In a real run, replace this with:  creds = setup_github_credentials()
DEMO_CREDS = {
    "token"  : "ghp_DEMO_TOKEN",
    "owner"  : "my-organisation",
    "repo"   : "my-web-app",
    "headers": {
        "Authorization"       : "Bearer ghp_DEMO_TOKEN",
        "Accept"              : "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
    },
}

# ── Run the deploy bot in dry-run mode ────────────────────────────────────────
report = deploy_bot(
    creds               = DEMO_CREDS,
    project_name        = "my-web-app",
    base_deploy_command = "./scripts/deploy.sh --env=$DEPLOY_ENV",
    target_environments = ["dev", "staging", "production"],
    dry_run             = True,   # ← Change to False for a real deployment
    python_version      = "3.11",
    rollback_on_error   = True,
)
╭──────────────────────────────────────────── 🤖 Deploy Bot Starting ─────────────────────────────────────────────╮
 Project: my-web-app                                                                                             
 Repository: my-organisation/my-web-app                                                                          
 Environments: dev, staging, production                                                                          
 Deploy Command: ./scripts/deploy.sh --env=$DEPLOY_ENV                                                           
 Mode: 🔍 DRY RUN                                                                                                
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✅ Configuration validation passed.
📄 Generated 3 workflow file(s)
╭────────────────────────────────────────── 🔍 DRY RUN — deploy-dev.yml ──────────────────────────────────────────╮
    1 name: '[my-web-app] Deploy to Dev'                                                                         
    2 'on':                                                                                                      
    3   push:                                                                                                    
    4     branches:                                                                                              
    5     - develop                                                                                              
    6   workflow_dispatch: null                                                                                  
    7 env:                                                                                                       
    8   DEPLOY_ENV: dev                                                                                          
    9   GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}                                                                
   10   LOG_LEVEL: DEBUG                                                                                         
   11 jobs:                                                                                                      
   12   deploy-dev:                                                                                              
   13     name: Deploy to Dev                                                                                    
   14     runs-on: ubuntu-latest                                                                                 
   15     environment: dev                                                                                       
   16     steps:                                                                                                 
   17     - name: 📥 Checkout repository                                                                         
   18       uses: actions/checkout@v4                                                                            
   19     - name: 🐍 Set up Python                                                                               
   20       uses: actions/setup-python@v5                                                                        
   21       with:                                                                                                
   22         python-version: '3.11'                                                                             
   23     - name: 📦 Cache pip dependencies                                                                      
   24       uses: actions/cache@v4                                                                               
   25       with:                                                                                                
   26         path: ~/.cache/pip                                                                                 
   27         key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}                                  
   28         restore-keys: ${{ runner.os }}-pip-                                                                
   29     - name: ⚙️ Install dependencies                                                                         
   30       run: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi                            
   31     - name: '🚀 Deploy step 1: ./scripts/deploy.sh --env=$DEPLOY_ENV'                                      
   32       run: ./scripts/deploy.sh --env=$DEPLOY_ENV                                                           
   33     - name: '🚀 Deploy step 2: echo '' Dev deploy complete'''                                            
   34       run: echo '✅ Dev deploy complete'                                                                   
   35                                                                                                            
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────── 🔍 DRY RUN — deploy-staging.yml ────────────────────────────────────────╮
    1 name: '[my-web-app] Deploy to Staging'                                                                     
    2 'on':                                                                                                      
    3   push:                                                                                                    
    4     branches:                                                                                              
    5     - release                                                                                              
    6   workflow_dispatch: null                                                                                  
    7 env:                                                                                                       
    8   DEPLOY_ENV: staging                                                                                      
    9   GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}                                                                
   10   LOG_LEVEL: INFO                                                                                          
   11   DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}                                                        
   12 jobs:                                                                                                      
   13   deploy-staging:                                                                                          
   14     name: Deploy to Staging                                                                                
   15     runs-on: ubuntu-latest                                                                                 
   16     environment: staging                                                                                   
   17     steps:                                                                                                 
   18     - name: 📥 Checkout repository                                                                         
   19       uses: actions/checkout@v4                                                                            
   20     - name: 🐍 Set up Python                                                                               
   21       uses: actions/setup-python@v5                                                                        
   22       with:                                                                                                
   23         python-version: '3.11'                                                                             
   24     - name: 📦 Cache pip dependencies                                                                      
   25       uses: actions/cache@v4                                                                               
   26       with:                                                                                                
   27         path: ~/.cache/pip                                                                                 
   28         key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}                                  
   29         restore-keys: ${{ runner.os }}-pip-                                                                
   30     - name: ⚙️ Install dependencies                                                                         
   31       run: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi                            
   32     - name: '🚀 Deploy step 1: ./scripts/deploy.sh --env=$DEPLOY_ENV'                                      
   33       run: ./scripts/deploy.sh --env=$DEPLOY_ENV                                                           
   34     - name: '🚀 Deploy step 2: pytest tests/smoke/ -v --tb=short'                                          
   35       run: pytest tests/smoke/ -v --tb=short                                                               
   36     - name: '🚀 Deploy step 3: echo '' Staging smoke tests passed'''                                     
   37       run: echo '✅ Staging smoke tests passed'                                                            
   38     - name: 💬 Notify Slack                                                                                
   39       if: always()                                                                                         
   40       uses: slackapi/slack-github-action@v1.27.0                                                           
   41       with:                                                                                                
   42         payload: '{"text": "Deploy to *staging* \u2014 ${{ job.status == ''success''                       
   43           && ''\u2705 Succeeded'' || ''\u274c Failed'' }}\nRepo: ${{ github.repository                     
   44           }} | Branch: ${{ github.ref_name }} | By: ${{ github.actor }}"}'                                 
   45       env:                                                                                                 
   46         SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}                                                
   47                                                                                                            
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────── 🔍 DRY RUN — deploy-production.yml ───────────────────────────────────────╮
    1 name: '[my-web-app] Deploy to Production'                                                                  
    2 'on':                                                                                                      
    3   push:                                                                                                    
    4     branches:                                                                                              
    5     - main                                                                                                 
    6   workflow_dispatch: null                                                                                  
    7 env:                                                                                                       
    8   DEPLOY_ENV: production                                                                                   
    9   GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}                                                                
   10   LOG_LEVEL: WARNING                                                                                       
   11   DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}                                                           
   12 jobs:                                                                                                      
   13   deploy-production:                                                                                       
   14     name: Deploy to Production                                                                             
   15     runs-on: ubuntu-latest                                                                                 
   16     environment: production                                                                                
   17     steps:                                                                                                 
   18     - name: 📥 Checkout repository                                                                         
   19       uses: actions/checkout@v4                                                                            
   20     - name: 🐍 Set up Python                                                                               
   21       uses: actions/setup-python@v5                                                                        
   22       with:                                                                                                
   23         python-version: '3.11'                                                                             
   24     - name: 📦 Cache pip dependencies                                                                      
   25       uses: actions/cache@v4                                                                               
   26       with:                                                                                                
   27         path: ~/.cache/pip                                                                                 
   28         key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}                                  
   29         restore-keys: ${{ runner.os }}-pip-                                                                
   30     - name: ⚙️ Install dependencies                                                                         
   31       run: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi                            
   32     - name: '🚀 Deploy step 1: ./scripts/deploy.sh --env=$DEPLOY_ENV'                                      
   33       run: ./scripts/deploy.sh --env=$DEPLOY_ENV                                                           
   34     - name: '🚀 Deploy step 2: pytest tests/ -v --tb=short -x'                                             
   35       run: pytest tests/ -v --tb=short -x                                                                  
   36     - name: '🚀 Deploy step 3: curl --fail ${{ secrets.PROD_HEALTH_CHECK_URL }}'                           
   37       run: curl --fail ${{ secrets.PROD_HEALTH_CHECK_URL }}                                                
   38     - name: '🚀 Deploy step 4: echo '' Production deployment verified'''                                 
   39       run: echo '✅ Production deployment verified'                                                        
   40     - name: 💬 Notify Slack                                                                                
   41       if: always()                                                                                         
   42       uses: slackapi/slack-github-action@v1.27.0                                                           
   43       with:                                                                                                
   44         payload: '{"text": "Deploy to *production* \u2014 ${{ job.status == ''success''                    
   45           && ''\u2705 Succeeded'' || ''\u274c Failed'' }}\nRepo: ${{ github.repository                     
   46           }} | Branch: ${{ github.ref_name }} | By: ${{ github.actor }}"}'                                 
   47       env:                                                                                                 
   48         SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}                                                
   49                                                                                                            
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
        Deploy Bot Report — my-web-app [🔍 DRY RUN]        
┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Workflow File          Status   Commit / Note         ┃
┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━┩
│ deploy-dev.yml         DRY_RUN  (dry run — no commit) │
├───────────────────────┼─────────┼───────────────────────┤
│ deploy-staging.yml     DRY_RUN  (dry run — no commit) │
├───────────────────────┼─────────┼───────────────────────┤
│ deploy-production.yml  DRY_RUN  (dry run — no commit) │
└───────────────────────┴─────────┴───────────────────────┘
3/3 workflow(s) previewed successfully.

Section 9 — Advanced: Workflow Templates Library

Reusable Templates for Common Deployment Scenarios

Different technology stacks need different deployment recipes.
A Django app needs database migrations. A static React site just needs to sync files to S3. A Docker service needs to build and push an image.

The template library below provides ready-to-use configurations for the most common scenarios. Each template is a dictionary of parameters that slot directly into deploy_bot().

[ ]
def get_workflow_template(template_name: str) -> Dict[str, Any]:
    """
    Return a pre-configured deploy bot parameter set for a named technology stack.

    Each template is a battle-tested configuration for a common deployment
    scenario.  Pass the returned dict directly to deploy_bot() via **unpacking.

    Available Templates
    -------------------
    'django'     — Django/Python web app with Gunicorn + Nginx
    'fastapi'    — FastAPI/Python REST API with Uvicorn
    'react-s3'   — React SPA deployed to AWS S3 + CloudFront
    'docker'     — Dockerised service pushed to a registry and redeployed
    'flask'      — Flask app with basic Gunicorn deployment

    Parameters
    ----------
    template_name : str
        One of the template keys listed above.

    Returns
    -------
    dict
        Keyword arguments suitable for deploy_bot(), excluding 'creds'.

    Raises
    ------
    KeyError
        If template_name is not in the known templates.

    Example
    -------
    >>> tmpl = get_workflow_template('django')
    >>> report = deploy_bot(creds=creds, **tmpl)
    """

    templates = {

        # ── Django template ───────────────────────────────────────────────────
        "django": {
            "project_name"        : "django-app",
            "base_deploy_command" : (
                "python manage.py migrate --no-input && "
                "python manage.py collectstatic --no-input && "
                "sudo systemctl restart gunicorn"
            ),
            "target_environments" : ["staging", "production"],
            "python_version"      : "3.11",
        },

        # ── FastAPI template ──────────────────────────────────────────────────
        "fastapi": {
            "project_name"        : "fastapi-service",
            "base_deploy_command" : (
                "alembic upgrade head && "
                "sudo systemctl restart uvicorn"
            ),
            "target_environments" : ["dev", "staging", "production"],
            "python_version"      : "3.11",
        },

        # ── React → S3 template ───────────────────────────────────────────────
        # Note: requires AWS credentials as GitHub secrets
        "react-s3": {
            "project_name"        : "react-frontend",
            "base_deploy_command" : (
                "npm ci && "
                "npm run build && "
                "aws s3 sync ./build s3://${{ secrets.S3_BUCKET }} --delete && "
                "aws cloudfront create-invalidation "
                "--distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} --paths '/*'"
            ),
            "target_environments" : ["staging", "production"],
            "python_version"      : "3.11",  # Python still needed for YAML gen
        },

        # ── Docker template ───────────────────────────────────────────────────
        # Builds, pushes, and restarts a Docker-based service
        "docker": {
            "project_name"        : "docker-service",
            "base_deploy_command" : (
                "docker build -t ${{ secrets.REGISTRY }}/${{ github.repository }}:${{ github.sha }} . && "
                "docker push ${{ secrets.REGISTRY }}/${{ github.repository }}:${{ github.sha }} && "
                "docker compose pull && docker compose up -d"
            ),
            "target_environments" : ["staging", "production"],
            "python_version"      : "3.11",
        },

        # ── Flask template ────────────────────────────────────────────────────
        "flask": {
            "project_name"        : "flask-app",
            "base_deploy_command" : (
                "flask db upgrade && "
                "sudo systemctl restart gunicorn-flask"
            ),
            "target_environments" : ["dev", "staging", "production"],
            "python_version"      : "3.11",
        },
    }

    if template_name not in templates:
        available = ", ".join(sorted(templates.keys()))
        raise KeyError(
            f"Unknown template '{template_name}'. Available: {available}"
        )

    return templates[template_name]


# ── Demo: show all available templates in a table ────────────────────────────
template_table = Table(title="Available Workflow Templates", show_lines=True)
template_table.add_column("Template",     style="cyan", no_wrap=True)
template_table.add_column("Project Name", style="green")
template_table.add_column("Environments", style="yellow")
template_table.add_column("Deploy Command (truncated)", style="white")

for name in ["django", "fastapi", "react-s3", "docker", "flask"]:
    tmpl = get_workflow_template(name)
    template_table.add_row(
        name,
        tmpl["project_name"],
        ", ".join(tmpl["target_environments"]),
        tmpl["base_deploy_command"][:55] + "...",
    )

console.print(template_table)
                                           Available Workflow Templates                                            
┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Template  Project Name     Environments              Deploy Command (truncated)                              ┃
┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ django    django-app       staging, production       python manage.py migrate --no-input && python           │
│                                                      manage.py...                                            │
├──────────┼─────────────────┼──────────────────────────┼─────────────────────────────────────────────────────────┤
│ fastapi   fastapi-service  dev, staging, production  alembic upgrade head && sudo systemctl restart          │
│                                                      uvicorn...                                              │
├──────────┼─────────────────┼──────────────────────────┼─────────────────────────────────────────────────────────┤
│ react-s3  react-frontend   staging, production       npm ci && npm run build && aws s3 sync ./build          │
│                                                      s3://${{...                                             │
├──────────┼─────────────────┼──────────────────────────┼─────────────────────────────────────────────────────────┤
│ docker    docker-service   staging, production       docker build -t ${{ secrets.REGISTRY }}/${{             │
│                                                      github.repo...                                          │
├──────────┼─────────────────┼──────────────────────────┼─────────────────────────────────────────────────────────┤
│ flask     flask-app        dev, staging, production  flask db upgrade && sudo systemctl restart              │
│                                                      gunicorn-fla...                                         │
└──────────┴─────────────────┴──────────────────────────┴─────────────────────────────────────────────────────────┘

Section 10 — Advanced: Listing and Auditing Existing Workflows

Visibility Into What's Already Deployed

A good deploy bot doesn't just push files — it also lets you inspect what's already there.
The function below queries the GitHub API to list all workflow files currently in a repository, giving you a clear audit trail before making changes.

[ ]
def list_existing_workflows(
    owner  : str,
    repo   : str,
    headers: Dict[str, str],
) -> List[Dict[str, str]]:
    """
    Retrieve a list of all GitHub Actions workflow files in a repository.

    Queries the `.github/workflows/` directory via the GitHub Contents API
    and returns metadata for every YAML file found.  Useful for auditing
    what workflows are currently active before running the deploy bot.

    Parameters
    ----------
    owner   : str  — repository owner
    repo    : str  — repository name
    headers : dict — GitHub API authentication headers

    Returns
    -------
    list of dict
        Each dict contains:
          - 'name'       : filename (e.g., 'deploy-production.yml')
          - 'path'       : full path in the repo
          - 'sha'        : current file SHA
          - 'size_bytes' : file size in bytes
          - 'html_url'   : link to view the file on GitHub

    Returns an empty list if the directory does not exist yet.

    Example
    -------
    >>> workflows = list_existing_workflows(owner, repo, headers)
    >>> for wf in workflows:
    ...     print(wf['name'], wf['size_bytes'])
    """

    url      = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{WORKFLOW_DIR}"
    response = requests.get(url, headers=headers, timeout=10)

    # 404 means the .github/workflows directory doesn't exist yet — that's fine
    if response.status_code == 404:
        console.print(
            "[yellow]ℹ️  No .github/workflows directory found — repository has no workflows yet.[/yellow]"
        )
        return []

    response.raise_for_status()  # Surface other errors

    files = response.json()

    # Filter to only YAML workflow files (ignore any non-.yml files)
    workflows = [
        {
            "name"      : f["name"],
            "path"      : f["path"],
            "sha"       : f["sha"],
            "size_bytes": f["size"],
            "html_url"  : f["html_url"],
        }
        for f in files
        if f["name"].endswith((".yml", ".yaml")) and f["type"] == "file"
    ]

    # ── Display results in a table ────────────────────────────────────────────
    if workflows:
        audit_table = Table(
            title=f"Existing Workflows in {owner}/{repo}",
            show_lines=True,
        )
        audit_table.add_column("Filename",   style="cyan",   no_wrap=True)
        audit_table.add_column("Size",       style="yellow")
        audit_table.add_column("SHA (short)",style="green")

        for wf in workflows:
            audit_table.add_row(
                wf["name"],
                f"{wf['size_bytes']} B",
                wf["sha"][:10] + "...",
            )

        console.print(audit_table)
    else:
        console.print("[yellow]ℹ️  No YAML workflow files found in .github/workflows/[/yellow]")

    return workflows


print("✅ list_existing_workflows() defined.")
print("   To use it, call:  list_existing_workflows(creds['owner'], creds['repo'], creds['headers'])")
✅ list_existing_workflows() defined.
   To use it, call:  list_existing_workflows(creds['owner'], creds['repo'], creds['headers'])

Section 11 — Advanced: CLI-Style Interactive Interface

Making the Bot User-Friendly

Power users run tools from the command line.
This section wraps the deploy bot in a simple interactive menu so anyone can use it without editing code directly.
It also serves as a model for how to turn any notebook into a CLI-style tool.

[ ]
def run_interactive_deploy_bot() -> None:
    """
    Launch an interactive CLI-style interface for the GitHub Actions deploy bot.

    Guides the user through:
      1. Connecting to a GitHub repository
      2. Choosing a workflow template or custom configuration
      3. Selecting target environments
      4. Running in dry-run or live mode

    This function is a thin wrapper around the other functions in this
    notebook, designed to make the bot accessible without editing code.

    Parameters
    ----------
    None

    Returns
    -------
    None  (side-effect: interactive prompts and output to console)

    Example
    -------
    >>> run_interactive_deploy_bot()
    """

    console.print(Panel(
        "[bold]GitHub Actions Deploy Bot[/bold]\n"
        "This wizard will create deployment workflow files in your repository.\n"
        "Type [cyan]Ctrl+C[/cyan] at any time to cancel.",
        title="🤖 Deploy Bot Wizard",
        border_style="blue"
    ))

    # ── Step 1: Connect ────────────────────────────────────────────────────────
    console.print("\n[bold cyan]Step 1/4 — Connect to GitHub[/bold cyan]")
    creds = setup_github_credentials()

    # ── Step 2: Choose template or custom ─────────────────────────────────────
    console.print("\n[bold cyan]Step 2/4 — Choose Configuration[/bold cyan]")
    console.print("Available templates: [green]django, fastapi, react-s3, docker, flask[/green]")
    console.print("Or type [yellow]custom[/yellow] to enter a deploy command manually.")

    choice = input("\nTemplate or 'custom': ").strip().lower()

    if choice == "custom":
        project_name  = input("Project name (letters, numbers, hyphens only): ").strip()
        deploy_cmd    = input("Deploy command: ").strip()
        template_args = {
            "project_name"       : project_name,
            "base_deploy_command": deploy_cmd,
        }
    else:
        template_args = get_workflow_template(choice)
        console.print(f"[green]✅ Loaded template: {choice}[/green]")

    # ── Step 3: Choose environments ────────────────────────────────────────────
    console.print("\n[bold cyan]Step 3/4 — Select Environments[/bold cyan]")
    console.print("Options: [green]dev, staging, production[/green] (comma-separated)")
    env_input = input("Environments [default: dev,staging,production]: ").strip()

    if env_input:
        # Parse comma-separated input, stripping whitespace from each value
        target_envs = [e.strip() for e in env_input.split(",") if e.strip()]
    else:
        target_envs = ["dev", "staging", "production"]

    template_args["target_environments"] = target_envs

    # ── Step 4: Dry run or live? ───────────────────────────────────────────────
    console.print("\n[bold cyan]Step 4/4 — Choose Mode[/bold cyan]")
    mode_input = input("Run mode — [D]ry run (preview only) or [L]ive deploy? [D/L]: ").strip().upper()
    dry_run    = mode_input != "L"   # Default to dry run unless explicitly 'L'

    if not dry_run:
        confirm = input(
            f"⚠️  This will COMMIT files to {creds['owner']}/{creds['repo']}. "
            "Type YES to confirm: "
        ).strip()
        if confirm != "YES":
            console.print("[yellow]Cancelled. Running as dry run instead.[/yellow]")
            dry_run = True

    # ── Execute ────────────────────────────────────────────────────────────────
    deploy_bot(
        creds   = creds,
        dry_run = dry_run,
        **template_args,
    )


# Uncomment to launch the interactive wizard:
# run_interactive_deploy_bot()

print("✅ run_interactive_deploy_bot() defined.")
print("   Uncomment the last line to launch the wizard.")
✅ run_interactive_deploy_bot() defined.
   Uncomment the last line to launch the wizard.

Conclusion

This notebook has demonstrated how to build a powerful and flexible GitHub Actions deploy bot using Python. You've learned to:

  • Securely handle credentials with getpass and environment variables.
  • Programmatically generate GitHub Actions YAML workflows.
  • Implement multi-environment deployments (dev, staging, production) with tailored settings.
  • Interact with the GitHub API to create and update workflow files.
  • Add robust features like dry runs, rollback on error, and detailed reporting.
  • Create reusable workflow templates for common technology stacks.
  • Build an interactive CLI-style interface for user-friendly operation.

By automating the creation and management of GitHub Actions workflows, this bot streamlines your CI/CD pipeline, reduces human error, and ensures consistent deployment practices across all your projects.