Skip to content

Build, Test, and Deploy — PH Core FHIR IG

Jörn Guy Süß edited this page Feb 27, 2026 · 1 revision

Build, Test, and Deploy — PH Core FHIR IG

Note

Documentation for build-ig.yml — the CI/CD pipeline for the Philippine Core FHIR Implementation Guide.


Table of Contents


Overview

This GitHub Actions workflow automates the full CI/CD lifecycle for the PH Core FHIR Implementation Guide:

  1. Compile — FSH sources → FHIR JSON via SUSHI
  2. Build — FHIR JSON → HTML IG site via IG Publisher
  3. Validate — QA gate checks for zero errors
  4. Deploy — Immutable, SHA-keyed releases to Dev and Staging servers

Important

Every push to main deploys to both Dev and Staging. Pushes to dev deploy to Dev only. Pull requests run CI without any deployment.


Triggers

Event Branches CI Deploy Dev Deploy Staging
pull_request dev, main
push dev
push main
workflow_dispatch
Concurrency control

A concurrency group ensures only one run per branch at a time. In-progress runs are cancelled when a new commit arrives on the same ref.

concurrency:
  group: fhir-core-ig-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Pipeline Architecture

High-Level Job Flow

flowchart LR
    A["🔨 CI\nBuild & QA"] -- "artifact\n(core-ig-output)" --> B["🚀 Deploy Dev"]
    B --> C["🚀 Deploy Staging"]

    style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
    style B fill:#f5a623,stroke:#c17d1a,color:#fff
    style C fill:#7ed321,stroke:#5a9a18,color:#fff
Loading

Job Conditions by Trigger

flowchart TD
    trigger{{"🎯 Trigger Event"}}
    trigger -->|pull_request| ci_only["CI only\n🔒 No deployment"]
    trigger -->|"push → dev"| ci_dev["CI → Deploy Dev"]
    trigger -->|"push → main"| ci_dev_staging["CI → Deploy Dev → Deploy Staging"]
    trigger -->|workflow_dispatch| ci_only

    style ci_only fill:#4a90d9,stroke:#2c5f8a,color:#fff
    style ci_dev fill:#f5a623,stroke:#c17d1a,color:#fff
    style ci_dev_staging fill:#7ed321,stroke:#5a9a18,color:#fff
Loading

Jobs

1. ci — Build & QA

Compiles FSH, builds the IG, validates QA, and uploads the output as an artifact.

Runtime Environment

Tool Version Setup Action
Java (Temurin) 17 actions/setup-java@v4
Node.js 18 actions/setup-node@v4
Ruby 3.1 ruby/setup-ruby@v1
IG Publisher latest Downloaded via curl
SUSHI latest npm install -g fsh-sushi
Jekyll + Bundler latest gem install jekyll bundler

CI Step Sequence

flowchart TD
    A["📥 Checkout repo"] --> B["☕ Setup Java 17"]
    B --> C["🟢 Setup Node.js 18"]
    C --> D["💎 Setup Ruby 3.1"]
    D --> E["Install Jekyll & Bundler"]
    E --> F["Install SUSHI"]
    F --> G["📦 Cache publisher + FHIR packages"]
    G --> H["⬇️ Download IG Publisher JAR"]
    H --> I["🐟 Run SUSHI\n(FSH → FHIR JSON)"]
    I --> J["🏗️ Build IG\n(IG Publisher → HTML)"]
    J --> K{"output/ exists\nand non-empty?"}
    K -->|No| FAIL1["❌ Fail"]
    K -->|Yes| L{"🔍 QA Gate"}
    L -->|"errors > 0"| FAIL2["❌ Fail"]
    L -->|Pass ✅| M["📤 Upload artifact\n(core-ig-output)"]

    style FAIL1 fill:#d94a4a,stroke:#8a2c2c,color:#fff
    style FAIL2 fill:#d94a4a,stroke:#8a2c2c,color:#fff
    style M fill:#7ed321,stroke:#5a9a18,color:#fff
Loading
📦 Caching Strategy

The workflow caches input-cache/ and ~/.fhir/packages using a composite key with progressive fallback:

# Primary key (exact match)
{os}-fhir-core-{publisher_version}-{hash(sushi-config.yaml, ig.ini, package.json)}

# Fallback keys (prefix match, most → least specific)
{os}-fhir-core-{publisher_version}-
{os}-fhir-core-

Cached paths:

  • input-cache/ — IG Publisher JAR and terminology caches
  • ~/.fhir/packages — Downloaded FHIR package dependencies
🔍 QA Gate Logic

The QA gate uses a two-tier fallback strategy — preferring structured JSON over HTML scraping:

flowchart TD
    A{"📄 qa.json exists?"}
    A -->|Yes| B["Parse with Node.js:\nerrCount ?? errors ?? errorCount"]
    B --> C{"errors == 0?"}
    C -->|Yes| PASS1["✅ QA passed (via qa.json)"]
    C -->|No| FAIL1["❌ QA failed (via qa.json)"]
    A -->|No| D{"📄 qa.html exists?"}
    D -->|Yes| E["grep for\n'Errors: [1-9]' or 'ERROR'"]
    E --> F{"Match found?"}
    F -->|Yes| FAIL2["❌ QA failed (via qa.html)"]
    F -->|No| PASS2["✅ QA passed (via qa.html)"]
    D -->|No| FAIL3["❌ No QA output — fail safe"]

    style PASS1 fill:#7ed321,stroke:#5a9a18,color:#fff
    style PASS2 fill:#7ed321,stroke:#5a9a18,color:#fff
    style FAIL1 fill:#d94a4a,stroke:#8a2c2c,color:#fff
    style FAIL2 fill:#d94a4a,stroke:#8a2c2c,color:#fff
    style FAIL3 fill:#d94a4a,stroke:#8a2c2c,color:#fff
Loading

[!CAUTION] If neither qa.json nor qa.html is found in output/, the build fails for safety. This prevents silent deployment of un-validated content.


2. deploy_dev — Deploy to Dev

Property Value
Runs when push to dev or main (after CI passes)
Depends on ci
Environment dev
Target path $DEPLOY_BASE/Dev/Core/<SHA>
flowchart TD
    A["📥 Download artifact\n(core-ig-output)"] --> B["🏷️ Add VERSION files\n(VERSION.txt + version.html)"]
    B --> C["🔑 SSH: mkdir target dir\nDev/Core/<SHA>"]
    C --> D["📤 SCP: upload output/*"]
    D --> E["🔑 SSH: post-deploy"]

    subgraph "Post-Deploy (remote server)"
        E --> E1["🔗 Symlink current/ → SHA"]
        E1 --> E2["📝 Write VERSION.txt"]
        E2 --> E3["📋 Generate versions.html\n(index of all deployments)"]
        E3 --> E4["🔒 Fix permissions\n(dirs: 755 · files: 644)"]
    end

    style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
    style D fill:#f5a623,stroke:#c17d1a,color:#fff
    style E4 fill:#7ed321,stroke:#5a9a18,color:#fff
Loading
Post-deploy script details

The remote SSH script performs these operations atomically:

# 1. Validate target directory exists
test -d "$TARGET_DIR" || exit 1

# 2. Atomic symlink update (ln -sfn is atomic on most filesystems)
ln -sfn "$TARGET_DIR" "$CORE_DIR/current"

# 3. Write version metadata
echo "$SHA" > "$CORE_DIR/VERSION.txt"

# 4. Auto-generate version index (scans for 40-char hex SHA directories)
for d in $(ls -1 "$CORE_DIR" | grep -E '^[0-9a-f]{40}$'); do ...

# 5. Lock down permissions
find "$TARGET_DIR" -type d -exec chmod 755 {} \;
find "$TARGET_DIR" -type f -exec chmod 644 {} \;

3. deploy_staging — Deploy to Staging

Property Value
Runs when push to main only (after CI and Dev deploy pass)
Depends on ci, deploy_dev
Environment staging
Target path $DEPLOY_BASE/Staging/Core/<SHA>

Note

The staging deployment is structurally identical to the Dev deployment, differing only in target path (Staging/Core/ vs Dev/Core/).

flowchart TD
    A["📥 Download artifact"] --> B["🏷️ Add VERSION files"]
    B --> C["🔑 SSH: mkdir target dir\nStaging/Core/<SHA>"]
    C --> D["📤 SCP: upload output/*"]
    D --> E["🔑 SSH: post-deploy"]

    subgraph "Post-Deploy (remote server)"
        E --> E1["🔗 Symlink current/ → SHA"]
        E1 --> E2["📝 Write VERSION.txt"]
        E2 --> E3["📋 Generate versions.html"]
        E3 --> E4["🔒 Fix permissions"]
    end

    style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
    style D fill:#f5a623,stroke:#c17d1a,color:#fff
    style E4 fill:#7ed321,stroke:#5a9a18,color:#fff
Loading

Server Directory Structure

Each deployment creates an immutable, SHA-keyed directory and updates the current symlink:

$DEPLOY_BASE/
├── Dev/
│   └── Core/
│       ├── current → abc123def456…   # symlink to latest
│       ├── abc123def456…/            # immutable IG output
│       │   ├── index.html
│       │   ├── VERSION.txt
│       │   └── …
│       ├── 789abc012def…/            # previous deployment
│       ├── VERSION.txt               # current SHA (plain text)
│       ├── version.html              # current SHA (HTML)
│       └── versions.html             # auto-generated index
└── Staging/
    └── Core/
        ├── current → abc123def456…
        ├── abc123def456…/
        ├── VERSION.txt
        ├── version.html
        └── versions.html
flowchart TD
    subgraph "DEPLOY_BASE"
        subgraph "Dev / Core /"
            DC_CUR["🔗 current/ → latest-sha"]
            DC_V1["📁 abc123…/"]
            DC_V2["📁 def456…/"]
            DC_VER["📄 VERSION.txt"]
            DC_VERS["📄 versions.html"]
        end
        subgraph "Staging / Core /"
            SC_CUR["🔗 current/ → latest-sha"]
            SC_V1["📁 abc123…/"]
            SC_VER["📄 VERSION.txt"]
            SC_VERS["📄 versions.html"]
        end
    end

    style DC_CUR fill:#f5a623,stroke:#c17d1a,color:#fff
    style SC_CUR fill:#7ed321,stroke:#5a9a18,color:#fff
Loading
File Purpose
current/ Symlink to the latest deployed SHA directory
<sha>/ Immutable IG output for a specific commit
VERSION.txt Plain-text current commit SHA
version.html HTML page showing current version
versions.html Auto-generated index linking all deployed versions

Warning

SHA directories are never overwritten or deleted by the workflow. Manual cleanup of old deployments on the server may be needed over time.


Required Secrets

Important

All five secrets below must be configured in the repository settings under Settings → Secrets and variables → Actions before deployments will work.

Secret Used For Example
ADMIN_HOST SSH/SCP target hostname fhir.example.ph
ADMIN_USER SSH/SCP username deployer
ADMIN_KEY SSH private key (PEM format) -----BEGIN OPENSSH…
ADMIN_PORT SSH port 22
DEPLOY_BASE Base directory on the server /var/www/fhir
Third-party actions used for deployment
Action Version Purpose
appleboy/ssh-action v0.1.8 Execute remote commands over SSH
appleboy/scp-action v1 Copy files to remote server via SCP

Required GitHub Environments

Environment Used By Condition Protection Recommended[^1]
dev deploy_dev push to dev or main
staging deploy_staging push to main only

[^1]: Environment protection rules (required reviewers, wait timers, deployment branches) can be configured in Settings → Environments.


Build Toolchain

flowchart LR
    FSH["📝 FSH Sources\n(input/fsh/)"] -->|"fsh-sushi"| JSON["📦 FHIR JSON\n(fsh-generated/)"]
    JSON -->|"publisher.jar"| HTML["🌐 IG Website\n(output/)"]
    HTML -->|"QA Gate"| QA{"Pass?"}
    QA -->|"✅ Yes"| DEPLOY["🚀 Deploy to Server"]
    QA -->|"❌ No"| STOP["🛑 Build Failed"]

    style FSH fill:#e8d5f5,stroke:#9b59b6,color:#333
    style JSON fill:#d5e8f5,stroke:#3498db,color:#333
    style HTML fill:#d5f5e3,stroke:#27ae60,color:#333
    style DEPLOY fill:#7ed321,stroke:#5a9a18,color:#fff
    style STOP fill:#d94a4a,stroke:#8a2c2c,color:#fff
Loading
Step Tool Input Output
Compile fsh-sushi input/fsh/*.fsh fsh-generated/resources/*.json
Build publisher.jar (IG Publisher) FHIR JSON + ig.ini + sushi-config.yaml output/ (full HTML site)
Validate IG Publisher QA output/qa.json or output/qa.html Pass / Fail
Deploy ssh-action + scp-action output/* Remote server directory

Extending the Workflow

Adding a Production deployment

To add a deploy_prod job, follow this checklist:

  • Create a production environment in Settings → Environments
  • Add required reviewers as a protection rule
  • Add the job to the workflow:
deploy_prod:
  name: CD - Deploy Production (Core IG)
  needs: [ci, deploy_dev, deploy_staging]
  runs-on: ubuntu-latest
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  environment: production
  steps:
    # ... same pattern as deploy_dev / deploy_staging
    # but targeting: ${{ secrets.DEPLOY_BASE }}/Production/Core/

Pinning IG Publisher version

Tip

Pinning prevents unexpected breakage from upstream IG Publisher releases.

Change the IG_PUBLISHER_VERSION env variable and update the download URL:

env:
  IG_PUBLISHER_VERSION: "1.6.0"
# In the download step — use a versioned release URL:
curl -fsSL \
  "https://github.com/HL7/fhir-ig-publisher/releases/download/${IG_PUBLISHER_VERSION}/publisher.jar" \
  -o "input-cache/publisher.jar"

Troubleshooting

❌ "IG Publisher did not create output/"

Cause: SUSHI compilation succeeded but the IG Publisher failed silently.

Check:

  • Look at the Build IG step logs for Java exceptions
  • Verify ig.ini points to the correct ig resource
  • Ensure sushi-config.yaml has valid id, url, and fhirVersion
❌ QA gate fails with error count > 0

Cause: The IG Publisher found validation errors in your FHIR resources.

Check:

  • Download the core-ig-output artifact and open qa.html locally
  • Look for ERROR-level entries — warnings do not fail the gate
  • Common causes: broken references, invalid codes, missing required elements
❌ SSH/SCP deployment fails with "Permission denied"

Cause: SSH key or server configuration issue.

Check:

  • Verify ADMIN_KEY secret contains the private key (not public)
  • Verify the key is in PEM format (-----BEGIN OPENSSH PRIVATE KEY-----)
  • Confirm the user has write access to $DEPLOY_BASE on the server
  • Check that ADMIN_PORT matches the server's SSH port
❌ Cache not restoring — slow builds

Cause: Cache key mismatch after config file changes.

Check:

  • The cache key hashes sushi-config.yaml, ig.ini, and package.json
  • Any change to these files invalidates the primary cache
  • Fallback keys should still provide partial cache hits
  • GitHub caches expire after 7 days of no access

Note

Last updated: 2026-02-27 · Source: build-ig.yml

Clone this wiki locally