name: FHIR IG CI/CD Pipeline with Version Persistence on: push: tags: - 'v*.*.*' pull_request: branches: [ main ] permissions: contents: read concurrency: group: fhir-ig-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: REGISTRY: git.dghs.gov.bd IMAGE_NAME: gitadmin/bd-core-fhir-ig defaults: run: shell: bash jobs: build-ig: runs-on: fhir-runner steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Extract version from IG id: version run: | set -euo pipefail VERSION="$(grep -oP '> "$GITHUB_OUTPUT" if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then BUILD_TYPE="release" TAG_VERSION="${GITHUB_REF#refs/tags/v}" if [[ "${TAG_VERSION}" != "${VERSION}" ]]; then echo "ERROR: Git tag version (${TAG_VERSION}) doesn't match IG version (${VERSION})" exit 1 fi else BUILD_TYPE="dev" fi echo "build_type=${BUILD_TYPE}" >> "$GITHUB_OUTPUT" echo "Build type: ${BUILD_TYPE}" - name: Prepare package-list.json and history.xml env: VERSION: ${{ steps.version.outputs.version }} BUILD_TYPE: ${{ steps.version.outputs.build_type }} run: | set -euo pipefail DATE="$(date +%Y-%m-%d)" export VERSION BUILD_TYPE DATE echo "Preparing package-list.json and history.xml..." if [[ ! -f package-list.json ]]; then echo "package-list.json not found in repo root. Creating initial file..." cat > package-list.json <<'PKGEOF' { "package-id": "bd.fhir.core", "title": "Bangladesh Core FHIR Implementation Guide", "canonical": "https://fhir.dghs.gov.bd/core", "introduction": "The Bangladesh Core FHIR IG defines national base profiles, value sets, and extensions for health data interoperability.", "list": [ { "version": "current", "desc": "Continuous Integration Build (latest in version control)", "path": "https://fhir.dghs.gov.bd/core/", "status": "ci-build", "current": true } ] } PKGEOF fi python3 <<'PYEOF' import json import os import sys from html import escape from pathlib import Path version = os.environ["VERSION"] date = os.environ["DATE"] build_type = os.environ["BUILD_TYPE"] pkg_path = Path("package-list.json") with pkg_path.open("r", encoding="utf-8") as f: pkg_list = json.load(f) if "list" not in pkg_list or not isinstance(pkg_list["list"], list): print("ERROR: package-list.json does not contain a valid 'list' array") sys.exit(1) if not any(e.get("version") == "current" for e in pkg_list["list"]): pkg_list["list"].insert(0, { "version": "current", "desc": "Continuous Integration Build (latest in version control)", "path": "https://fhir.dghs.gov.bd/core/", "status": "ci-build", "current": True }) if build_type == "release": existing = next((e for e in pkg_list["list"] if e.get("version") == version), None) if existing is None: new_entry = { "version": version, "date": date, "desc": f"Release {version}", "path": f"https://fhir.dghs.gov.bd/core/{version}/", "status": "trial-use", "sequence": "STU 1" } insert_index = 1 for i, entry in enumerate(pkg_list["list"]): if entry.get("version") == "current": insert_index = i + 1 break pkg_list["list"].insert(insert_index, new_entry) print(f"Added version {version} to package-list.json") else: print(f"Version {version} already exists in package-list.json") else: print("Dev build - keeping existing package-list.json structure") with pkg_path.open("w", encoding="utf-8") as f: json.dump(pkg_list, f, indent=2, ensure_ascii=False) Path("input").mkdir(parents=True, exist_ok=True) Path("input/package-list.json").write_text( pkg_path.read_text(encoding="utf-8"), encoding="utf-8" ) Path("input/pagecontent").mkdir(parents=True, exist_ok=True) def version_key(v: str): try: return tuple(int(x) for x in v.split(".")) except Exception: return (0,) published = [ e for e in pkg_list["list"] if e.get("version") and e.get("version") != "current" ] published.sort(key=lambda e: version_key(e.get("version", "0.0.0")), reverse=True) xml_parts = [ '', '
', '', '

This page provides the version history for the Bangladesh Core FHIR Implementation Guide.

', '', '

For a machine-readable version history see package-list.json.

', '', '

Published Versions

', '', '', '', '', '', '', '', '', '', '', '', ] if published: for idx, entry in enumerate(published): version_escaped = escape(entry.get("version", "Unknown")) date_escaped = escape(entry.get("date", "N/A")) desc_escaped = escape(entry.get("desc", "")) path_escaped = escape(entry.get("path", "#")) status_val = entry.get("status", "unknown") if status_val == "trial-use": status = 'Trial Use' elif status_val == "normative": status = 'Normative' else: status = f'{escape(status_val)}' badge = 'Latest ' if idx == 0 else '' xml_parts.extend([ '', f'', f'', f'', f'', '', ]) else: xml_parts.extend([ '', '', '', ]) xml_parts.extend([ '', '
VersionDateStatusDescription
{badge}{version_escaped}{date_escaped}{status}{desc_escaped}
No published versions available yet.
', '', '

Continuous Integration Build

', ]) current_entry = next((e for e in pkg_list["list"] if e.get("version") == "current"), None) if current_entry: path_escaped = escape(current_entry.get("path", pkg_list.get("canonical", "") + "/")) xml_parts.append( f'

The latest development build is available at: {path_escaped}

' ) else: xml_parts.append('

No CI build entry found in package-list.json.

') xml_parts.extend(['', '
', '']) Path("input/pagecontent/history.xml").write_text( "\n".join(xml_parts), encoding="utf-8" ) print("Generated input/pagecontent/history.xml") PYEOF python3 -m json.tool package-list.json > /dev/null test -f input/package-list.json test -f input/pagecontent/history.xml echo "Prepared files:" echo " - $(pwd)/package-list.json" echo " - $(pwd)/input/package-list.json" echo " - $(pwd)/input/pagecontent/history.xml" - name: Lightweight disk cleanup run: | set -euo pipefail echo "Disk usage before cleanup:" df -h AVAIL_GB="$(df -BG "$GITHUB_WORKSPACE" | awk 'NR==2 {gsub(/G/, "", $4); print $4}')" if [[ "${AVAIL_GB}" -lt 10 ]]; then echo "Low free disk detected (${AVAIL_GB}G). Running safe cleanup..." docker system prune -af || true rm -rf "${GITHUB_WORKSPACE}/output" \ "${GITHUB_WORKSPACE}/temp" \ "${GITHUB_WORKSPACE}/input-cache" \ "${GITHUB_WORKSPACE}/fsh-generated" || true rm -rf ~/.fhir/packages || true else echo "Sufficient disk available (${AVAIL_GB}G). Skipping aggressive cleanup." fi echo "Disk usage after cleanup:" df -h - name: Ensure Docker is available run: | set -euo pipefail if command -v docker >/dev/null 2>&1; then echo "Docker already installed: $(docker --version)" exit 0 fi echo "Docker not found. Installing..." sudo apt-get update sudo apt-get install -y docker.io docker --version - name: Preload previous IG package for comparison env: VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail echo "Detecting previous version..." PREV_VERSION="$( python3 <<'PY' import json import os current_build = os.environ.get("VERSION", "") with open("package-list.json", encoding="utf-8") as f: data = json.load(f) def parse(v): try: return tuple(int(x) for x in v.split(".")) except Exception: return (0,) versions = [ entry["version"] for entry in data.get("list", []) if entry.get("version") not in ("current", current_build) ] versions.sort(key=parse, reverse=True) print(versions[0] if versions else "") PY )" rm -rf previous-packages mkdir -p previous-packages if [[ -z "${PREV_VERSION}" ]]; then echo "No previous version found. Skipping preload." exit 0 fi echo "Previous version detected: ${PREV_VERSION}" TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT URL="https://fhir.dghs.gov.bd/core/${PREV_VERSION}/package.tgz" echo "Downloading ${URL}" curl -fL "${URL}" -o "${TMPDIR}/package.tgz" mkdir -p "previous-packages/bd.fhir.core#${PREV_VERSION}" tar -xzf "${TMPDIR}/package.tgz" -C "previous-packages/bd.fhir.core#${PREV_VERSION}" echo "Previous package cached:" ls -lah previous-packages - name: Build FHIR IG env: VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail echo "Building FHIR IG version ${VERSION}..." CONTAINER_ID="$( docker create \ -v "$(pwd)/previous-packages:/previous-packages" \ hl7fhir/ig-publisher-base:latest \ /bin/bash -lc ' set -euo pipefail mkdir -p /tmp/build cp -r /home/publisher/ig /tmp/build/ig cd /tmp/build/ig rm -f package-list.json _updatePublisher.sh -y _genonce.sh ' )" echo "Container ID: ${CONTAINER_ID}" cleanup() { docker rm -f "${CONTAINER_ID}" >/dev/null 2>&1 || true } trap cleanup EXIT docker cp "$(pwd)/." "${CONTAINER_ID}:/home/publisher/ig/" echo "Mounted previous packages:" ls -R previous-packages || echo "No previous packages directory" docker start -a "${CONTAINER_ID}" EXIT_CODE=$? echo "Copying outputs from container..." docker cp "${CONTAINER_ID}:/tmp/build/ig/output" ./output || echo "Warning: No output directory" docker cp "${CONTAINER_ID}:/tmp/build/ig/fsh-generated" ./fsh-generated || echo "No FSH generated" docker cp "${CONTAINER_ID}:/tmp/build/ig/input-cache" ./input-cache || echo "No input-cache" docker cp "${CONTAINER_ID}:/tmp/build/ig/temp" ./temp || echo "No temp directory" if [[ ${EXIT_CODE} -ne 0 ]]; then echo "Build failed." exit "${EXIT_CODE}" fi if [[ ! -f output/index.html ]]; then echo "ERROR: Build failed - output/index.html not found" exit 1 fi echo "Checking for history.html..." if [[ -f output/history.html ]]; then echo "history.html generated successfully" ls -lh output/history.html else echo "WARNING: history.html was not generated" fi echo "IG Publisher comparison log:" cat output/qa.compare.txt || echo "qa.compare.txt not found" echo "Build successful" - name: Update package-feed.xml for releases if: steps.version.outputs.build_type == 'release' env: VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail DATETIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" export DATETIME if [[ ! -f package-feed.xml ]]; then echo "package-feed.xml not found, creating initial feed..." cat > package-feed.xml < bd.fhir.core https://fhir.dghs.gov.bd/core/package-feed.xml ${DATETIME} EOF fi python3 <<'PYEOF' import os import xml.etree.ElementTree as ET version = os.environ["VERSION"] datetime_iso = os.environ["DATETIME"] ET.register_namespace('', 'http://www.w3.org/2005/Atom') tree = ET.parse('package-feed.xml') root = tree.getroot() ns = {'atom': 'http://www.w3.org/2005/Atom'} updated_elem = root.find('atom:updated', ns) if updated_elem is not None: updated_elem.text = datetime_iso entry_exists = False for entry in root.findall('atom:entry', ns): title = entry.find('atom:title', ns) if title is not None and version in (title.text or ''): entry_exists = True entry_updated = entry.find('atom:updated', ns) if entry_updated is not None: entry_updated.text = datetime_iso break if not entry_exists: new_entry = ET.Element('{http://www.w3.org/2005/Atom}entry') title = ET.SubElement(new_entry, '{http://www.w3.org/2005/Atom}title') title.text = f"bd.fhir.core version {version}" link = ET.SubElement(new_entry, '{http://www.w3.org/2005/Atom}link') link.set('rel', 'alternate') link.set('href', f"https://fhir.dghs.gov.bd/core/{version}/") entry_id = ET.SubElement(new_entry, '{http://www.w3.org/2005/Atom}id') entry_id.text = f"https://fhir.dghs.gov.bd/core/{version}/" entry_updated = ET.SubElement(new_entry, '{http://www.w3.org/2005/Atom}updated') entry_updated.text = datetime_iso summary = ET.SubElement(new_entry, '{http://www.w3.org/2005/Atom}summary') summary.text = f"Release {version} of Bangladesh Core FHIR Implementation Guide" insert_pos = 0 for i, child in enumerate(root): if child.tag.endswith('entry'): insert_pos = i break insert_pos = i + 1 root.insert(insert_pos, new_entry) tree.write('output/package-feed.xml', encoding='utf-8', xml_declaration=True) print("Updated package-feed.xml") PYEOF cp package-list.json output/package-list.json echo "Updated registry files" - name: Prepare deployment artifact run: | set -euo pipefail VERSION="${{ steps.version.outputs.version }}" BUILD_TYPE="${{ steps.version.outputs.build_type }}" BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" tar -czf ig-output.tar.gz -C output . { echo "version=${VERSION}" echo "build_type=${BUILD_TYPE}" echo "build_date=${BUILD_DATE}" } > deployment.env echo "Output contents:" ls -lh output/ | grep -E "(history\.html|package-list\.json|package-feed\.xml|index\.html)" || echo "Some files may be missing" ls -lh ig-output.tar.gz - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ig-output path: | ig-output.tar.gz deployment.env package-list.json package-feed.xml if-no-files-found: warn retention-days: 30 deploy: needs: build-ig runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') steps: - name: Download artifact uses: actions/download-artifact@v4 with: name: ig-output - name: Load deployment env id: deploy_info run: | set -euo pipefail source deployment.env echo "version=${version}" >> "$GITHUB_OUTPUT" echo "build_type=${build_type}" >> "$GITHUB_OUTPUT" echo "build_date=${build_date}" >> "$GITHUB_OUTPUT" echo "Deploying version: ${version}" echo "Build type: ${build_type}" - name: Deploy files to server uses: appleboy/scp-action@v0.1.7 with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} password: ${{ secrets.DEPLOY_PASSWORD }} port: ${{ secrets.DEPLOY_PORT || 22 }} source: "ig-output.tar.gz,deployment.env,package-list.json,package-feed.xml" target: "/tmp/fhir-ig-deploy/" - name: Execute deployment on server uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} password: ${{ secrets.DEPLOY_PASSWORD }} port: ${{ secrets.DEPLOY_PORT || 22 }} script: | set -euo pipefail source /tmp/fhir-ig-deploy/deployment.env echo "==========================================" echo "Deploying FHIR IG" echo "Version: $version" echo "Build Type: $build_type" echo "Build Date: $build_date" echo "==========================================" VERSIONS_DIR="/opt/fhir-ig/versions" mkdir -p "$VERSIONS_DIR" if [[ "$build_type" == "release" ]]; then TARGET_DIR="$VERSIONS_DIR/$version" echo "Deploying release version to: $TARGET_DIR" else TARGET_DIR="$VERSIONS_DIR/dev" echo "Deploying dev build to: $TARGET_DIR" mkdir -p "$TARGET_DIR" echo "Cleaning old dev files..." rm -rf "$TARGET_DIR"/* fi mkdir -p "$TARGET_DIR" echo "Extracting IG output..." tar -xzf /tmp/fhir-ig-deploy/ig-output.tar.gz -C "$TARGET_DIR" if [[ -f "$TARGET_DIR/history.html" ]]; then echo "history.html deployed successfully" else echo "WARNING: history.html not found in deployment" fi if [[ -f "$TARGET_DIR/package-list.json" ]]; then cp "$TARGET_DIR/package-list.json" "$VERSIONS_DIR/package-list.json" cp "$TARGET_DIR/package-list.json" "/opt/fhir-ig/package-list.json" elif [[ -f /tmp/fhir-ig-deploy/package-list.json ]]; then cp /tmp/fhir-ig-deploy/package-list.json "$VERSIONS_DIR/package-list.json" cp /tmp/fhir-ig-deploy/package-list.json "/opt/fhir-ig/package-list.json" fi if [[ -f "$TARGET_DIR/package-feed.xml" ]]; then cp "$TARGET_DIR/package-feed.xml" "$VERSIONS_DIR/package-feed.xml" cp "$TARGET_DIR/package-feed.xml" "/opt/fhir-ig/package-feed.xml" elif [[ -f /tmp/fhir-ig-deploy/package-feed.xml ]]; then cp /tmp/fhir-ig-deploy/package-feed.xml "$VERSIONS_DIR/package-feed.xml" cp /tmp/fhir-ig-deploy/package-feed.xml "/opt/fhir-ig/package-feed.xml" fi if [[ "$build_type" == "release" ]]; then echo "Updating current symlink to point to $version" ln -sfn "$version" "$VERSIONS_DIR/current" fi cd /opt/fhir-ig if [[ ! -f docker-compose.prod.yml ]]; then echo "ERROR: docker-compose.prod.yml not found!" exit 1 fi docker compose -f docker-compose.prod.yml up -d --force-recreate fhir-ig rm -rf /tmp/fhir-ig-deploy echo "==========================================" echo "Deployment completed successfully" if [[ "$build_type" == "release" ]]; then echo " - https://fhir.dghs.gov.bd/core/$version/" echo " - https://fhir.dghs.gov.bd/core/$version/history.html" echo " - https://fhir.dghs.gov.bd/core/ (current)" else echo " - https://fhir.dghs.gov.bd/core/dev/" fi echo "==========================================" echo "Available versions:" ls -lh "$VERSIONS_DIR" | grep -v total || true