-
Notifications
You must be signed in to change notification settings - Fork 14
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.
- Overview
- Triggers
- Pipeline Architecture
- Jobs
- Server Directory Structure
- Required Secrets
- Required GitHub Environments
- Build Toolchain
- Extending the Workflow
- Troubleshooting
This GitHub Actions workflow automates the full CI/CD lifecycle for the PH Core FHIR Implementation Guide:
- Compile — FSH sources → FHIR JSON via SUSHI
- Build — FHIR JSON → HTML IG site via IG Publisher
- Validate — QA gate checks for zero errors
- 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.
| 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: trueflowchart 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
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
Compiles FSH, builds the IG, validates QA, and uploads the output as an artifact.
| 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 |
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
📦 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
[!CAUTION] If neither
qa.jsonnorqa.htmlis found inoutput/, the build fails for safety. This prevents silent deployment of un-validated content.
| 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
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 {} \;| 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
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
| 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.
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 |
| 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.
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
| 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 |
To add a deploy_prod job, follow this checklist:
- Create a
productionenvironment 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/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"❌ "IG Publisher did not create output/"
Cause: SUSHI compilation succeeded but the IG Publisher failed silently.
Check:
- Look at the
Build IGstep logs for Java exceptions - Verify
ig.inipoints to the correctigresource - Ensure
sushi-config.yamlhas validid,url, andfhirVersion
❌ QA gate fails with error count > 0
Cause: The IG Publisher found validation errors in your FHIR resources.
Check:
- Download the
core-ig-outputartifact and openqa.htmllocally - 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_KEYsecret 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_BASEon the server - Check that
ADMIN_PORTmatches 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, andpackage.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