Skip to content

GitHub Actions CI/CD ​

Concepts ​

CI (Continuous Integration) automatically tests and validates code changes on every push. It catches bugs early and ensures code quality before merging.

CD (Continuous Deployment) automatically builds, packages, and deploys code to production after successful tests.

GitHub Actions is GitHub's automation platform that runs workflows (automated processes) triggered by events like pushes, pull requests, or tags. Each workflow contains jobs (units of work), which contain steps (individual commands).

Workflow Diagram ​

Feature Branch Development
          ↓
    Merge to main
          ↓
    Changesets Workflow
    (version bump + changelog)
          ↓
    Create Git Tag (v1.2.3)
          ↓
    Build & Package on Tag
    (build app, build DB artifact, create bundle)
          ↓
    Manual/Auto Trigger Deploy
          ├→ Fetch Bundle
          ├→ Deploy DB (SQLcl project deploy)
          └→ Deploy Web (blue/green to server)

Workflows ​

1. Changesets ​

./.github/workflows/changesets.yml ​

source
yml
name: Changesets
on:
  push:
    branches:
      - main
  workflow_dispatch: {}
permissions:
  contents: write
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
        with:
          package_json_file: "apps/package.json"
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
          cache-dependency-path: "apps/pnpm-lock.yaml"
      - run: cd apps && pnpm install --frozen-lockfile
      - run: |
          git config --global user.email "github-actions[bot]@users.noreply.github.com"
          git config --global user.name "github-actions[bot]"
      - name: Verify changeset directory
        shell: bash
        run: |
          echo "Workspace contents:" && ls -la
          echo "apps contents:" && ls -la apps
          echo ".changeset contents:" && ls -la apps/.changeset
      - name: Apply version bump and push
        shell: bash
        run: |
          cd apps
          # Skip versioning if there are no pending changeset files (exclude README.md)
          if ls .changeset/*.md 2>/dev/null | grep -v -q 'README.md'; then
            echo "Pending changesets found. Running versioning..."
            pnpm changeset version
            git add -A
            if git diff --cached --quiet; then
              echo "No version changes to commit after versioning."
              exit 0
            fi
            git commit -m "chore(release): version bump and changelog"
            # Push back to the same branch (typically main)
            git push origin "HEAD:${GITHUB_REF_NAME}"
          else
            echo "No pending changesets; skipping version bump."
          fi
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  test:
    needs: release
    uses: ./.github/workflows/test.yml

Trigger: On push to main

Automatically manages versioning and changelog. After a merge to main:

  • Steps:
    1. Checkout code and install dependencies
    2. Check for pending changesets (version descriptions committed with PRs)
    3. If found: run pnpm changeset version to bump version numbers and update CHANGELOG.md
    4. Commit and push version changes back to main

Purpose: Eliminates manual version management; keeps releases synchronized.

2. Build & Package on Tag ​

./.github/workflows/release-tag.yml ​

source
yml
name: Build & Package on Tag
on:
  push:
    tags:
      - "v[0-9]+.[0-9]+.[0-9]+"

permissions:
  contents: write
  actions: write

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      artifact_zip: ${{ steps.db.outputs.zip }}
    steps:
      - uses: actions/checkout@v4

      - name: Setup SQLcl
        uses: gvenzl/setup-oracle-sqlcl@v1
        with:
          version: "latest"

      - name: Generate DB artifact
        id: db
        working-directory: db
        run: |
          set -euo pipefail
          VERSION="${GITHUB_REF_NAME}"
          NAME="odbvue-db"

          # Ensure artifact directory exists
          mkdir -p artifact

          # Generate artifact using Liquibase project in current directory
          sql /nolog << ENDSQL
          project gen-artifact -name $NAME -version $VERSION -format zip
          exit
          ENDSQL

          # Verify artifact was created
          ARTIFACT="artifact/${NAME}-${VERSION}.zip"
          if [ ! -f "$ARTIFACT" ]; then
            echo "::error::Artifact file not found: $ARTIFACT"
            ls -la artifact/ || echo "artifact directory does not exist"
            exit 1
          fi

          echo "zip=db/$ARTIFACT" >> "$GITHUB_OUTPUT"
          echo "DB artifact generated: $ARTIFACT"

      - name: Setup pnpm/node
        uses: pnpm/action-setup@v2
        with:
          package_json_file: "apps/package.json"
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
          cache-dependency-path: "apps/pnpm-lock.yaml"
      - run: cd apps && pnpm install --frozen-lockfile
      - run: cd apps && pnpm build && pnpm wiki:build

      - name: Assemble release bundle
        run: |
          set -euo pipefail
          mkdir -p build-bundle/{apps,wiki,main,db}
          cp -r apps/dist/* build-bundle/apps/ || true
          cp -r apps/.vitepress/dist/* build-bundle/wiki/ || true
          cp -r main/* build-bundle/main/ || true

          cp "${{ steps.db.outputs.zip }}" build-bundle/db/
          tar -czf "release-${GITHUB_REF_NAME}.tar.gz" -C build-bundle .
          sha256sum "release-${GITHUB_REF_NAME}.tar.gz" > "release-${GITHUB_REF_NAME}.sha256"

      - name: Upload bundle
        uses: actions/upload-artifact@v4
        with:
          name: release-bundle
          path: |
            release-${{ github.ref_name }}.tar.gz
            release-${{ github.ref_name }}.sha256

      - name: Publish GitHub Release assets
        if: ${{ github.ref_type == 'tag' }}
        uses: softprops/action-gh-release@v2
        with:
          tag_name: ${{ github.ref_name }}
          name: ${{ github.ref_name }}
          draft: false
          prerelease: false
          files: |
            release-${{ github.ref_name }}.tar.gz
            release-${{ github.ref_name }}.sha256
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Trigger: On push of a tag matching v[0-9]+.[0-9]+.[0-9]+ (e.g., v1.2.3)

Creates release artifacts for deployment.

  • Job: build
    1. Checkout code
    2. Setup SQLcl → Generate database artifact (ZIP) with versioning
    3. Setup Node/pnpm → Build Vue app (pnpm build)
    4. Build wiki documentation (pnpm wiki:build)
    5. Assemble bundle: combine app, wiki, main, and DB artifact into single tarball
    6. Generate SHA256 checksum for verification
    7. Upload artifacts to GitHub Release

Purpose: Creates immutable, verified release packages ready for deployment.

3. Deploy ​

./.github/workflows/deploy.yml ​

source
yml
name: Deploy
on:
  workflow_dispatch:
    inputs:
      tag:
        description: "Tag to deploy (e.g., v1.2.3). Must have a corresponding release artifact from a previous successful 'Build & Package on Tag' workflow run."
        required: true
        type: string
  workflow_run:
    workflows: ["Build & Package on Tag"]
    types: [completed]

permissions:
  contents: read
  actions: read

concurrency:
  # Serialize per tag when available; otherwise, fall back to the originating run id to avoid cross-tag serialization
  group: deploy-${{ github.event_name == 'workflow_dispatch' && inputs.tag || (github.event_name == 'workflow_run' && (github.event.workflow_run.head_branch || github.event.workflow_run.id)) || github.run_id }}
  cancel-in-progress: false

jobs:
  fetch-bundle:
    if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') }}
    runs-on: ubuntu-latest
    outputs:
      bundle_path: ${{ steps.set-path.outputs.path }}
      tag: ${{ steps.get-tag.outputs.tag }}
    steps:
      - uses: actions/checkout@v4
      - name: Get tag
        id: get-tag
        run: |
          if [ -n "${{ inputs.tag }}" ]; then
            TAG="${{ inputs.tag }}"
          else
            TAG="${{ github.event.workflow_run.head_branch }}"
          fi
          echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
      - name: Download bundle from Build workflow (auto-trigger)
        if: ${{ github.event_name == 'workflow_run' }}
        uses: actions/download-artifact@v4
        with:
          name: release-bundle
          path: bundle
          github-token: ${{ secrets.GITHUB_TOKEN }}
          run-id: ${{ github.event.workflow_run.id }}
      - name: Download bundle from GitHub Release (manual trigger)
        if: ${{ github.event_name == 'workflow_dispatch' }}
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail
          mkdir -p bundle
          gh release download "${{ steps.get-tag.outputs.tag }}" \
            --repo "${{ github.repository }}" \
            -p "release-${{ steps.get-tag.outputs.tag }}.tar.gz" \
            -p "release-${{ steps.get-tag.outputs.tag }}.sha256" \
            -D bundle
      - name: Verify bundle exists
        run: |
          BUNDLE_PATH="bundle/release-${{ steps.get-tag.outputs.tag }}.tar.gz"
          if [ ! -f "$BUNDLE_PATH" ]; then
            echo "::error::Bundle file not found: $BUNDLE_PATH"
            echo "Available files in bundle directory:"
            ls -la bundle/ || echo "bundle directory does not exist"
            exit 1
          fi
      - name: Verify bundle checksum
        run: |
          set -euo pipefail
          cd bundle
          if [ ! -f "release-${{ steps.get-tag.outputs.tag }}.sha256" ]; then
            echo "::error::Checksum file not found: release-${{ steps.get-tag.outputs.tag }}.sha256"
            ls -la
            exit 1
          fi
          # Verify integrity; fails the job on mismatch
          if command -v sha256sum >/dev/null 2>&1; then
            sha256sum -c "release-${{ steps.get-tag.outputs.tag }}.sha256"
          else
            shasum -a 256 -c "release-${{ steps.get-tag.outputs.tag }}.sha256"
          fi
      - name: Set bundle path
        id: set-path
        run: echo "path=bundle/release-${{ steps.get-tag.outputs.tag }}.tar.gz" >> "$GITHUB_OUTPUT"
      - name: Upload bundle for downstream jobs
        uses: actions/upload-artifact@v4
        with:
          name: current-bundle
          path: |
            bundle/release-${{ steps.get-tag.outputs.tag }}.tar.gz
            bundle/release-${{ steps.get-tag.outputs.tag }}.sha256

  deploy-db:
    needs: fetch-bundle
    if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Download bundle from current workflow
        uses: actions/download-artifact@v4
        with:
          name: current-bundle
          path: bundle
      - uses: gvenzl/setup-oracle-sqlcl@v1
        with:
          version: latest
      - name: Extract bundle and verify database artifact
        run: |
          set -euo pipefail
          if [ ! -f "${{ needs.fetch-bundle.outputs.bundle_path }}" ]; then
            echo "::error::Bundle file not found at: ${{ needs.fetch-bundle.outputs.bundle_path }}"
            exit 1
          fi
          # Verify checksum before extraction
          SHA_FILE="bundle/$(basename "${{ needs.fetch-bundle.outputs.bundle_path }}" | sed 's/\.tar\.gz$/.sha256/')"
          if [ -f "$SHA_FILE" ]; then
            (cd bundle && sha256sum -c "$(basename "$SHA_FILE")")
          fi
          tar -xzf "${{ needs.fetch-bundle.outputs.bundle_path }}" -C bundle
          DB_ARTIFACT="bundle/db/odbvue-db-${{ needs.fetch-bundle.outputs.tag }}.zip"
          if [ ! -f "$DB_ARTIFACT" ]; then
            echo "::error::Database artifact not found in bundle: $DB_ARTIFACT"
            echo "Contents of bundle/db:"
            find bundle/db -type f || true
            exit 1
          fi
          echo "Database artifact verified at: $DB_ARTIFACT"
      - name: Restore ADB wallet
        run: |
          set -euo pipefail
          WALLET_DIR="/tmp/wallet"
          mkdir -p "$WALLET_DIR"
          printf %s "${{ secrets.ADB_WALLET_BASE64 }}" | base64 -d > "$WALLET_DIR/wallet.zip"
          unzip -P "${{ secrets.ADB_WALLET_PASSWORD }}" -q -o "$WALLET_DIR/wallet.zip" -d "$WALLET_DIR"
          echo "TNS_ADMIN=$WALLET_DIR" >> $GITHUB_ENV
          echo "Wallet extracted to $WALLET_DIR"
      - name: Deploy DB
        env:
          ADB_USER: ${{ secrets.ADB_USER }}
          ADB_PASSWORD: ${{ secrets.ADB_PASSWORD }}
          ADB_TNS_ALIAS: ${{ secrets.ADB_TNS_ALIAS }}
          APP_CONFIG: ${{ secrets.APP_CONFIG }}
        run: |
          set -euo pipefail

          # Deployment variables
          APP_CONFIG_SINGLE_LINE=$(echo "$APP_CONFIG" | tr '\n' ' ' | sed 's/[[:space:]]+/ /g')
          VERSION="${{ needs.fetch-bundle.outputs.tag }}"
          NAME="odbvue-db"
          SCHEMA=$(echo "$APP_CONFIG_SINGLE_LINE" | jq -r '.schema.username')
          EDITION=$(echo "${SCHEMA}_$(echo "$VERSION" | sed 's/\./_/g')" | tr '[:lower:]' '[:upper:]')

          # Get artifact from bundle
          DB_ARTIFACT="bundle/db/$NAME-${VERSION}.zip"
          if [ ! -f "$DB_ARTIFACT" ]; then
            echo "::error::Database artifact not found: $DB_ARTIFACT"
            ls -la bundle/db/ || echo "bundle/db directory does not exist"
            exit 1
          fi

          # Deploy to database and capture output
          echo "Connecting to ${ADB_TNS_ALIAS}..."
          DEPLOY_LOG="deploy.log"
          sql -nohistory "${ADB_USER}/${ADB_PASSWORD}@${ADB_TNS_ALIAS}" > "$DEPLOY_LOG" 2>&1 << ENDSQL || true
          whenever oserror exit failure
          whenever sqlerror exit sql.sqlcode rollback
          prompt Connected..
          define CONFIG = '${APP_CONFIG_SINGLE_LINE}'
          define VERSION = '${VERSION}'
          define EDITION = '${EDITION}'
          define SCHEMA = '${SCHEMA}'
          project deploy -file "$DB_ARTIFACT" -log-path . -verbose
          prompt Artifact deployed..
          lb tag -tag "$VERSION"
          prompt Tagged..
          exit
          ENDSQL

          # Check for Liquibase errors in the log
          if grep -q "ERROR\|ValidationFailed\|Exception" "$DEPLOY_LOG"; then
            echo "::error::Liquibase deployment failed. Check log below:"
            cat "$DEPLOY_LOG"
            exit 1
          fi

          # Show deployment log
          echo "Deployment completed successfully:"
          cat "$DEPLOY_LOG"

  deploy-web:
    needs: [fetch-bundle, deploy-db]
    if: ${{ success() && (github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')) }}
    runs-on: ubuntu-latest
    steps:
      - name: Download bundle from current workflow
        uses: actions/download-artifact@v4
        with:
          name: current-bundle
          path: bundle
      - name: Setup SSH
        run: |
          set -euo pipefail
          mkdir -p ~/.ssh
          printf %s "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          # Remove any existing host keys for this host before adding the new one
          ssh-keygen -f ~/.ssh/known_hosts -R "${{ secrets.SSH_HOST }}" 2>/dev/null || true
          printf %s "${{ secrets.SSH_HOST_KEY }}" >> ~/.ssh/known_hosts
          # Create SSH config to handle host key changes gracefully
          cat > ~/.ssh/config << 'EOF'
          Host ${{ secrets.SSH_HOST }}
            StrictHostKeyChecking=accept-new
            UserKnownHostsFile ~/.ssh/known_hosts
            IdentityFile ~/.ssh/deploy_key
            IdentitiesOnly yes
          EOF
          chmod 600 ~/.ssh/config
      - name: Extract bundle
        run: |
          set -euo pipefail
          if [ ! -f "${{ needs.fetch-bundle.outputs.bundle_path }}" ]; then
            echo "::error::Bundle file not found at: ${{ needs.fetch-bundle.outputs.bundle_path }}"
            exit 1
          fi
          # Verify checksum before extraction
          SHA_FILE="bundle/$(basename "${{ needs.fetch-bundle.outputs.bundle_path }}" | sed 's/\.tar\.gz$/.sha256/')"
          if [ -f "$SHA_FILE" ]; then
            (cd bundle && sha256sum -c "$(basename "$SHA_FILE")")
          fi
          tar -xzf "${{ needs.fetch-bundle.outputs.bundle_path }}" -C bundle
          echo "Extracted bundle contents:"
          find bundle -type f -o -type d | head -20 || true
      - name: Deploy with blue/green strategy
        run: |
          set -euo pipefail

          for site in apps wiki main; do
            # Get current active slot
            ACTIVE=$(ssh -i ~/.ssh/deploy_key ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "readlink /var/www/$site/current 2>/dev/null | grep -oE '(blue|green)' || echo blue")
            INACTIVE=$([ "$ACTIVE" = "blue" ] && echo "green" || echo "blue")
            TARGET_DIR="/var/www/$site/$INACTIVE"
            
            echo "Deploying $site: $ACTIVE -> $INACTIVE"
            
            # Clean and create target directory (owned by deploy user for rsync)
            ssh -i ~/.ssh/deploy_key ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "sudo rm -rf '$TARGET_DIR' && sudo mkdir -p '$TARGET_DIR' && sudo chown ${{ secrets.SSH_USER }}:${{ secrets.SSH_USER }} '$TARGET_DIR'"
            
            # Upload content (rsync can now write)
            rsync -az -e "ssh -i ~/.ssh/deploy_key" bundle/$site/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:"$TARGET_DIR/"
            
            # Fix permissions (change to nginx owner)
            ssh -i ~/.ssh/deploy_key ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "sudo chown -R nginx:nginx '$TARGET_DIR' && sudo find '$TARGET_DIR' -type d -exec chmod 755 {} \; && sudo find '$TARGET_DIR' -type f -exec chmod 644 {} \;"
            
            # Flip symlink atomically
            ssh -i ~/.ssh/deploy_key ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "sudo ln -sfn '$TARGET_DIR' /var/www/$site/current.tmp && sudo mv -T /var/www/$site/current.tmp /var/www/$site/current"
          done

          # Validate and reload nginx
          ssh -i ~/.ssh/deploy_key ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "sudo nginx -t && sudo systemctl reload nginx"

Trigger: Manual via workflow_dispatch OR auto-triggered after successful Build & Package on Tag

Deploys the release to production.

  • Job: fetch-bundle

    • Downloads release bundle (either from workflow artifacts or GitHub Release)
    • Verifies SHA256 checksum before extraction
    • Outputs bundle path and tag for downstream jobs
  • Job: deploy-db (depends on fetch-bundle)

    1. Download bundle
    2. Setup SQLcl
    3. Extract and verify database artifact
    4. Restore ADB wallet from secrets
    5. Deploy DB using SQLcl: project deploy (applies DDL/DML from artifact)
    6. Tag deployment with version
  • Job: deploy-web (depends on fetch-bundle + deploy-db)

    1. Extract bundle
    2. For each site (apps, wiki, main):
      • Get current active slot (blue or green)
      • Deploy to inactive slot
      • Atomically flip symlink to switch slots (zero downtime)
      • Fix permissions for nginx
    3. Validate nginx config and reload

Purpose: Zero-downtime deployment with database versioning and atomic cut-over.

Key Features ​

  • Automated versioning: Changesets workflow keeps versions in sync with changelog
  • Release verification: SHA256 checksums ensure bundle integrity
  • Database versioning: Tagged database deployments with SQLcl project (Liquibase) tagging
  • Blue/green deployment: Zero-downtime web updates via atomic symlink flip
  • Concurrency control: Deploy workflows serialize by tag to prevent race conditions
  • Manual override: Deploy workflow can be triggered manually for any released tag