feat(workflows): pipeline runtime, encounter binding, and clinical tasks (CYN-64/65/66) #130
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Pipeline | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened, closed] | |
| workflow_dispatch: | |
| inputs: | |
| dry_run: | |
| description: "Only list matching environments without deleting" | |
| type: boolean | |
| default: true | |
| pr_number_filter: | |
| description: "Only delete environments for this PR number (empty = all)" | |
| type: string | |
| default: "" | |
| concurrency: | |
| group: pipeline-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event.action != 'closed' }} | |
| permissions: | |
| contents: read | |
| checks: read | |
| pull-requests: write | |
| env: | |
| DOTNET_CONFIGURATION: Release | |
| DOTNET_NOLOGO: true | |
| DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true | |
| DOTNET_CLI_TELEMETRY_OPTOUT: true | |
| jobs: | |
| check: | |
| name: Format, lint, and test | |
| runs-on: ubuntu-latest | |
| if: github.event.action != 'closed' | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup .NET and restore | |
| uses: ./.github/actions/setup-dotnet-and-restore | |
| - name: Restore Cake runner | |
| run: dotnet tool restore | |
| - name: Format check | |
| run: dotnet cake --target=FormatCheck | |
| - name: Lint | |
| run: dotnet cake --target=Lint --configuration=${{ env.DOTNET_CONFIGURATION }} | |
| - name: Verify Docker | |
| run: docker info | |
| - name: Test | |
| env: | |
| TESTCONTAINERS_RYUK_DISABLED: "true" | |
| run: dotnet cake --target=Test --configuration=${{ env.DOTNET_CONFIGURATION }} | |
| acceptance: | |
| name: E2E acceptance suite | |
| runs-on: ubuntu-latest | |
| if: github.event.action != 'closed' | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup .NET and restore | |
| uses: ./.github/actions/setup-dotnet-and-restore | |
| - name: Restore Cake runner | |
| run: dotnet tool restore | |
| - name: Verify Docker | |
| run: docker info | |
| - name: E2E acceptance tests | |
| env: | |
| TESTCONTAINERS_RYUK_DISABLED: "true" | |
| run: dotnet cake --target=TestE2E --configuration=${{ env.DOTNET_CONFIGURATION }} | |
| neon-upsert: | |
| name: Provision Neon branch and Render preview | |
| runs-on: ubuntu-latest | |
| if: github.event.action != 'closed' | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Compute Neon branch name | |
| id: name | |
| uses: ./.github/actions/compute-neon-branch-name | |
| with: | |
| head_ref: ${{ github.head_ref }} | |
| pr_number: ${{ github.event.number }} | |
| - name: Create Neon branch | |
| id: create | |
| uses: neondatabase/create-branch-action@v5 | |
| with: | |
| project_id: ${{ vars.NEON_PROJECT_ID }} | |
| branch_name: ${{ steps.name.outputs.name }} | |
| api_key: ${{ secrets.NEON_API_KEY }} | |
| - name: Add render-preview label | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| labels: ['render-preview'], | |
| }); | |
| - name: Wait for Render preview service | |
| id: preview | |
| env: | |
| RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} | |
| RENDER_SERVICE_BASE: cynara-api | |
| PR_NUMBER: ${{ github.event.number }} | |
| run: | | |
| set -euo pipefail | |
| base="https://api.render.com/v1" | |
| auth_header="Authorization: Bearer ${RENDER_API_KEY}" | |
| preview_name="${RENDER_SERVICE_BASE} PR #${PR_NUMBER}" | |
| deadline=$(( $(date +%s) + 300 )) | |
| service_id="" | |
| while [ "$(date +%s)" -lt "$deadline" ]; do | |
| response=$(curl -sS -G -w "\n%{http_code}" \ | |
| -H "$auth_header" \ | |
| -H "Accept: application/json" \ | |
| --data-urlencode "name=${preview_name}" \ | |
| --data "limit=20" \ | |
| --data "includePreviews=true" \ | |
| "${base}/services") | |
| code=$(printf '%s' "$response" | tail -n1) | |
| body=$(printf '%s' "$response" | sed '$d') | |
| if [ "$code" != "200" ]; then | |
| echo "Render API returned ${code}, retrying in 10s..." | |
| sleep 10 | |
| continue | |
| fi | |
| service_id=$(printf '%s' "$body" \ | |
| | jq -r --arg n "${preview_name}" \ | |
| '.[] | select(.service.name == $n) | .service.id' \ | |
| | head -n1) | |
| if [ -n "$service_id" ] && [ "$service_id" != "null" ]; then | |
| break | |
| fi | |
| sleep 10 | |
| done | |
| if [ -z "$service_id" ] || [ "$service_id" = "null" ]; then | |
| echo "::error::Could not find Render preview service '${preview_name}' after 5 minutes." | |
| echo "::error::If previews are configured with manual generation in the Render dashboard, this usually means Render did not pick up the render-preview label." | |
| exit 1 | |
| fi | |
| echo "service_id=${service_id}" >> "$GITHUB_OUTPUT" | |
| - name: Write Neon connection string into Render preview | |
| env: | |
| RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} | |
| RENDER_SERVICE_ID: ${{ steps.preview.outputs.service_id }} | |
| DB_URL: ${{ steps.create.outputs.db_url_with_pooler }} | |
| run: | | |
| set -euo pipefail | |
| base="https://api.render.com/v1" | |
| auth_header="Authorization: Bearer ${RENDER_API_KEY}" | |
| CS=$(DB_URL="${DB_URL}" python3 - <<'PY' | |
| import os, sys, urllib.parse as u | |
| raw = os.environ["DB_URL"] | |
| p = u.urlparse(raw) | |
| qs = dict(u.parse_qsl(p.query, keep_blank_values=True)) | |
| def grab(*keys): | |
| for k in keys: | |
| if k in qs and qs[k] != "": | |
| return qs[k] | |
| return None | |
| sslmode = (grab("sslmode", "ssl") or "require").lower() | |
| if sslmode not in ("disable", "prefer", "require", "verify-ca", "verify-full"): | |
| # Npgsql only accepts the three legacy values; neon uses `require`. | |
| sslmode = "require" | |
| channel = (grab("channel_binding") or "require").lower() | |
| if channel not in ("disable", "prefer", "require"): | |
| channel = "require" | |
| password = u.unquote(p.password or "") | |
| username = u.unquote(p.username or "") | |
| database = (p.path or "").lstrip("/") or "" | |
| host = p.hostname or "" | |
| port = p.port or 5432 | |
| def q(v): | |
| return "'" + str(v).replace("'", "''") + "'" | |
| parts = [ | |
| f"Host={q(host)}", | |
| f"Port={port}", | |
| f"Database={q(database)}", | |
| f"Username={q(username)}", | |
| f"Password={q(password)}", | |
| f"SSL Mode={sslmode}", | |
| f"Channel Binding={channel}", | |
| "Trust Server Certificate=true", | |
| ] | |
| sys.stdout.write("; ".join(parts)) | |
| PY | |
| ) | |
| curl -fsS -X PUT \ | |
| -H "$auth_header" \ | |
| -H "Content-Type: application/json" \ | |
| --data "$(jq -n --arg v "${CS}" '{value: $v}')" \ | |
| "${base}/services/${RENDER_SERVICE_ID}/env-vars/ConnectionStrings__Default" | |
| - name: Comment on PR | |
| uses: marocchino/sticky-pull-request-comment@v2 | |
| with: | |
| header: neon-preview | |
| message: Neon branch ready. | |
| neon-delete: | |
| name: Delete Neon branch and PR environment | |
| runs-on: ubuntu-latest | |
| if: github.event.action == 'closed' | |
| permissions: | |
| contents: read | |
| deployments: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: main | |
| fetch-depth: 1 | |
| - name: Compute Neon branch name | |
| id: name | |
| uses: ./.github/actions/compute-neon-branch-name | |
| with: | |
| head_ref: ${{ github.head_ref }} | |
| pr_number: ${{ github.event.number }} | |
| - name: Delete Neon branch | |
| uses: neondatabase/delete-branch-action@v3 | |
| with: | |
| project_id: ${{ vars.NEON_PROJECT_ID }} | |
| branch: ${{ steps.name.outputs.name }} | |
| api_key: ${{ secrets.NEON_API_KEY }} | |
| cleanup-neon-environments: | |
| name: Cleanup orphan Neon PR environments | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'workflow_dispatch' | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: List and delete Neon PR environments | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| DRY_RUN: ${{ inputs.dry_run }} | |
| PR_NUMBER_FILTER: ${{ inputs.pr_number_filter }} | |
| run: | | |
| set -euo pipefail | |
| list_envs() { | |
| local page=1 | |
| while :; do | |
| local body status | |
| body=$(mktemp) | |
| status=$(curl -sS -o "$body" -w '%{http_code}' \ | |
| -H "Authorization: Bearer ${GITHUB_TOKEN}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/environments?per_page=100&page=${page}") | |
| if [ "$status" != "200" ]; then | |
| echo "::error::Failed to list environments (status ${status})." | |
| cat "$body" | |
| exit 1 | |
| fi | |
| jq -r '.environments[]?.name // empty' < "$body" | |
| local count | |
| count=$(jq -r '.environments | length // 0' < "$body") | |
| rm -f "$body" | |
| if [ "$count" -lt 100 ]; then | |
| break | |
| fi | |
| page=$((page + 1)) | |
| done | |
| } | |
| names=$(list_envs) | |
| pattern=' - cynara-api PR #' | |
| if [ -n "${PR_NUMBER_FILTER}" ]; then | |
| names=$(printf '%s\n' "$names" | grep -F "${pattern}${PR_NUMBER_FILTER}\$" || true) | |
| else | |
| names=$(printf '%s\n' "$names" | grep -F "$pattern" || true) | |
| fi | |
| if [ -z "$names" ]; then | |
| echo "No matching Neon PR environments found." | |
| exit 0 | |
| fi | |
| echo "Matched environments:" | |
| printf ' - %s\n' $names | |
| if [ "${DRY_RUN}" = "true" ]; then | |
| echo "::notice::Dry run — set inputs.dry_run=false to actually delete." | |
| exit 0 | |
| fi | |
| failures=0 | |
| while IFS= read -r name; do | |
| [ -z "$name" ] && continue | |
| encoded=$(jq -rn --arg n "$name" '$n | @uri') | |
| body=$(mktemp) | |
| status=$(curl -sS -o "$body" -w '%{http_code}' \ | |
| -X DELETE \ | |
| -H "Authorization: Bearer ${GITHUB_TOKEN}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/environments/${encoded}") | |
| case "$status" in | |
| 204|404) | |
| echo "Removed: ${name} (status ${status})" | |
| ;; | |
| *) | |
| echo "::error::Failed to delete ${name} (status ${status})." | |
| cat "$body" | |
| failures=$((failures + 1)) | |
| ;; | |
| esac | |
| rm -f "$body" | |
| done <<< "$names" | |
| if [ "$failures" -gt 0 ]; then | |
| echo "::error::${failures} environment(s) failed to delete." | |
| exit 1 | |
| fi |