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
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.ymlTrigger: On push to main
Automatically manages versioning and changelog. After a merge to main:
- Steps:
- Checkout code and install dependencies
- Check for pending changesets (version descriptions committed with PRs)
- If found: run
pnpm changeset versionto bump version numbers and updateCHANGELOG.md - 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
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- Checkout code
- Setup SQLcl → Generate database artifact (ZIP) with versioning
- Setup Node/pnpm → Build Vue app (
pnpm build) - Build wiki documentation (
pnpm wiki:build) - Assemble bundle: combine app, wiki, main, and DB artifact into single tarball
- Generate SHA256 checksum for verification
- Upload artifacts to GitHub Release
Purpose: Creates immutable, verified release packages ready for deployment.
3. Deploy
./.github/workflows/deploy.yml
source
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 onfetch-bundle)- Download bundle
- Setup SQLcl
- Extract and verify database artifact
- Restore ADB wallet from secrets
- Deploy DB using SQLcl:
project deploy(applies DDL/DML from artifact) - Tag deployment with version
Job:
deploy-web(depends onfetch-bundle+deploy-db)- Extract bundle
- 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
- 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