skills/franciscosanchezn/easyfactu-es/github-actions-workflows

github-actions-workflows

SKILL.md

GitHub Actions Workflows Skill

Create production-ready GitHub Actions workflows with security best practices, reusable patterns, and multi-cloud integration (Hetzner/K3s, Azure).

When to Use This Skill

  • Creating new CI/CD workflows from scratch
  • Building reusable workflow templates
  • Building and pushing container images to GHCR
  • Deploying to Hetzner Cloud VPS via K3s (SSH + kubectl)
  • Setting up Azure deployments with OIDC
  • Implementing security best practices in workflows
  • Configuring multi-language CI pipelines
  • Troubleshooting workflow issues

Core Workflow Structure

name: Workflow Name

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:  # Manual trigger

permissions:
  contents: read  # Always use least privilege

jobs:
  job-name:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      # Additional steps...

Security Checklist

Mandatory Security Practices

  • Pin actions to full SHA (not tags)
  • Declare permissions explicitly (never use default write-all)
  • Use OIDC for cloud authentication (no stored credentials)
  • Never echo secrets to logs
  • Use environments for deployment protection
  • Enable Dependabot for action updates

Permissions Reference

permissions:
  contents: read        # Read repository contents
  pull-requests: write  # Comment on PRs
  id-token: write       # OIDC authentication (Azure/AWS/GCP)
  packages: write       # Push to GitHub Packages
  actions: read         # Read workflow artifacts
  security-events: write # Upload security scanning results

GHCR (GitHub Container Registry) Patterns

Build and Push to GHCR

permissions:
  contents: read
  packages: write

jobs:
  build-push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Log in to GHCR
        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
        with:
          images: ghcr.io/${{ github.repository }}/my-app
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@263435318d21b8e681c14492fe198e19c816612b # v6.18.0
        with:
          context: .
          file: apps/my-app/Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Monorepo Docker Build (Build Arg Pattern)

# Building from monorepo root with --build-arg
- name: Build and push API image
  uses: docker/build-push-action@263435318d21b8e681c14492fe198e19c816612b # v6.18.0
  with:
    context: .  # Monorepo root for workspace dependency resolution
    file: apps/easyfactu-api/Dockerfile
    push: true
    tags: ghcr.io/${{ github.repository }}/easyfactu-api:${{ github.sha }}
    build-args: |
      MODULE_NAME=easyfactu_api
    cache-from: type=gha
    cache-to: type=gha,mode=max

Multi-Platform Build

- name: Set up QEMU
  uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0

- name: Build and push (multi-platform)
  uses: docker/build-push-action@263435318d21b8e681c14492fe198e19c816612b # v6.18.0
  with:
    context: .
    file: apps/my-app/Dockerfile
    push: true
    platforms: linux/amd64,linux/arm64
    tags: ghcr.io/${{ github.repository }}/my-app:${{ github.sha }}

GHCR Image Cleanup

# Clean up old untagged images to stay within GHCR free tier
- name: Delete old container images
  uses: actions/delete-package-versions@e5bc658cc4c965c472ez4d6f4c01e47f6869ed # v5.0.0
  with:
    package-name: my-app
    package-type: container
    min-versions-to-keep: 5
    delete-only-untagged-versions: true

Hetzner Cloud / K3s Deployment Patterns

SSH-Based Deployment to K3s

Deploy to a Hetzner VPS running K3s via SSH and kubectl:

permissions:
  contents: read
  packages: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production

    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Log in to GHCR
        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@263435318d21b8e681c14492fe198e19c816612b # v6.18.0
        with:
          context: .
          file: apps/easyfactu-api/Dockerfile
          push: true
          tags: ghcr.io/${{ github.repository }}/easyfactu-api:${{ github.sha }}

      - name: Configure SSH
        run: |
          mkdir -p ~/.ssh
          echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -H $SERVER_IP >> ~/.ssh/known_hosts
        env:
          SSH_PRIVATE_KEY: ${{ secrets.K3S_SSH_PRIVATE_KEY }}
          SERVER_IP: ${{ secrets.K3S_SERVER_IP }}

      - name: Deploy to K3s
        run: |
          ssh root@$SERVER_IP "
            kubectl set image deployment/easyfactu-api \
              api=ghcr.io/${{ github.repository }}/easyfactu-api:${{ github.sha }} \
              -n easyfactu
            kubectl rollout status deployment/easyfactu-api -n easyfactu --timeout=120s
          "
        env:
          SERVER_IP: ${{ secrets.K3S_SERVER_IP }}

      - name: Cleanup SSH key
        if: always()
        run: rm -f ~/.ssh/id_ed25519

Kustomize-Based K3s Deployment

- name: Deploy with Kustomize
  run: |
    ssh root@$SERVER_IP "
      cd /opt/k8s-manifests &&
      git pull origin main &&
      kubectl apply -k infra/k8s/overlays/prod
    "
  env:
    SERVER_IP: ${{ secrets.K3S_SERVER_IP }}

Remote kubectl via Kubeconfig

- name: Set up kubeconfig
  run: |
    mkdir -p ~/.kube
    echo "$KUBECONFIG_CONTENT" > ~/.kube/config
    chmod 600 ~/.kube/config
    # Replace localhost with the actual server IP
    sed -i "s/127.0.0.1/$SERVER_IP/" ~/.kube/config
  env:
    KUBECONFIG_CONTENT: ${{ secrets.K3S_KUBECONFIG }}
    SERVER_IP: ${{ secrets.K3S_SERVER_IP }}

- name: Deploy via remote kubectl
  run: |
    kubectl set image deployment/easyfactu-api \
      api=ghcr.io/${{ github.repository }}/easyfactu-api:${{ github.sha }} \
      -n easyfactu
    kubectl rollout status deployment/easyfactu-api -n easyfactu --timeout=120s

GHCR Pull Secret for K3s

# Create or update GHCR pull secret on the K3s cluster
- name: Update GHCR pull secret
  run: |
    ssh root@$SERVER_IP "
      kubectl create secret docker-registry ghcr-credentials \
        --namespace=easyfactu \
        --docker-server=ghcr.io \
        --docker-username=${{ github.actor }} \
        --docker-password=$GHCR_TOKEN \
        --dry-run=client -o yaml | kubectl apply -f -
    "
  env:
    SERVER_IP: ${{ secrets.K3S_SERVER_IP }}
    GHCR_TOKEN: ${{ secrets.GHCR_PULL_TOKEN }}

Terraform Apply for Hetzner Infrastructure

jobs:
  terraform:
    runs-on: ubuntu-latest
    environment: production
    defaults:
      run:
        working-directory: infra/terraform/environments/prod

    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2

      - name: Terraform Init
        run: terraform init

      - name: Terraform Plan
        run: terraform plan -out=tfplan -no-color
        env:
          TF_VAR_hcloud_token: ${{ secrets.HCLOUD_TOKEN }}
          TF_VAR_ssh_public_key: ${{ secrets.SSH_PUBLIC_KEY }}
          TF_VAR_admin_ip: ${{ secrets.ADMIN_IP }}

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve tfplan
        env:
          TF_VAR_hcloud_token: ${{ secrets.HCLOUD_TOKEN }}
          TF_VAR_ssh_public_key: ${{ secrets.SSH_PUBLIC_KEY }}
          TF_VAR_admin_ip: ${{ secrets.ADMIN_IP }}

Hetzner/K3s Secrets Reference

Secret Description
K3S_SSH_PRIVATE_KEY Ed25519 private key for SSH to K3s server
K3S_SERVER_IP Public IPv4 of the Hetzner VPS
K3S_KUBECONFIG Kubeconfig from /etc/rancher/k3s/k3s.yaml
HCLOUD_TOKEN Hetzner Cloud API token (for Terraform)
GHCR_PULL_TOKEN PAT with read:packages scope for K3s image pulls

Azure OIDC Authentication Pattern

permissions:
  id-token: write  # REQUIRED for OIDC
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production  # Uses environment-specific secrets

    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Azure Login (OIDC - No Secrets!)
        uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

Reusable Workflow Template

Callable Workflow (reusable-deploy.yml)

name: Reusable Deploy

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      app-name:
        required: true
        type: string
    secrets:
      AZURE_CLIENT_ID:
        required: true
      AZURE_TENANT_ID:
        required: true
      AZURE_SUBSCRIPTION_ID:
        required: true
    outputs:
      url:
        description: 'Deployment URL'
        value: ${{ jobs.deploy.outputs.webapp-url }}

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    outputs:
      webapp-url: ${{ steps.deploy.outputs.webapp-url }}

    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy
        id: deploy
        uses: azure/webapps-deploy@4bca689e4c7129e55925cd7f1751cb9c4ac29b30 # v3.0.2
        with:
          app-name: ${{ inputs.app-name }}

Caller Workflow

name: Deploy Production

on:
  push:
    branches: [main]

jobs:
  deploy:
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: production
      app-name: my-app-prod
    secrets:
      AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
      AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
      AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
    permissions:
      id-token: write
      contents: read

Language-Specific CI Patterns

Python (UV)

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
  with:
    python-version: '3.12'

- uses: astral-sh/setup-uv@6b9c6063abd6010835644d4c2e1bef4cf5cd0fca # v6.0.1

- run: uv sync
- run: uv run pytest
- run: uv run mypy src/
- run: uv run ruff check src/

Node.js

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
  with:
    node-version: '20'
    cache: 'npm'

- run: npm ci
- run: npm test
- run: npm run build

.NET

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
  with:
    dotnet-version: '8.0.x'

- run: dotnet restore
- run: dotnet build --no-restore
- run: dotnet test --no-build

Java (Maven)

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
  with:
    java-version: '21'
    distribution: 'temurin'
    cache: 'maven'

- run: mvn -B package --file pom.xml

Supply Chain Security

Pin Actions to SHA

# ❌ Vulnerable - tags can be moved
- uses: actions/checkout@v4

# ✅ Secure - immutable reference
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

Dependabot Configuration

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    commit-message:
      prefix: "ci"

Vulnerability Scanning

- name: Run Trivy
  uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
  with:
    scan-type: 'fs'
    severity: 'CRITICAL,HIGH'
    exit-code: '1'

Common Patterns

Matrix Strategy

strategy:
  matrix:
    python-version: ['3.11', '3.12']
    os: [ubuntu-latest, windows-latest]
  fail-fast: false

Caching

- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
  with:
    path: ~/.cache/uv
    key: ${{ runner.os }}-uv-${{ hashFiles('**/uv.lock') }}
    restore-keys: ${{ runner.os }}-uv-

Conditional Execution

jobs:
  deploy-prod:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

Artifacts

# Upload
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
  with:
    name: build-output
    path: dist/

# Download
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
  with:
    name: build-output

Azure Deployment Patterns

App Service

- uses: azure/webapps-deploy@4bca689e4c7129e55925cd7f1751cb9c4ac29b30 # v3.0.2
  with:
    app-name: ${{ inputs.app-name }}
    package: ./dist

Container Apps

- run: |
    az acr login --name ${{ vars.ACR_NAME }}
    docker build -t ${{ vars.ACR_NAME }}.azurecr.io/app:${{ github.sha }} .
    docker push ${{ vars.ACR_NAME }}.azurecr.io/app:${{ github.sha }}

- uses: azure/container-apps-deploy-action@5f5f4c56ca90376e3cfbd76ba8fe8533c784e655 # v2.0.0
  with:
    containerAppName: ${{ vars.APP_NAME }}
    resourceGroup: ${{ vars.RESOURCE_GROUP }}
    imageToDeploy: ${{ vars.ACR_NAME }}.azurecr.io/app:${{ github.sha }}

Azure Functions

- uses: azure/functions-action@fd80521afbba9a2a76a99ba1acc07f61571f14b6 # v1.5.2
  with:
    app-name: ${{ vars.FUNCTION_APP_NAME }}
    package: .

Troubleshooting

Issue Solution
OIDC fails Add permissions: id-token: write
GHCR push denied Add permissions: packages: write
GHCR pull fails on K3s Verify ghcr-credentials secret exists in namespace
SSH connection refused Check Hetzner firewall allows port 22 from runner IP
kubectl timeout Increase --timeout, check pod resources
K3s rollout stuck Run kubectl rollout undo to revert
Secret not found Check environment configuration
Action version error Pin to full SHA
Cache miss Verify cache key pattern
Permission denied Review permissions block

Guidelines

  • Always pin actions to full commit SHA with version comment
  • Use OIDC for Azure authentication, never store credentials
  • Use SSH keys (Ed25519) for Hetzner/K3s deployment
  • Build and push images to GHCR (free for public and private repos)
  • Declare minimum required permissions explicitly
  • Use environments for deployment protection rules
  • Enable Dependabot for action updates
  • Always clean up SSH keys in an if: always() step
  • Use kubectl rollout status to verify deployments
  • Test workflows locally with act when possible
  • Add inline comments explaining non-obvious configurations

Bundled Resources

This skill includes ready-to-use templates and guides:

Workflow Templates

Guides

Weekly Installs
1
First Seen
12 days ago
Installed on
mcpjam1
claude-code1
junie1
windsurf1
zencoder1
crush1